Compare commits
26
Commits
v1.0.0-RELEASE
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
739651d548 | ||
|
|
3577b800d1 | ||
|
|
d5295772cb | ||
|
|
58e916ecf8 | ||
|
|
493bfed599 | ||
|
|
fcb19c96c8 | ||
|
|
9bbd9ceccf | ||
|
|
b2fc84846c | ||
|
|
18aa193039 | ||
|
|
ea6a9743d1 | ||
|
|
fb230ee35d | ||
|
|
74f16bfd0d | ||
|
|
2eed4ea548 | ||
|
|
759485fc47 | ||
|
|
cb76b77f1b | ||
|
|
c2aa0349fc | ||
|
|
1ea24bf7fa | ||
|
|
0b6f611110 | ||
|
|
a7cf28b404 | ||
|
|
3ff04feb92 | ||
|
|
96fedb9348 | ||
|
|
523d9b02b4 | ||
|
|
421bdb9ee8 | ||
|
|
c9b5eb10eb | ||
|
|
96c603f3f4 | ||
|
|
23aaa1b53e |
@@ -64,10 +64,10 @@ jobs:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v2)
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
|
||||
@@ -11,6 +11,8 @@ dist/
|
||||
|
||||
# ── Go ────────────────────────────────────────────────────────────────────
|
||||
vendor/
|
||||
.tools/
|
||||
.cache/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
# Security Audit Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This audit reviewed Nexus Terminal, a Wails desktop SSH/Telnet/RAW/VNC/SFTP client with a Go backend and JavaScript frontend. Fix mode was requested, so confirmed issues were patched, regression-tested, and prepared for commit.
|
||||
|
||||
The highest-risk issues were SSH trust failures: regular SSH sessions were not wired to the existing known-host verification flow, jump-host SSH connections ignored host keys, and VNC SSH tunnels also ignored host keys. These could allow machine-in-the-middle attacks against SSH credentials and terminal/VNC traffic. This run wired all SSH paths to known-host verification and prompt handling.
|
||||
|
||||
Other fixed issues include a local noVNC WebSocket proxy access-control weakness, SSH key-name path traversal in public-key reads, unsafe update-result DOM rendering, inline secret leakage in non-secret profile exports, and current vulnerable dependency versions in `golang.org/x/crypto` and `esbuild`.
|
||||
|
||||
Residual risk remains in user-selected insecure modes such as direct unencrypted VNC, user-configured `HostKeyInsecure`, and large local file transfer workflows that still process complete files in memory.
|
||||
|
||||
## 2. Scope Reviewed
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Go backend packages under `internal/`: API bindings, sessions, SSH, VNC, SFTP, storage, secrets, updater, raw/telnet clients, recording, validation, known hosts, config, app startup.
|
||||
- Frontend JavaScript under `frontend/src/js`: settings/update UI, host-key event handling, terminal and SFTP/VNC/GitHub surfaces, common DOM helpers.
|
||||
- Dependency manifests and lockfiles: `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
|
||||
- Build and release scripts: `Makefile`, `build.sh`, `release.sh`, Wails config.
|
||||
- Tests under `internal/**/*_test.go`.
|
||||
|
||||
Not in scope:
|
||||
|
||||
- Live network penetration testing of remote hosts.
|
||||
- Reverse engineering packaged binaries.
|
||||
- Manual verification of Wails runtime origin values in a built app.
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
- Static source review of trust boundaries, file operations, SSH/VNC flows, Wails API bindings, frontend DOM sinks, update checks, storage, crypto, and secret handling.
|
||||
- Dependency review with `govulncheck` and `npm audit`.
|
||||
- Configuration review of Wails, build scripts, update manifests, and release paths.
|
||||
- Threat modelling of desktop renderer compromise, local web-origin attacks, remote SSH/VNC attackers, and malicious update metadata.
|
||||
- Denial-of-service review of network timeouts, WebSocket proxy behavior, transfer paths, and file ingestion.
|
||||
- Regression tests added for fixed Go issues.
|
||||
|
||||
## 4. Risk Rating Method
|
||||
|
||||
Severity considered exploitability, attacker position, affected security property, and impact:
|
||||
|
||||
- Critical: practical remote compromise or credential disclosure without meaningful preconditions.
|
||||
- High: credential/session compromise or machine-in-the-middle exposure on primary connection paths.
|
||||
- Medium: meaningful local, renderer, or network-assisted exploit with constraints.
|
||||
- Low: hardening issue, local DoS, or user-assisted exposure.
|
||||
- Informational: checked area or non-security note.
|
||||
|
||||
Confidence reflects direct code evidence and verification coverage.
|
||||
|
||||
## 5. Findings Summary Table
|
||||
|
||||
| ID | Severity | Confidence | Title | Component | Status |
|
||||
|---|---:|---:|---|---|---|
|
||||
| SEC-001 | High | High | SSH sessions and jump hosts bypassed known-host verification | SSH sessions | Fixed |
|
||||
| SEC-002 | High | High | VNC SSH tunnels accepted any SSH host key | VNC over SSH | Fixed |
|
||||
| SEC-003 | Medium | High | Local noVNC WebSocket proxy used a guessable root endpoint | VNC WebSocket proxy | Fixed |
|
||||
| SEC-004 | Medium | High | SSH public-key read accepted traversal key names | SSH key API | Fixed |
|
||||
| SEC-005 | Medium | High | Update metadata rendered through dynamic HTML and inline handler | Settings/update UI | Fixed |
|
||||
| SEC-006 | Medium | High | Non-secret profile export leaked inline SSH secrets | Profile export | Fixed |
|
||||
| SEC-007 | High | High | `golang.org/x/crypto` vulnerable in reachable SSH code | Go dependencies | Fixed |
|
||||
| SEC-008 | Medium | High | `esbuild` dev dependency matched current npm advisory | Frontend dependency | Fixed |
|
||||
| SEC-009 | Low | Medium | Large local file upload paths remain memory-heavy | File transfer UX | Open |
|
||||
| SEC-010 | Low | Medium | Direct VNC remains unencrypted by protocol choice | VNC direct mode | Open |
|
||||
|
||||
## 6. Detailed Findings
|
||||
|
||||
### Finding ID: SEC-001
|
||||
|
||||
#### Title
|
||||
|
||||
SSH sessions and jump hosts bypassed known-host verification
|
||||
|
||||
#### Severity
|
||||
|
||||
High
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/sessions/manager.go`, SSH connection setup, now fixed at lines 657 and 1450-1502.
|
||||
- `internal/sshclient/sshclient.go`, jump-host config, now fixed at lines 599 and 646-652.
|
||||
- `frontend/src/js/app.js`, host-key response correlation, now fixed at lines 334-342.
|
||||
|
||||
#### Description
|
||||
|
||||
The project already contained known-host storage, a host-key verifier, and frontend prompt UI, but the main SSH session manager previously called the SSH client's default `Connect` path. That default path used `ssh.InsecureIgnoreHostKey()`. Jump hosts also built their own `ssh.ClientConfig` with `ssh.InsecureIgnoreHostKey()`.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fix now calls `ConnectWithHostKeyCallback(...)` for regular SSH sessions and propagates the final host-key callback into jump-host configs. A request ID is included in host-key prompt responses to prevent cross-session prompt confusion.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
An attacker on the network path presents a fake SSH server or jump host. Without host-key verification, Nexus Terminal would accept the attacker's key, allowing credential capture, terminal tampering, or traffic interception.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible SSH credential theft, terminal command tampering, and loss of server authenticity.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Always use the known-host verifier for every SSH hop. Unknown host keys should follow the configured profile policy: strict, ask, accept-new, or explicit insecure.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `Manager.hostKeyCallback` builds `sshclient.NewHostKeyVerifier(...)`, emits a prompt when needed, and `connectSSH` calls `ConnectWithHostKeyCallback(...)`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added regression coverage:
|
||||
|
||||
- `TestHostKeyCallbackAcceptNewStoresHost`
|
||||
- `TestHostKeyCallbackStrictRejectsUnknownHost`
|
||||
- `TestJumpHostClientConfigUsesFinalHostKeyCallback`
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-295: Improper Certificate Validation, analogous host-key trust failure.
|
||||
- OWASP ASVS 9.1.2: server certificate validation principles.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
Profiles may still explicitly choose `HostKeyInsecure`; that is a user-selected insecure mode and remains visible as residual risk.
|
||||
|
||||
### Finding ID: SEC-002
|
||||
|
||||
#### Title
|
||||
|
||||
VNC SSH tunnels accepted any SSH host key
|
||||
|
||||
#### Severity
|
||||
|
||||
High
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/vncclient/vncclient.go`, VNC SSH config, now fixed at lines 49 and 89-95.
|
||||
- `internal/sessions/manager.go`, VNC SSH callback wiring, now fixed at line 1016.
|
||||
|
||||
#### Description
|
||||
|
||||
VNC SSH tunnels established SSH connections with `ssh.InsecureIgnoreHostKey()`, allowing a network attacker to impersonate the SSH tunnel endpoint.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The VNC client now requires `SSHHostKeyCallback` for tunneled SSH. The session manager supplies the same known-host/prompt path used by SSH sessions.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
An attacker controlling DNS, Wi-Fi, or a gateway presents a fake SSH tunnel host for VNC traffic. The prior client accepted the key and tunneled VNC traffic through the attacker.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible VNC credential exposure, remote desktop traffic interception, and remote interaction tampering.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Require known-host verification for VNC SSH tunnels and reject tunnel setup if no callback is configured.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `vncclient.Connect` returns an error if `cfg.SSHHostKeyCallback` is nil, and uses the provided callback in `ssh.ClientConfig`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Covered indirectly by session host-key callback tests and VNC package tests.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-295.
|
||||
- OWASP ASVS 9.1.2.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
VNC tunnel host-key policy defaults to ask mode because VNC profiles do not currently expose a separate host-key policy field.
|
||||
|
||||
### Finding ID: SEC-003
|
||||
|
||||
#### Title
|
||||
|
||||
Local noVNC WebSocket proxy used a guessable root endpoint
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/vncclient/vncclient.go`, WebSocket proxy setup, now fixed at lines 171-183 and 255-260.
|
||||
|
||||
#### Description
|
||||
|
||||
The local VNC WebSocket proxy listened on `127.0.0.1` with a random port, but accepted upgrades on `/` and allowed any Origin. A malicious local web page could scan localhost ports and attempt to connect to an active proxy.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The proxy now generates a 128-bit random token and exposes only `ws://127.0.0.1:<port>/<token>` to the Wails frontend.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user visits a malicious web page while a VNC session is active. The page scans localhost WebSocket ports and connects to the proxy endpoint, potentially reading or injecting VNC traffic.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible local cross-origin access to VNC session traffic if the port is discovered.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Require an unguessable per-session URL token and continue binding only to loopback.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `newProxyToken()` uses `crypto/rand` and the proxy route is registered only at the token path.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added `TestNewProxyTokenIsRandomHex`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-346: Origin Validation Error.
|
||||
- OWASP ASVS 14.4.3: cross-origin resource restrictions.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
`CheckOrigin` still returns true because Wails/noVNC origin behavior needs built-app verification. The unguessable path materially reduces exposure.
|
||||
|
||||
### Finding ID: SEC-004
|
||||
|
||||
#### Title
|
||||
|
||||
SSH public-key read accepted traversal key names
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`, SSH key API, now fixed at lines 1653-1708.
|
||||
|
||||
#### Description
|
||||
|
||||
`GetSSHPublicKey(name)` previously built `~/.ssh/<name>.pub` directly from a renderer-provided name. Unlike key generation, it did not restrict names to simple key basenames. Names such as `../somefile` could read `~/.ssh/../somefile.pub`.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fix centralizes key name validation through `sshKeyPairPaths(...)`, allowing only alphanumeric characters, hyphens, and underscores.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
If renderer-side JavaScript is compromised or a malicious plugin/flow can invoke the Wails API, it can request public-key contents outside the intended `.ssh` key namespace if the target path ends in `.pub`.
|
||||
|
||||
#### Impact
|
||||
|
||||
Local file disclosure for matching `.pub` paths under paths reachable through traversal.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Treat key names as basenames, not paths. Apply the same validation to generate, read, and delete operations.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `GetSSHPublicKey`, `GenerateSSHKey`, and `DeleteSSHKey` now all use `sshKeyPairPaths`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added:
|
||||
|
||||
- `TestSSHKeyPairPathsAcceptsSafeName`
|
||||
- `TestSSHKeyPairPathsRejectsTraversal`
|
||||
- `TestSSHKeyPairPathsRejectsNestedPath`
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-22: Path Traversal.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The API still intentionally lists and reads legitimate SSH public keys in `~/.ssh`.
|
||||
|
||||
### Finding ID: SEC-005
|
||||
|
||||
#### Title
|
||||
|
||||
Update metadata rendered through dynamic HTML and inline handler
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/src/js/settings.js`, update-check UI, now fixed at lines 940-958.
|
||||
|
||||
#### Description
|
||||
|
||||
Update metadata from GitHub or a configured update manifest was inserted into `innerHTML`, including an inline `onclick` that interpolated `info.releaseURL`. A malicious or compromised update metadata source could inject script into the Wails renderer.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fix constructs DOM nodes, sets text via `textContent`, and attaches a click listener with `addEventListener`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
An attacker controlling a self-hosted update manifest returns a release URL containing a quote and JavaScript payload. The prior inline handler interpolation could execute renderer JavaScript.
|
||||
|
||||
#### Impact
|
||||
|
||||
Renderer code execution. In a Wails app, renderer execution can call exposed backend APIs, increasing impact.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Never render remote metadata through string-built HTML. Use `textContent` and event listeners.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented in `settings.js` with `createElement`, `document.createTextNode`, and `addEventListener`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
No frontend unit harness exists. Verified with `npm run build`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-79: Cross-Site Scripting.
|
||||
- OWASP ASVS 5.3: output encoding.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
Other static modal templates still use `innerHTML`; reviewed high-risk dynamic remote metadata paths first.
|
||||
|
||||
### Finding ID: SEC-006
|
||||
|
||||
#### Title
|
||||
|
||||
Non-secret profile export leaked inline SSH secrets
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`, Wails export wrapper, now fixed at lines 683-684.
|
||||
- `internal/storage/store.go`, storage export, now fixed at lines 356-358 and 726-738.
|
||||
|
||||
#### Description
|
||||
|
||||
The plain profile export path accepted `includeSecrets=false` but profiles can still contain inline `ssh.proxyPassword` and `ssh.totpSecret` fields. Those fields could be serialized despite a non-secret export request.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fix redacts `ProxyPassword` and `TOTPSecret` for non-secret exports in both API and storage layers.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user creates a plain profile export believing secrets are excluded. The exported JSON contains proxy credentials or TOTP secrets that are then shared, backed up, or stored insecurely.
|
||||
|
||||
#### Impact
|
||||
|
||||
Credential leakage and potential 2FA bypass for affected profiles.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Redact inline secret fields whenever `includeSecrets=false`. Prefer encrypted export for any export that intentionally includes secrets.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `redactProfileSecrets(...)` in storage and API packages.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added:
|
||||
|
||||
- `TestExportProfilesRedactsInlineSSHSecrets`
|
||||
- `TestRedactProfileSecretsClearsInlineSSHSecrets`
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-200: Exposure of Sensitive Information.
|
||||
- OWASP ASVS 8.3: sensitive data protection.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The encrypted export path intentionally includes selected profile data and is password-protected.
|
||||
|
||||
### Finding ID: SEC-007
|
||||
|
||||
#### Title
|
||||
|
||||
`golang.org/x/crypto` vulnerable in reachable SSH code
|
||||
|
||||
#### Severity
|
||||
|
||||
High
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `go.mod`, `golang.org/x/crypto`, now fixed at line 14.
|
||||
- `go.mod`, indirect `golang.org/x/sys`, now updated at line 45.
|
||||
- Reachable SSH code in `internal/sshclient`, `internal/vncclient`, `internal/sftpclient`.
|
||||
|
||||
#### Description
|
||||
|
||||
`govulncheck` reported seven reachable vulnerabilities in `golang.org/x/crypto@v0.51.0`, including SSH DoS/panic issues, FIDO/U2F security-key interaction bypass risk, and SSH agent forwarding constraint handling issues.
|
||||
|
||||
#### Evidence
|
||||
|
||||
`govulncheck` identified GO-2026-5033, GO-2026-5020, GO-2026-5019, GO-2026-5018, GO-2026-5017, GO-2026-5013, and GO-2026-5006 as reachable before the update. The dependency is now `v0.52.0`, and rerun output reports: "No vulnerabilities found. Your code is affected by 0 vulnerabilities."
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A malicious or compromised SSH server, SSH agent, key material, or protocol peer triggers vulnerable SSH library behavior during connection, channel use, key parsing, or agent forwarding.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible client panic, denial of service, weakened security-key interaction, or agent forwarding constraint mishandling depending on the CVE path.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep `golang.org/x/crypto` at or above `v0.52.0` and run `govulncheck` in CI.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `go get golang.org/x/crypto@v0.52.0`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Ran `govulncheck` and `go test ./...`.
|
||||
|
||||
#### References
|
||||
|
||||
- https://pkg.go.dev/vuln/GO-2026-5033
|
||||
- https://pkg.go.dev/vuln/GO-2026-5020
|
||||
- https://pkg.go.dev/vuln/GO-2026-5019
|
||||
- https://pkg.go.dev/vuln/GO-2026-5018
|
||||
- https://pkg.go.dev/vuln/GO-2026-5017
|
||||
- https://pkg.go.dev/vuln/GO-2026-5013
|
||||
- https://pkg.go.dev/vuln/GO-2026-5006
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
`govulncheck` also noted vulnerabilities in required modules that are not called by this code.
|
||||
|
||||
### Finding ID: SEC-008
|
||||
|
||||
#### Title
|
||||
|
||||
`esbuild` dev dependency matched current npm advisory
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/package.json`, `esbuild`, now fixed at line 22.
|
||||
- `frontend/package-lock.json`.
|
||||
|
||||
#### Description
|
||||
|
||||
`npm audit` reported GHSA-67mh-4wv8-2f99 for `esbuild <=0.24.2`: any website could send requests to the dev server and read responses under affected versions.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The frontend previously resolved `esbuild@0.24.2`. It now uses `^0.28.0`, and `npm audit --json` reports zero vulnerabilities.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
During local development, a malicious website could interact with the esbuild dev server and read responses if a developer runs an affected dev server.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible local development data exposure.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Use a fixed esbuild release and keep npm audit in CI.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `npm install esbuild@0.28.0`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Ran `npm audit --json` and `npm run build`.
|
||||
|
||||
#### References
|
||||
|
||||
- https://github.com/advisories/GHSA-67mh-4wv8-2f99
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This is a devDependency, so production exploitability is lower than runtime dependencies.
|
||||
|
||||
### Finding ID: SEC-009
|
||||
|
||||
#### Title
|
||||
|
||||
Large local file upload paths remain memory-heavy
|
||||
|
||||
#### Severity
|
||||
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
|
||||
Medium
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/src/js/terminal.js`, dropped-file paste/upload flows.
|
||||
- `internal/api/api.go`, `SFTPUploadData`.
|
||||
|
||||
#### Description
|
||||
|
||||
Some upload paths read whole files into memory and send base64 payloads to Go before uploading. This can be expensive for very large local files.
|
||||
|
||||
#### Evidence
|
||||
|
||||
`SFTPUploadData` decodes the entire base64 payload before calling session upload. The frontend drag/drop paste path also reads file contents into strings.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A local user or compromised renderer submits very large files, causing high memory use and potential application unresponsiveness.
|
||||
|
||||
#### Impact
|
||||
|
||||
Local denial of service.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Add size limits for paste-as-text, prefer path-based streaming upload for large files, and surface user-facing warnings for files above a threshold.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Use file path streaming upload where possible and reject in-memory uploads above a configured maximum.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Add boundary tests for maximum accepted in-memory upload size and UI tests for large-file warnings.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-400: Uncontrolled Resource Consumption.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This is primarily local/user-assisted. It was left open to avoid changing UX and transfer semantics during the security fix pass.
|
||||
|
||||
### Finding ID: SEC-010
|
||||
|
||||
#### Title
|
||||
|
||||
Direct VNC remains unencrypted by protocol choice
|
||||
|
||||
#### Severity
|
||||
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
|
||||
Medium
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/vncclient/vncclient.go`, direct TCP VNC mode.
|
||||
- VNC profile settings.
|
||||
|
||||
#### Description
|
||||
|
||||
Direct VNC connections use raw TCP to the VNC server. VNC is commonly unencrypted unless tunneled or separately protected.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The direct branch calls `net.DialTimeout` to the VNC endpoint. SSH tunnel mode is available and now host-key verified.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
On an untrusted network, direct VNC traffic may expose screen contents or credentials depending on server configuration.
|
||||
|
||||
#### Impact
|
||||
|
||||
Sensitive remote desktop data exposure.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Warn users before direct VNC connections and prefer SSH tunnel mode by default.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Default new VNC profiles to SSH tunnel where feasible, or show an explicit "unencrypted direct VNC" warning.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Add UI tests that direct VNC profiles show a warning before connection.
|
||||
|
||||
#### References
|
||||
|
||||
- OWASP ASVS 9.1: communications security.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
Some VNC servers may provide their own encryption; this audit did not test remote server configurations.
|
||||
|
||||
## 7. Denial-of-Service Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- Updated `golang.org/x/crypto` to resolve reachable SSH panic/deadlock/DoS vulnerabilities.
|
||||
- Added random-token access control to the local VNC WebSocket proxy, reducing local cross-origin abuse.
|
||||
|
||||
Reviewed and not changed:
|
||||
|
||||
- HTTP clients for GitHub and update checks use finite timeouts.
|
||||
- SSH/Telnet/RAW connection setup uses timeouts.
|
||||
- VNC WebSocket bridge is intentionally long-lived and uses no read/write timeout.
|
||||
- Large in-memory file upload/paste paths remain a local DoS risk; see SEC-009.
|
||||
|
||||
## 8. Dependency and Supply-Chain Review
|
||||
|
||||
Checks run:
|
||||
|
||||
- `govulncheck`: fixed reachable `golang.org/x/crypto` vulnerabilities by updating to `v0.52.0`; rerun found zero reachable vulnerabilities.
|
||||
- `npm audit --json`: fixed `esbuild` advisory by updating to `0.28.0`; rerun found zero vulnerabilities.
|
||||
- `npm run build`: passed after dependency update.
|
||||
|
||||
Notes:
|
||||
|
||||
- `xterm` packages are deprecated upstream in favor of `@xterm/*`, but no current audit advisory was reported in this run.
|
||||
- `go.mod` still has transitive modules with vulnerabilities that `govulncheck` reports as not called by this code.
|
||||
|
||||
## 9. Secrets and Sensitive Data Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- Non-secret profile export now redacts inline `ProxyPassword` and `TOTPSecret`.
|
||||
- SSH host-key verification now protects credential submission from basic MITM paths.
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Vault uses Argon2id and AES-256-GCM with random salts/nonces.
|
||||
- Vault file is written with `0600` permissions and store directories with `0700`.
|
||||
- OS keychain is preferred where available.
|
||||
|
||||
Residual:
|
||||
|
||||
- Secret values still exist in process memory while sessions need them.
|
||||
- Proxy password is still present in profile model state when entered inline.
|
||||
|
||||
## 10. Authentication and Authorisation Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- SSH, jump host, and VNC-over-SSH authentication now has host authenticity checks before credential use.
|
||||
- SSH host-key prompt responses are request-correlated.
|
||||
|
||||
Reviewed:
|
||||
|
||||
- SSH/TOTP flows use profile-specific secret callbacks.
|
||||
- Agent forwarding is explicit profile configuration.
|
||||
- Known-host policies include strict, ask, accept-new, and insecure.
|
||||
|
||||
Residual:
|
||||
|
||||
- `HostKeyInsecure` remains available for user-selected insecure connections.
|
||||
|
||||
## 11. Input Validation and Output Encoding Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- SSH key names now reject traversal and nested path separators.
|
||||
- Update metadata is rendered with DOM APIs instead of string-built HTML.
|
||||
- Non-secret export redacts inline secret fields.
|
||||
|
||||
Reviewed:
|
||||
|
||||
- SQL operations use parameterized queries.
|
||||
- Profile validation checks host, port, username, protocol-specific fields, and port-forwarding basics.
|
||||
- Frontend frequently uses `textContent` or `escapeHtml`; remaining `innerHTML` static templates should still be reviewed during future frontend hardening.
|
||||
|
||||
## 12. Infrastructure and Deployment Review
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Wails app binds Go services directly to the renderer. This makes frontend XSS more impactful than in a browser-only app.
|
||||
- Build scripts use `set -euo pipefail`.
|
||||
- Update checking is opt-in and does not auto-install binaries.
|
||||
|
||||
Residual:
|
||||
|
||||
- No CI configuration was present in the reviewed file list, so dependency scanning is not currently enforced by repository automation.
|
||||
|
||||
## 13. Logging, Monitoring, and Error Handling Review
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Audit logger records profile create/update/delete, connection events, secret access, import/export.
|
||||
- Errors are generally returned to the frontend with enough context for troubleshooting.
|
||||
|
||||
Residual:
|
||||
|
||||
- Some error messages include file paths, which is acceptable for a local desktop tool but should be considered if telemetry is later added.
|
||||
|
||||
## 14. Test Coverage and Security Regression Gaps
|
||||
|
||||
Added tests:
|
||||
|
||||
- API SSH key path validation and export redaction.
|
||||
- Session host-key callback storage and strict rejection behavior.
|
||||
- Jump-host host-key callback propagation.
|
||||
- Storage export redaction.
|
||||
- VNC proxy token generation.
|
||||
|
||||
Remaining gaps:
|
||||
|
||||
- No frontend unit test harness for DOM injection regressions.
|
||||
- No integration test for full Wails host-key prompt round trip.
|
||||
- No VNC WebSocket integration test verifying token-only connection behavior.
|
||||
- No large-file upload boundary tests.
|
||||
|
||||
## 15. Prioritised Remediation Plan
|
||||
|
||||
Immediate:
|
||||
|
||||
- Keep the fixes in this audit and ensure they are merged.
|
||||
- Add dependency scanning to CI: `govulncheck ./...` and `npm audit --audit-level=moderate`.
|
||||
|
||||
Short-term:
|
||||
|
||||
- Add a frontend test harness for update UI and other dynamic DOM surfaces.
|
||||
- Add direct VNC warning UX and default to SSH tunnel where possible.
|
||||
- Add explicit in-memory upload size limits.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Expose host-key policy for VNC SSH tunnels instead of always using ask mode.
|
||||
- Review all Wails-exposed methods under a compromised-renderer threat model.
|
||||
- Migrate deprecated `xterm` packages to maintained `@xterm/*` packages.
|
||||
|
||||
Long-term:
|
||||
|
||||
- Consider capability gating or confirmation for high-risk renderer-callable APIs such as file deletion, SSH key deletion, local shell selection, and profile import/export.
|
||||
- Add signed update metadata if self-hosted update manifests become a production channel.
|
||||
|
||||
## 16. False Positives / Needs Manual Verification
|
||||
|
||||
- Wails/noVNC Origin behavior in a packaged app should be manually verified. Tokenized VNC WebSocket paths are in place, but Origin policy may still be tightened later.
|
||||
- Direct VNC encryption depends on remote VNC server configuration and network placement.
|
||||
- `HostKeyInsecure` is intentionally user-configurable; confirm UI wording makes the risk clear.
|
||||
- Large-file memory usage thresholds need product decisions before enforcement.
|
||||
|
||||
## 17. Final Notes
|
||||
|
||||
Fix mode completed with code changes, tests, and dependency updates. The strongest residual concern is the general Wails threat model: a frontend XSS can call powerful local backend APIs. This audit fixed the confirmed dynamic update UI injection path and several backend trust-boundary issues, but future work should continue reducing string-built HTML and adding capability checks around sensitive local operations.
|
||||
@@ -1,9 +1,13 @@
|
||||
.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package release help
|
||||
.PHONY: all build build-compressed dev clean test test-cover lint install deps frontend-deps upx-check run package release help
|
||||
|
||||
# Resolve the Wails binary. Prefer wails on PATH; fall back to GOPATH/bin.
|
||||
WAILS := $(or $(shell which wails 2>/dev/null),$(shell go env GOPATH)/bin/wails)
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo dev)-$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
LD_UPDATE_URL := $(if $(NEXUS_UPDATE_URL),-X github.com/andydixon/nexus/internal/api.appUpdateURL=$(NEXUS_UPDATE_URL),)
|
||||
UPX_FLAGS ?=
|
||||
NEXUS_FORCE_MACOS_UPX ?= 0
|
||||
GOBIN_DIR := $(shell go env GOPATH)/bin
|
||||
export PATH := $(GOBIN_DIR):$(PATH)
|
||||
|
||||
# Default target
|
||||
all: build
|
||||
@@ -12,6 +16,17 @@ all: build
|
||||
deps: frontend-deps
|
||||
go mod download
|
||||
|
||||
upx-check:
|
||||
@command -v upx >/dev/null 2>&1 || { echo "UPX is required for compressed builds. Install it first, for example: brew install upx"; exit 1; }
|
||||
@if [ "$$(uname -s)" = "Darwin" ] && [ "$(NEXUS_FORCE_MACOS_UPX)" != "1" ]; then \
|
||||
echo "UPX compression is disabled for macOS by default. Use ./release.sh for a zipped .app, or run NEXUS_FORCE_MACOS_UPX=1 UPX_FLAGS='--force-macos --best --no-color --no-progress' make build-compressed."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ "$$(uname -s)" = "Darwin" ] && [ "$(NEXUS_FORCE_MACOS_UPX)" = "1" ] && ! printf '%s\n' "$(UPX_FLAGS)" | grep -q -- '--force-macos'; then \
|
||||
echo "macOS UPX builds must pass --force-macos in UPX_FLAGS."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Install frontend dependencies
|
||||
frontend-deps:
|
||||
cd frontend && npm install
|
||||
@@ -22,7 +37,11 @@ dev: deps
|
||||
|
||||
# Production build
|
||||
build: deps
|
||||
$(WAILS) build -ldflags "-s -w -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
|
||||
CGO_CFLAGS="$${CGO_CFLAGS:--O2 -g0}" CGO_CXXFLAGS="$${CGO_CXXFLAGS:--O2 -g0}" $(WAILS) build -trimpath -ldflags "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
|
||||
|
||||
# Production build with UPX compression
|
||||
build-compressed: deps upx-check
|
||||
CGO_CFLAGS="$${CGO_CFLAGS:--O2 -g0}" CGO_CXXFLAGS="$${CGO_CXXFLAGS:--O2 -g0}" $(WAILS) build -trimpath -upx $(if $(UPX_FLAGS),-upxflags "$(UPX_FLAGS)",) -ldflags "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@@ -60,6 +79,7 @@ help:
|
||||
@echo " make deps — Install all dependencies"
|
||||
@echo " make dev — Run in development mode with hot reload"
|
||||
@echo " make build — Production build"
|
||||
@echo " make build-compressed — Production build with UPX compression"
|
||||
@echo " make test — Run tests"
|
||||
@echo " make test-cover — Run tests with coverage report"
|
||||
@echo " make clean — Clean build artefacts"
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# Nexus Terminal
|
||||
|
||||
**A desktop client for SSH, Telnet, RAW TCP, and GitHub — with secure credential handling and a focused, multi-session workflow.**
|
||||
**A desktop client for SSH, Telnet, RAW TCP, VNC, GitHub, local terminals, and Leviathan binary analysis — with secure credential handling and a focused, multi-session workflow.**
|
||||
|
||||
Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence.
|
||||
|
||||
## Highlights
|
||||
|
||||
- SSH, Telnet, RAW TCP, and GitHub sessions in one app
|
||||
- SSH, Telnet, RAW TCP, VNC, GitHub, and local terminal sessions in one app
|
||||
- Tabbed sessions with split panes, pinning, and reordering
|
||||
- SFTP browser with drag-and-drop uploads and a directory picker
|
||||
- Leviathan binary workbench with hex editing, entropy, carving, BinSnoop, structure parsers, transforms, and spectrogram tools
|
||||
- Disconnected overlay with one-click reconnect
|
||||
- Broadcast input, snippets, and terminal search
|
||||
- Session recording and playback
|
||||
@@ -29,6 +30,7 @@ If a session drops, use the **Reconnect** button in the disconnected overlay.
|
||||
- Product overview: [docs/overview.md](docs/overview.md)
|
||||
- Quick start (shareable): [docs/quick-start.md](docs/quick-start.md)
|
||||
- User manual: [docs/user-manual.md](docs/user-manual.md)
|
||||
- Build and release: [docs/build-and-release.md](docs/build-and-release.md)
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Functionality and Next Steps Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix-mode audit improved immediate security and robustness around profile validation, imports, drag-drop upload, frontend encoding, dependencies, and release reproducibility.
|
||||
|
||||
The most useful next work is to turn these fixes into durable product quality: add frontend interaction tests, make large file upload streaming and consistent, warn users about intentionally insecure protocol choices, and strengthen release provenance.
|
||||
|
||||
## 2. Current Functional Risks
|
||||
|
||||
Direct VNC can still be used without SSH tunnelling. That is valid for trusted networks, but the UI should make the risk visible when profiles are saved or connected.
|
||||
|
||||
Drag-drop SFTP upload is now capped at 25 MiB because it still uses an in-memory browser-to-Go base64 path. Larger files should use the existing native SFTP upload flow or a future streaming drag-drop implementation.
|
||||
|
||||
Profile import now rejects malformed or unsafe profiles before storage. That is safer, but older third-party exports with missing defaults may need a clearer import error UI or a migration/repair preview.
|
||||
|
||||
RAW session history and recordings can still grow over long sessions. Users need limits, retention controls, and clear storage management.
|
||||
|
||||
## 3. Code Quality Improvements
|
||||
|
||||
Profile validation is now centralized enough for create/update/import, but profile normalization is still split between frontend defaults, Go defaults, and import-time cleanup. A schema-versioned import normalizer would make future protocol additions safer.
|
||||
|
||||
Frontend still has many `innerHTML` templates. Some are static or escaped, but a small helper pattern for safe modal construction would reduce future XSS mistakes.
|
||||
|
||||
Release scripts and CI now use pinned Wails and `npm ci`; follow-up work should avoid duplicate release logic between `build.sh`, `release.sh`, and GitHub Actions.
|
||||
|
||||
## 4. Frontend Interaction and Error-Handling Improvements
|
||||
|
||||
Add Wails/browser interaction tests for:
|
||||
|
||||
- Dropping a file with HTML metacharacters in its filename.
|
||||
- Dropping a file over 25 MiB.
|
||||
- Importing malformed and oversized profile bundles.
|
||||
- Saving GitHub profiles with HTTP and HTTPS API base URLs.
|
||||
- Creating direct VNC profiles and seeing a warning.
|
||||
|
||||
Improve import UX by showing which profile entry failed validation without exposing secret values.
|
||||
|
||||
## 5. Testing Roadmap
|
||||
|
||||
Immediate tests to add:
|
||||
|
||||
- Frontend DOM regression for file-drop title rendering.
|
||||
- API tests for successful valid GitHub Enterprise HTTPS profile creation.
|
||||
- Import tests for mixed valid/invalid profile arrays.
|
||||
|
||||
Short-term tests:
|
||||
|
||||
- End-to-end Wails smoke tests for create profile, connect prompt flow, import/export, SFTP upload, and VNC tunnel setup.
|
||||
- Load tests for large SFTP directories, RAW captures, and recordings.
|
||||
- CI security job with `npm audit`, govulncheck, and secret scanning.
|
||||
|
||||
Longer-term tests:
|
||||
|
||||
- Release workflow dry-run or artifact validation tests.
|
||||
- Migration tests for profile schema changes.
|
||||
|
||||
## 6. Recommended New Functionality
|
||||
|
||||
Add a direct-VNC safety warning and a per-profile “prefer SSH tunnel” nudge.
|
||||
|
||||
Add streaming drag-drop SFTP upload for larger files, or route drag-drop files above 25 MiB into the native upload flow.
|
||||
|
||||
Add import preview: list profile names, protocols, validation warnings, and duplicate handling before writing.
|
||||
|
||||
Add recording retention settings: max recording size, max age, and one-click cleanup.
|
||||
|
||||
Add security diagnostics in settings: dependency check status, known-host summary, direct insecure profile count, and last audit-log events.
|
||||
|
||||
## 7. Next-Level Opportunities
|
||||
|
||||
Release hardening:
|
||||
|
||||
- Sign and notarize release artifacts where platform-appropriate.
|
||||
- Publish checksums and provenance.
|
||||
- Pin or cache dependency sources in CI.
|
||||
|
||||
Observability:
|
||||
|
||||
- Add structured non-sensitive debug logs for import rejection, update checks, VNC proxy lifecycle, and SFTP transfer failures.
|
||||
- Add audit entries for validation failures that indicate suspicious imports, without storing payload contents.
|
||||
|
||||
Developer experience:
|
||||
|
||||
- Consolidate release/build dependency resolution into one reusable script.
|
||||
- Add a frontend lint rule or custom check for unsafe `innerHTML` with dynamic values.
|
||||
|
||||
Accessibility/product quality:
|
||||
|
||||
- Ensure modal error states are keyboard reachable and announced.
|
||||
- Add clearer status messages for blocked uploads and import validation failures.
|
||||
|
||||
## 8. Prioritised Action Plan
|
||||
|
||||
Immediate:
|
||||
|
||||
- Add direct VNC warning copy and tests.
|
||||
- Add frontend file-drop XSS and oversize tests.
|
||||
- Add CI jobs for `go test ./...`, `npm run build`, `npm audit`, and govulncheck.
|
||||
|
||||
Short-term:
|
||||
|
||||
- Replace deprecated `xterm` packages with maintained `@xterm/*` packages.
|
||||
- Build import preview and validation repair UI.
|
||||
- Add recording and RAW capture storage limits.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Implement streaming drag-drop upload.
|
||||
- Add release signing, checksums, and provenance.
|
||||
- Add schema-versioned profile import/export.
|
||||
|
||||
Long-term:
|
||||
|
||||
- Build a security/settings dashboard for insecure profiles, known hosts, audit status, and update health.
|
||||
- Add comprehensive Wails end-to-end smoke coverage for core workflows.
|
||||
|
||||
## 9. Caveats and Validation Needed
|
||||
|
||||
The fixes were verified with automated local checks, but not with live Wails UI automation.
|
||||
|
||||
The new import validation may reject malformed legacy profiles that were previously accepted. That is intentional for safety, but the UX should help users repair old data.
|
||||
|
||||
The 25 MiB drag-drop upload cap is a guardrail, not a final large-file strategy. Streaming support would be the better long-term user experience.
|
||||
|
||||
Direct VNC was left open because some users need it on trusted networks; the next step is product warning and safer defaults, not silent removal.
|
||||
@@ -0,0 +1,752 @@
|
||||
# Security Audit Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix-mode audit reviewed Nexus Terminal, a Wails desktop client with a Go backend and JavaScript frontend for SSH, Telnet, RAW TCP, VNC, SFTP, GitHub, recordings, local shells, encrypted secrets, and profile import/export.
|
||||
|
||||
The highest-risk issues fixed in this run were unsafe profile persistence/import validation, a local DOM XSS trigger in the file-drop modal, memory-exhaustion paths in import and drag-drop upload flows, vulnerable dependency versions, and release tooling that installed unpinned build dependencies. I also removed the stale insecure SSH connection helper so future code cannot silently bypass host-key verification.
|
||||
|
||||
Post-fix verification passed: `go test ./...`, focused Go tests, `npm run build`, `npm audit --json`, `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`, `bash -n build.sh release.sh`, and `git diff --check`.
|
||||
|
||||
Residual risk remains where users intentionally choose insecure protocols or workflows, especially direct VNC without SSH tunnelling. No high-confidence secrets or private keys were found in the repository search performed during this audit.
|
||||
|
||||
## 2. Scope Reviewed
|
||||
|
||||
Reviewed code and configuration:
|
||||
|
||||
- Go backend under `internal/`: API bindings, sessions, SSH, VNC, SFTP, storage, secrets, export/import, validation, updater, recordings, local shell, GitHub client, raw and telnet clients.
|
||||
- Frontend JavaScript under `frontend/src/js`, especially file drop, terminal, settings, GitHub, profile, SFTP, VNC, notification, and import/export flows.
|
||||
- Dependency manifests and lockfiles: `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
|
||||
- Build/release configuration: `build.sh`, `release.sh`, `.github/workflows/release-builds.yml`, `wails.json`.
|
||||
- Existing tests under `internal/**/*_test.go`.
|
||||
|
||||
Not reviewed with live external testing:
|
||||
|
||||
- Real SSH/VNC/GitHub endpoints.
|
||||
- Packaged binaries and platform installers.
|
||||
- Manual UI testing inside a running Wails app.
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
I used a two-pass review:
|
||||
|
||||
- Attack-surface mapping of Wails API methods, import/export paths, file handling, DOM sinks, dependency/build chain, protocol clients, local proxy behavior, and credential flows.
|
||||
- Defensive validation from source and tests, followed by smallest safe fixes with regression tests.
|
||||
|
||||
Tools/checks used:
|
||||
|
||||
- Static review with `rg`, direct source reads, and line evidence.
|
||||
- `go test ./...`.
|
||||
- Focused tests for API, storage, export/import, validation, SSH client, and sessions.
|
||||
- `npm audit --json`.
|
||||
- `npm run build`.
|
||||
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`.
|
||||
- `bash -n build.sh release.sh`.
|
||||
- `git diff --check`.
|
||||
|
||||
## 4. Risk Rating Method
|
||||
|
||||
Severity considered attacker position, exploit preconditions, likely impact, and whether sensitive credentials or code execution could be affected.
|
||||
|
||||
- Critical: practical remote compromise or credential disclosure with minimal preconditions.
|
||||
- High: credible dependency, credential, or supply-chain compromise.
|
||||
- Medium: user-assisted or renderer/local attack that can affect confidentiality, integrity, or availability.
|
||||
- Low: hardening, defense-in-depth, or intentionally insecure user-selected modes.
|
||||
- Informational: verified area, non-exploitable issue, or test gap.
|
||||
|
||||
Confidence is High where source evidence and regression checks directly support the finding.
|
||||
|
||||
## 5. Attack Surface Map
|
||||
|
||||
Entry points:
|
||||
|
||||
- Wails-bound Go API methods in `internal/api/api.go`.
|
||||
- Profile create/update/import/export and encrypted `.nexus` imports.
|
||||
- Drag-and-drop file upload/paste in `frontend/src/js/terminal.js`.
|
||||
- SSH, Telnet, RAW TCP, VNC, SFTP, GitHub API, and local shell sessions.
|
||||
- Local VNC WebSocket proxy on loopback.
|
||||
- Build/release automation and dependency installation.
|
||||
|
||||
Trust boundaries:
|
||||
|
||||
- Frontend renderer to Wails API.
|
||||
- User-selected/imported local files to backend parsers.
|
||||
- Saved/imported profiles to outbound network clients.
|
||||
- Remote hosts and GitHub/GitHub Enterprise APIs to frontend rendering.
|
||||
- Local filesystem/keychain/vault to application process.
|
||||
- CI/release environment to dependency registries and Go module proxy.
|
||||
|
||||
Attacker starting positions considered:
|
||||
|
||||
- Malicious imported profile bundle.
|
||||
- Crafted local file or filename dropped into the terminal.
|
||||
- Compromised renderer or malicious frontend input reaching Wails APIs.
|
||||
- Network attacker on SSH/VNC/GitHub paths.
|
||||
- Dependency registry or release-time supply-chain manipulation.
|
||||
|
||||
## 6. Findings Summary Table
|
||||
|
||||
| ID | Severity | Confidence | Title | Component | Status |
|
||||
|---|---:|---:|---|---|---|
|
||||
| SEC-20260615-001 | Medium | High | Profile persistence and imports accepted unsafe or malformed profiles | API, validation, import | Fixed |
|
||||
| SEC-20260615-002 | Medium | High | Import and drag-drop upload paths allowed memory exhaustion | Import/export, SFTP upload | Fixed |
|
||||
| SEC-20260615-003 | Medium | High | Dropped filenames were rendered through `innerHTML` | Frontend terminal file-drop modal | Fixed |
|
||||
| SEC-20260615-004 | Medium | High | Unused SSH helper still defaulted to insecure host-key verification | SSH client | Fixed |
|
||||
| SEC-20260615-005 | High | High | Dependency advisories affected `esbuild` and required `x/net` module versions | Dependencies | Fixed |
|
||||
| SEC-20260615-006 | Medium | High | Release automation installed unpinned build dependencies | CI/release supply chain | Fixed |
|
||||
| SEC-20260615-007 | Low | High | Direct VNC remains unencrypted when SSH tunnelling is disabled | VNC client | Open |
|
||||
|
||||
## 7. Detailed Findings
|
||||
|
||||
### Finding ID: SEC-20260615-001
|
||||
|
||||
#### Title
|
||||
|
||||
Profile persistence and imports accepted unsafe or malformed profiles
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`, `CreateProfile`, `UpdateProfile`, `ImportProfiles`, `ImportProfilesEncrypted`, lines 94-131, 715-728, 1176-1189, 1754-1791.
|
||||
- `internal/validation/validation.go`, profile, GitHub, and VNC validation, lines 51-140 and 325-410.
|
||||
- `internal/storage/store.go`, `ImportProfiles`, lines 365-380.
|
||||
|
||||
#### Description
|
||||
|
||||
The API comment said profile creation validates input, but the Wails API previously persisted profiles without calling the validation package. Plain JSON import also decoded into `[]*models.Profile` and dereferenced entries without rejecting `null`. A malicious or corrupt import could therefore crash the app or store profiles with unsafe GitHub API base URLs that would later receive a saved or prompted token.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed code now calls `validation.ValidateProfile` before create/update and validates every imported profile before writing. GitHub API base URLs must be HTTPS unless loopback, must be absolute, and must not include URL credentials. Import strategy and null entries are rejected before persistence.
|
||||
|
||||
Regression tests were added in `internal/api/api_test.go` and `internal/storage/storage_test.go`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user imports a profile JSON containing a GitHub profile with `http://evil.example/api/v3` as the API base URL, or a `null` profile entry. Before the fix, malformed data could be saved or crash import handling. If the malicious GitHub profile were later used, the app could send a token to an attacker-controlled endpoint.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible token exposure through malicious profile configuration, app crash during import, and fragile persisted state.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Validate all Wails-bound persistence inputs and imported profiles before storage. Reject non-HTTPS remote GitHub API URLs and malformed entries.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented:
|
||||
|
||||
- `CreateProfile` and `UpdateProfile` call `validation.ValidateProfile`.
|
||||
- `ValidateGitHubConfig` rejects remote HTTP and URL credentials.
|
||||
- Import helpers reject oversized, null, or invalid entries before writes.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented:
|
||||
|
||||
- Unsafe GitHub base URL rejection.
|
||||
- Null import entry rejection.
|
||||
- Oversized JSON import rejection.
|
||||
- Storage-level null import entry rejection.
|
||||
- GitHub and VNC validation cases.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-20: Improper Input Validation.
|
||||
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor.
|
||||
- OWASP API Security Top 10: API3 Broken Object Property Level Authorization / unsafe object properties.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
GitHub Enterprise over non-loopback HTTP is now rejected. If a legitimate deployment needs HTTP, it should be placed behind local loopback or HTTPS.
|
||||
|
||||
### Finding ID: SEC-20260615-002
|
||||
|
||||
#### Title
|
||||
|
||||
Import and drag-drop upload paths allowed memory exhaustion
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`, plain import and SFTP drag-drop upload, lines 715-718 and 1336-1350.
|
||||
- `internal/exportimport/exportimport.go`, encrypted import file read, lines 37 and 131-144.
|
||||
- `frontend/src/js/terminal.js`, drag-drop file upload cap, lines 10 and 829-849.
|
||||
|
||||
#### Description
|
||||
|
||||
Several local file paths loaded attacker-controlled or user-selected content fully into memory without a size cap. Plain profile JSON import accepted arbitrary string size from the renderer. Encrypted `.nexus` import used `os.ReadFile` on the selected path. Drag-drop SFTP upload read each browser `File` into an `ArrayBuffer`, converted it to base64, and sent it to the backend, where it was decoded into another byte slice.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed code adds:
|
||||
|
||||
- 5 MiB cap for plain profile JSON import.
|
||||
- 10 MiB cap for encrypted `.nexus` import files.
|
||||
- 25 MiB frontend and backend caps for inline drag-drop SFTP uploads.
|
||||
|
||||
Regression tests cover oversized plain import, encrypted import, and backend upload payload rejection.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user drops or imports a very large crafted file. Before the fix, the renderer and backend could allocate multiple full copies, causing high memory pressure or an app crash.
|
||||
|
||||
#### Impact
|
||||
|
||||
Local denial of service against the desktop application.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep bounded inline payload paths and prefer streaming APIs for larger transfers. The existing native SFTP upload path streams local files and should remain the recommended path for large uploads.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented size checks before decode/read where practical:
|
||||
|
||||
- `base64.StdEncoding.DecodedLen` guard before decoding drag-drop upload payloads.
|
||||
- `os.Stat` size check before encrypted import `os.ReadFile`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented:
|
||||
|
||||
- `TestImportProfilesRejectsOversizedJSON`.
|
||||
- `TestDecryptRejectsOversizedFile`.
|
||||
- `TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-400: Uncontrolled Resource Consumption.
|
||||
- OWASP ASVS 12.4: file and resource limits.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The caps are conservative application-level limits. Larger profile bundles or drag-drop uploads now require a streaming implementation or the existing native SFTP upload flow.
|
||||
|
||||
### Finding ID: SEC-20260615-003
|
||||
|
||||
#### Title
|
||||
|
||||
Dropped filenames were rendered through `innerHTML`
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/src/js/terminal.js`, file-drop modal, lines 720-742.
|
||||
|
||||
#### Description
|
||||
|
||||
The file-drop modal interpolated the dropped filename into an `innerHTML` template. Local files can have names containing HTML metacharacters. In a Wails desktop app, DOM script execution is higher impact than a normal static page because the renderer can call privileged Wails APIs.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed modal escapes the file-drop title with `escapeHtml(title)` before injecting it into the template.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user downloads or receives a file with a crafted name such as an HTML tag with an event handler, then drops it into a terminal. Before the fix, the modal could parse the filename as markup.
|
||||
|
||||
#### Impact
|
||||
|
||||
Potential renderer-side script execution and access to Wails-bound APIs from malicious DOM content.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Do not put user-controlled strings into `innerHTML`. Use `textContent` or escape before insertion.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `escapeHtml(title)` in the file-drop modal.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Add a frontend interaction test that drops a file named with `<img ...>`-style metacharacters and asserts the modal text is rendered literally. Manual Wails/browser verification was not run in this audit.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-79: Improper Neutralization of Input During Web Page Generation.
|
||||
- OWASP ASVS 5.3: output encoding.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This fix covers the reviewed file-drop title sink. Other `innerHTML` templates were spot-checked; future work should add an automated DOM-sink lint or test pass.
|
||||
|
||||
### Finding ID: SEC-20260615-004
|
||||
|
||||
#### Title
|
||||
|
||||
Unused SSH helper still defaulted to insecure host-key verification
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/sshclient/sshclient.go`, `Connect`, lines 72-75.
|
||||
- `internal/sshclient/sshclient_test.go`, regression test, lines 12-19.
|
||||
|
||||
#### Description
|
||||
|
||||
Current production session code uses explicit host-key callbacks, and no remaining reviewed SSH/VNC path calls `ssh.InsecureIgnoreHostKey`. However, the internal `sshclient.Connect` helper still defaulted to an insecure host-key callback. That made future misuse easy.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The helper now fails closed and instructs callers to use `ConnectWithHostKeyCallback`. `rg` found no remaining `InsecureIgnoreHostKey` references after the change.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A future feature calls the convenience method instead of `ConnectWithHostKeyCallback`. Before the fix, that new path would silently accept any SSH host key.
|
||||
|
||||
#### Impact
|
||||
|
||||
Potential SSH machine-in-the-middle exposure if the helper were reused.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep host-key policy explicit on every SSH path. Do not provide insecure convenience APIs in internal packages.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `Connect` returns `sshclient: host key callback is required`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented `TestConnectRequiresHostKeyCallback`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-295: Improper Certificate Validation, analogous to SSH host-key validation.
|
||||
- OWASP ASVS 9.1.2: server authenticity validation.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This is a preventive fix. The reviewed production paths already used explicit callbacks.
|
||||
|
||||
### Finding ID: SEC-20260615-005
|
||||
|
||||
#### Title
|
||||
|
||||
Dependency advisories affected `esbuild` and required `x/net` module versions
|
||||
|
||||
#### Severity
|
||||
|
||||
High
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/package.json`, `esbuild`, line 22.
|
||||
- `frontend/package-lock.json`, `esbuild` and platform packages.
|
||||
- `go.mod`, `golang.org/x/net`, line 15.
|
||||
- `go.sum`.
|
||||
|
||||
#### Description
|
||||
|
||||
`npm audit` reported `esbuild 0.28.0` affected by a high-severity advisory and a low-severity advisory. `govulncheck -show verbose` reported six vulnerabilities in required `golang.org/x/net v0.54.0` modules, though no reachable symbols were called by this code.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed versions are:
|
||||
|
||||
- `esbuild` bumped to `^0.28.1`.
|
||||
- `golang.org/x/net` bumped to `v0.55.0`.
|
||||
|
||||
Post-fix `npm audit --json` reports zero vulnerabilities and `govulncheck` reports `No vulnerabilities found.`
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
For `esbuild`, a compromised or manipulated package installation environment could exploit known build-tool weaknesses. For `x/net`, future code changes could start calling vulnerable symbols if the module stayed on an affected version.
|
||||
|
||||
#### Impact
|
||||
|
||||
Supply-chain compromise risk during build/development and latent dependency risk.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep audited dependency patch versions current and include `npm audit` and `govulncheck` in regular release checks.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented package/module upgrades and lockfile updates.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented verification:
|
||||
|
||||
- `npm audit --json`.
|
||||
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`.
|
||||
- `go test ./...`.
|
||||
- `npm run build`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-494: Download of Code Without Integrity Check.
|
||||
- CWE-1104: Use of Unmaintained Third Party Components.
|
||||
- Go vulnerability database advisories for `golang.org/x/net`.
|
||||
- GitHub Advisory Database for `esbuild`.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The `x/net` advisories were not reachable before the bump, according to govulncheck. The upgrade removes the latent module-level finding anyway.
|
||||
|
||||
### Finding ID: SEC-20260615-006
|
||||
|
||||
#### Title
|
||||
|
||||
Release automation installed unpinned build dependencies
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `.github/workflows/release-builds.yml`, Wails and npm install steps, lines 66-70.
|
||||
- `release.sh`, Wails and npm install logic, lines 76-99.
|
||||
|
||||
#### Description
|
||||
|
||||
The release workflow installed the Wails CLI with `@latest` and used `npm install` rather than the lockfile-enforcing `npm ci`. Local release scripting also instructed or performed unpinned dependency installation in some paths.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed workflow installs the Wails CLI at the version resolved from `go.mod` and uses `npm ci`. `release.sh` now installs the pinned Wails CLI version from `go.mod` when needed and uses `npm ci` when `frontend/package-lock.json` exists.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A release build runs after a compromised or breaking upstream release, or after lockfile drift. Before the fix, release outputs could include unreviewed dependency changes.
|
||||
|
||||
#### Impact
|
||||
|
||||
Build reproducibility loss and supply-chain risk in release artifacts.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Use pinned module versions and lockfile-enforced installs in CI/release paths.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented:
|
||||
|
||||
- `go install ...@$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v2)`.
|
||||
- `npm ci` in CI and release script.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented:
|
||||
|
||||
- `bash -n build.sh release.sh`.
|
||||
- Frontend build after `npm ci`.
|
||||
|
||||
#### References
|
||||
|
||||
- SLSA build integrity principles.
|
||||
- CWE-494.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The workflow still allows network dependency retrieval during CI. Stronger controls would include dependency caching, provenance, and artifact signing.
|
||||
|
||||
### Finding ID: SEC-20260615-007
|
||||
|
||||
#### Title
|
||||
|
||||
Direct VNC remains unencrypted when SSH tunnelling is disabled
|
||||
|
||||
#### Severity
|
||||
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/sessions/manager.go`, direct VNC branch, lines 1012-1062.
|
||||
- `internal/vncclient/vncclient.go`, direct TCP dial, lines 139-144.
|
||||
|
||||
#### Description
|
||||
|
||||
When VNC SSH tunnelling is disabled, the client makes a direct TCP VNC connection. Classic VNC deployments often provide weak or no transport encryption.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The VNC client intentionally supports both SSH-tunnelled and direct modes. The direct branch uses `net.DialTimeout` to the VNC address.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user connects directly to VNC over an untrusted network. A network attacker observes or tampers with remote desktop traffic, depending on the VNC server's negotiated security type.
|
||||
|
||||
#### Impact
|
||||
|
||||
Potential confidentiality and integrity loss for remote desktop sessions.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep direct VNC available for trusted networks, but clearly warn users when SSH tunnelling is disabled and consider requiring explicit confirmation for saved direct-VNC profiles.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Prefer enabling `vnc.sshTunnel` and verifying the SSH host key for the tunnel host.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Add a UI test that direct VNC profiles show a warning, and an integration test that tunnelled VNC requires a host-key callback.
|
||||
|
||||
#### References
|
||||
|
||||
- OWASP ASVS 9.1: secure channel requirements.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This was not changed in this run because direct VNC can be a deliberate user choice for isolated networks.
|
||||
|
||||
## 8. Blackhat-Style Abuse Paths
|
||||
|
||||
Path A: malicious profile import to token exfiltration, now blocked.
|
||||
|
||||
1. Provide a profile bundle with a GitHub API base URL pointing to attacker infrastructure.
|
||||
2. User imports and connects.
|
||||
3. App prompts for or retrieves a token and sends it to the configured endpoint.
|
||||
4. Defensive breakpoints now added: profile validation, HTTPS requirement for remote GitHub APIs, import validation before storage.
|
||||
|
||||
Path B: crafted dropped filename to renderer script execution, now blocked.
|
||||
|
||||
1. User receives a file with HTML metacharacters in the filename.
|
||||
2. User drops it into a terminal.
|
||||
3. Filename is rendered in the file-drop modal.
|
||||
4. Defensive breakpoint now added: title is escaped before `innerHTML` insertion.
|
||||
|
||||
Path C: oversized file to app memory exhaustion, now blocked for reviewed inline paths.
|
||||
|
||||
1. User imports or drops an extremely large file.
|
||||
2. Renderer/backend allocate full file contents and base64 copies.
|
||||
3. App becomes slow or crashes.
|
||||
4. Defensive breakpoints now added: import and inline upload size caps.
|
||||
|
||||
Path D: direct VNC over hostile network, still open by design.
|
||||
|
||||
1. User disables SSH tunnel and connects to a VNC server over an untrusted network.
|
||||
2. Network attacker observes/tampers with VNC traffic if protocol security is weak.
|
||||
3. Defensive breakpoint: use SSH tunnel; future UI warning recommended.
|
||||
|
||||
## 9. Denial-of-Service Review
|
||||
|
||||
Fixed DoS controls:
|
||||
|
||||
- Plain profile import now rejects payloads over 5 MiB.
|
||||
- Encrypted profile import now rejects files over 10 MiB before `os.ReadFile`.
|
||||
- Drag-drop SFTP upload now rejects files over 25 MiB in the frontend and backend before base64 decode.
|
||||
- Null profile entries no longer panic import paths.
|
||||
|
||||
Checked and not found:
|
||||
|
||||
- SQL queries use parameters for profile and audit operations reviewed.
|
||||
- GitHub repo fetch has timeouts and caps repository listing at 5000 repos.
|
||||
- SSH/Telnet/RAW connection setup uses dial timeouts.
|
||||
- VNC local proxy binds to loopback and uses a per-session token in the current code.
|
||||
|
||||
Residual DoS risks:
|
||||
|
||||
- RAW session history and terminal recordings can grow with long sessions.
|
||||
- SFTP directory listing still depends on remote server behavior and could return very large directories.
|
||||
- Direct VNC proxy bridges long-lived streams by design.
|
||||
|
||||
## 10. Dependency and Supply-Chain Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- `esbuild` upgraded to `^0.28.1`; `npm audit --json` now reports zero vulnerabilities.
|
||||
- `golang.org/x/net` upgraded to `v0.55.0`; govulncheck now reports no vulnerabilities.
|
||||
- Release workflow uses `npm ci` and pinned Wails CLI version.
|
||||
- `release.sh` uses `npm ci` when a lockfile exists and installs the Wails CLI at the version from `go.mod`.
|
||||
|
||||
Checked:
|
||||
|
||||
- `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
|
||||
- `.github/workflows/release-builds.yml`, `build.sh`, `release.sh`.
|
||||
|
||||
Notes:
|
||||
|
||||
- npm warns that `xterm` packages are deprecated in favor of the newer `@xterm/*` packages. That is not an active vulnerability from `npm audit`, but it is a maintenance risk.
|
||||
|
||||
## 11. Secrets and Sensitive Data Review
|
||||
|
||||
Checked:
|
||||
|
||||
- Repository search for private key markers, common cloud tokens, GitHub token prefixes, and obvious password assignments.
|
||||
- Secret store uses AES-GCM with Argon2id-derived keys and writes vault data with `0600`.
|
||||
- Profile export redacts inline SSH proxy/TOTP secrets when non-secret export mode is used.
|
||||
|
||||
No high-confidence committed secrets or private keys were found.
|
||||
|
||||
Residual notes:
|
||||
|
||||
- GitHub and remote connection tokens are still available to the running app process by design.
|
||||
- Audit logging should continue to avoid logging raw secrets; reviewed audit messages do not include secret values.
|
||||
|
||||
## 12. Authentication and Authorisation Review
|
||||
|
||||
This is a desktop app, so there are no multi-user server authz roles in the reviewed code.
|
||||
|
||||
Checked/fixed:
|
||||
|
||||
- SSH host-key bypass helper removed by making `sshclient.Connect` fail closed.
|
||||
- Current SSH and VNC tunnel paths use explicit host-key callbacks.
|
||||
- GitHub API base URL validation now reduces token leakage to remote HTTP endpoints.
|
||||
|
||||
Residual:
|
||||
|
||||
- Users can still choose insecure protocols/modes such as direct VNC or legacy Telnet/RAW TCP.
|
||||
|
||||
## 13. Input Validation and Output Encoding Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- API create/update now validates profiles before persistence.
|
||||
- Plain and encrypted import validate every profile before storage.
|
||||
- GitHub API URLs now require HTTPS except loopback and reject URL credentials.
|
||||
- VNC validation supports VNC-specific host fallback and validates tunnel fields.
|
||||
- File-drop filename is escaped before template insertion.
|
||||
|
||||
Checked:
|
||||
|
||||
- SSH key management already rejects path traversal key names with alphanumeric, hyphen, and underscore constraints.
|
||||
- SFTP dropped filenames use `filepath.Base` before remote path construction.
|
||||
|
||||
## 14. Frontend HTML/JavaScript Robustness Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- File-drop modal filename sink now uses `escapeHtml`.
|
||||
- Drag-drop upload rejects files over 25 MiB before FileReader allocation.
|
||||
|
||||
Checked:
|
||||
|
||||
- Notifications render messages with `textContent`.
|
||||
- GitHub repository card fields reviewed use `textContent` or escaping for remote metadata.
|
||||
|
||||
Not fully exercised:
|
||||
|
||||
- No live Wails UI/browser automation was run in this audit. Add frontend interaction tests for file-drop, import/export, and update UI in future work.
|
||||
|
||||
## 15. Infrastructure and Deployment Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- GitHub Actions release workflow now uses pinned Wails CLI and `npm ci`.
|
||||
- Local `release.sh` now uses pinned Wails CLI and `npm ci`.
|
||||
|
||||
Checked:
|
||||
|
||||
- No Docker, Kubernetes, Terraform, or cloud deployment config was present in the reviewed file list.
|
||||
- Wails config was reviewed for frontend build/install commands.
|
||||
|
||||
Residual:
|
||||
|
||||
- Release artifacts are not signed/notarized in the reviewed workflow.
|
||||
- CI workflow has `contents: write`, required for release attachment, but should remain limited to release jobs.
|
||||
|
||||
## 16. Logging, Monitoring, and Error Handling Review
|
||||
|
||||
Checked:
|
||||
|
||||
- Audit log records profile changes, import/export counts, connects/disconnects, and secret access without raw secret values.
|
||||
- Fixed import validation returns actionable errors rather than panics.
|
||||
- Frontend upload errors now surface a user-safe oversized-file message.
|
||||
|
||||
Residual:
|
||||
|
||||
- Some protocol errors are emitted directly to the UI. Continue avoiding credentials in error strings from remote services.
|
||||
- Consider adding structured debug logs for rejected imports without dumping payloads.
|
||||
|
||||
## 17. Test Coverage and Security Regression Gaps
|
||||
|
||||
Added/updated tests:
|
||||
|
||||
- API rejects unsafe GitHub base URL.
|
||||
- API rejects null and oversized imports.
|
||||
- API rejects oversized inline SFTP upload before session lookup.
|
||||
- Export/import rejects oversized encrypted files.
|
||||
- Storage import rejects null profile entries and invalid strategy before looping.
|
||||
- Validation covers GitHub URL and VNC host/tunnel cases.
|
||||
- SSH client `Connect` requires explicit host-key callback.
|
||||
|
||||
Remaining gaps:
|
||||
|
||||
- Browser/Wails interaction tests for file-drop XSS and upload cap.
|
||||
- Load tests for very large SFTP directories, RAW history, and recordings.
|
||||
- End-to-end import/export tests through the actual Wails UI.
|
||||
- Release workflow provenance/signing tests.
|
||||
|
||||
## 18. Prioritised Remediation Plan
|
||||
|
||||
Immediate:
|
||||
|
||||
- Keep the fixed tests in CI.
|
||||
- Add `npm audit` and govulncheck to CI if not already present.
|
||||
- Warn users when saving or connecting direct VNC profiles without SSH tunnelling.
|
||||
|
||||
Short-term:
|
||||
|
||||
- Replace deprecated `xterm` packages with maintained `@xterm/*` packages.
|
||||
- Add frontend tests for DOM injection via filenames and imported profile metadata.
|
||||
- Add streaming drag-drop upload support or route large drops through the native file upload path.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Add release artifact signing/notarization and checksums with provenance.
|
||||
- Add application-level limits for RAW history, terminal recording size, SFTP listing size, and GitHub error body reads.
|
||||
|
||||
Long-term:
|
||||
|
||||
- Add a security-focused CI job: tests, `npm audit`, govulncheck, secret scanning, and static DOM-sink checks.
|
||||
- Introduce a profile schema version and migration/normalization layer for imports.
|
||||
|
||||
## 19. False Positives / Needs Manual Verification
|
||||
|
||||
- `govulncheck -show verbose` initially reported module-level `x/net` advisories that were not reachable. The module was still upgraded to remove latent risk.
|
||||
- Direct VNC is intentionally supported; risk depends on network trust and VNC server security type.
|
||||
- File-drop XSS was fixed by code inspection; manual Wails UI verification should still be added.
|
||||
- No live external service testing was performed.
|
||||
|
||||
## 20. Final Notes
|
||||
|
||||
The repo is in a materially better state after this run: profile data is validated before storage, local resource-consumption paths are bounded, the file-drop DOM sink is escaped, dependency scanners are clean, and release dependency installation is more reproducible.
|
||||
|
||||
The main residual security posture issue is user-selected insecure connectivity: Telnet/RAW/direct VNC remain useful but should be presented with clear safety context.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Functionality and Next Steps Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix pass moves Nexus closer to predictable large-file behavior: file-backed Leviathan documents stay file-backed for save/export, recordings parse incrementally, and unavoidable in-memory parsers now advertise and enforce clear limits.
|
||||
|
||||
The most useful next work is to surface these limits in the Leviathan UI and add user-facing guidance when an operation needs a smaller selection or a streaming implementation.
|
||||
|
||||
## 2. Current Functional Risks
|
||||
|
||||
Leviathan spectrogram conversion still uses bounded in-memory audio/image buffers because the current encoder/decoder APIs operate on byte slices. Very large images, MP3s, WAVs, or decoded MP3 output will now be rejected with an error rather than processed.
|
||||
|
||||
PDF, certificate, and key scanners still operate on bounded whole-document buffers for structural parsing. The cap avoids memory denial of service, but users working with very large firmware or document corpora may need range-based scans in future.
|
||||
|
||||
Recording playback is now line-streamed, but the returned API shape still collects parsed events before sending them to the frontend. Very long recordings are still protected by the playback cap; true progressive playback would require an API contract change.
|
||||
|
||||
## 3. Code Quality Improvements Completed
|
||||
|
||||
Chunk-first behavior:
|
||||
|
||||
- Leviathan working-copy save now writes with `Document.ForEachChunk`.
|
||||
- Leviathan case export now streams `working.bin` into the zip file on disk.
|
||||
- Recording playback parses line-by-line instead of reading and splitting the whole file.
|
||||
|
||||
Bounded read behavior:
|
||||
|
||||
- Spectrogram image/audio inputs reject oversize data before decode.
|
||||
- Image decode checks dimensions before allocating decoded pixels.
|
||||
- HTTP response helpers cap JSON and error bodies.
|
||||
- SSH key, VNC key, vault, public-key, PDF, and BinSnoop file reads have explicit limits.
|
||||
|
||||
Responsiveness:
|
||||
|
||||
- Heavy Leviathan spectrogram/export operations no longer hold the engine lock while processing or showing native save dialogs.
|
||||
|
||||
## 4. Frontend Interaction and Error Handling
|
||||
|
||||
Recommended UI follow-up:
|
||||
|
||||
- Show limit-aware messages for spectrogram tools, such as image max size, audio max size, and decoded image dimension limits.
|
||||
- For PDF/cert/key tools, show “scan a smaller file or selected range” guidance when caps are hit.
|
||||
- Add a small status line in Leviathan for whether an operation is streaming, bounded-buffer, or selection-based.
|
||||
- For recording playback, consider progressive playback loading so long recordings do not need all parsed events returned at once.
|
||||
|
||||
## 5. Testing Roadmap
|
||||
|
||||
Completed in this pass:
|
||||
|
||||
- Oversized spectrogram image dimensions are rejected before full decode.
|
||||
- Leviathan bounded document reads reject oversize documents.
|
||||
- Patched working-file writes preserve changed bytes.
|
||||
- Oversized GitHub/update responses are rejected.
|
||||
- Oversized SSH public keys, vault files, PDFs, and BinSnoop files are rejected.
|
||||
|
||||
Recommended next tests:
|
||||
|
||||
- Wails UI tests for Leviathan oversized image/audio/PDF error messages.
|
||||
- A case-export test that opens a sparse file-backed document and verifies the zip is produced without materializing `working.bin`.
|
||||
- A recording playback test with malformed lines and a long valid event stream.
|
||||
- Browser interaction tests for the Leviathan tab while a long analysis is running.
|
||||
|
||||
## 6. Recommended New Functionality
|
||||
|
||||
Add range-based versions of whole-document Leviathan tools:
|
||||
|
||||
- `keys.scanRange`
|
||||
- `certs.scanRange`
|
||||
- `pdf.quickScan` for headers/xref/trailer without full structural recovery
|
||||
- `spectrogram.renderRange` for selected audio windows
|
||||
|
||||
Add streaming media support later:
|
||||
|
||||
- Decode audio from file/range readers instead of complete byte slices.
|
||||
- Stream WAV output directly to disk for spectrogram generation.
|
||||
- Stream image insertion output to disk when carrier audio is large.
|
||||
|
||||
## 7. Caveats
|
||||
|
||||
The new caps trade permissiveness for stability. Files above the limits now fail fast instead of attempting best-effort processing.
|
||||
|
||||
The frontend build and regression checks passed, but I did not run a packaged Wails app manually in this pass.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Security Audit Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix-mode audit focused on file-reading and response-reading paths that could allocate complete files or unbounded remote bodies in memory.
|
||||
|
||||
The main risk fixed was local denial of service from large user-selected files, imported Leviathan artefacts, recordings, SSH key files, vault files, and remote update/GitHub API responses. Where the codebase already had a chunked document model, saves and exports now use it. Where a parser still requires a complete byte slice, the code now rejects oversized inputs before allocation.
|
||||
|
||||
Verification passed with targeted package tests, `go test ./...`, frontend regression tests, frontend build, and `git diff --check`.
|
||||
|
||||
## 2. Scope Reviewed
|
||||
|
||||
Reviewed and changed:
|
||||
|
||||
- Leviathan file-backed document save, case export, spectrogram image/audio conversion, PDF parsing, and BinSnoop file helpers.
|
||||
- Wails API helpers that read SSH public keys and Leviathan image/audio inputs.
|
||||
- SSH and VNC tunnel private-key loading.
|
||||
- GitHub client and update-checker HTTP response handling.
|
||||
- Recording playback parsing.
|
||||
- Encrypted secrets vault loading.
|
||||
|
||||
Not live-tested:
|
||||
|
||||
- A packaged Wails desktop binary.
|
||||
- Real remote SSH/VNC/GitHub endpoints.
|
||||
- Very large real-world media corpora beyond automated guard tests.
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
I searched for `ReadFile`, `ReadAll`, and whole-document `Range(0, size)` patterns, then classified each as chunked, bounded, embedded/test-only, or unsafe. Fixes were kept to low-risk local changes:
|
||||
|
||||
- Prefer `Document.ForEachChunk` for writing/exporting working file bytes.
|
||||
- Stream line-oriented recordings with `bufio.Scanner`.
|
||||
- Limit HTTP response reads with `io.LimitReader`.
|
||||
- Add `os.Stat` size checks before unavoidable bounded `os.ReadFile` calls.
|
||||
- Add parser-specific caps before whole-document reads.
|
||||
|
||||
## 4. Findings Summary
|
||||
|
||||
| ID | Severity | Confidence | Title | Component | Status |
|
||||
|---|---:|---:|---|---|---|
|
||||
| SEC-20260616-001 | Medium | High | Leviathan save/export and spectrogram tools could materialize large files | Leviathan API, spectrogram | Fixed |
|
||||
| SEC-20260616-002 | Medium | High | Remote API/update responses were read without size limits | GitHub client, updater | Fixed |
|
||||
| SEC-20260616-003 | Medium | High | Local app data/key reads lacked size caps | SSH keys, VNC keys, vault, BinSnoop | Fixed |
|
||||
| SEC-20260616-004 | Low | High | Recording playback loaded the whole capped recording file | Recording playback | Fixed |
|
||||
| SEC-20260616-005 | Low | Medium | Some parsers still require complete bounded buffers | PDF, cert/key scanners, media decoders | Accepted with caps |
|
||||
|
||||
## 5. Detailed Findings
|
||||
|
||||
### SEC-20260616-001: Leviathan full-file allocation paths
|
||||
|
||||
Leviathan save/export and spectrogram operations could duplicate entire working files in memory. Case export also built the zip archive in memory before writing it.
|
||||
|
||||
Fixed in:
|
||||
|
||||
- `internal/api/leviathan.go`
|
||||
- `internal/leviathan/spectrogram/spectrogram.go`
|
||||
- `internal/leviathan/pdf/pdf.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- Working-copy save writes via `Document.ForEachChunk`.
|
||||
- Case export streams `working.bin` directly into `zip.Writer` backed by the destination file.
|
||||
- Spectrogram encode/view/insert rejects oversized image/audio documents before bounded decode.
|
||||
- Image decoding checks compressed byte size and decoded pixel dimensions before full image decode.
|
||||
- PDF parsing rejects files above 128 MiB before whole-document structural parsing.
|
||||
- Long-running spectrogram/export tools no longer hold the Leviathan engine lock while doing heavy work or native save dialogs.
|
||||
|
||||
### SEC-20260616-002: Unbounded remote response reads
|
||||
|
||||
GitHub and update-check response bodies could be read without an explicit maximum.
|
||||
|
||||
Fixed in:
|
||||
|
||||
- `internal/githubclient/githubclient.go`
|
||||
- `internal/updater/updater.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- GitHub JSON responses are capped at 4 MiB.
|
||||
- GitHub error bodies are capped at 64 KiB.
|
||||
- Update release/manifest responses are capped at 1 MiB.
|
||||
- Repository archive downloads continue to stream to disk instead of memory.
|
||||
|
||||
### SEC-20260616-003: Local key/vault/helper reads lacked caps
|
||||
|
||||
Several local helper paths read files directly even though the expected files are small.
|
||||
|
||||
Fixed in:
|
||||
|
||||
- `internal/api/api.go`
|
||||
- `internal/sshclient/sshclient.go`
|
||||
- `internal/sessions/manager.go`
|
||||
- `internal/secrets/secrets.go`
|
||||
- `internal/leviathan/binsnoop/common.go`
|
||||
- `internal/leviathan/binsnoop/binsnoop.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- SSH public keys are capped at 64 KiB.
|
||||
- SSH private keys for SSH and VNC tunnel auth are capped at 1 MiB.
|
||||
- Secret vault files are capped at 10 MiB.
|
||||
- BinSnoop direct file helpers are capped at 64 MiB; the UI path already uses a bounded buffer.
|
||||
|
||||
### SEC-20260616-004: Recording playback whole-file read
|
||||
|
||||
Recording playback had a 100 MiB file cap, but still read and split the full file in memory.
|
||||
|
||||
Fixed in:
|
||||
|
||||
- `internal/recording/recorder.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- Playback now opens the recording and scans line-by-line.
|
||||
- Individual playback lines are capped at 8 MiB.
|
||||
- Existing malformed-event tolerance is preserved.
|
||||
|
||||
## 6. Residual Risk
|
||||
|
||||
Some operations still need bounded complete buffers because downstream parsers or decoders require byte slices: image/audio spectrogram conversion, PDF structural parsing, certificate/key scanning, and small SSH/vault/public-key parsing. These now fail before allocation when inputs exceed explicit caps.
|
||||
|
||||
Truly streaming versions of every binary parser would be a larger Leviathan architecture project. The current fixes remove the unbounded allocation behavior while preserving tool behavior for normal files.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
Passed:
|
||||
|
||||
- `go test ./internal/api ./internal/leviathan/spectrogram ./internal/leviathan/pdf ./internal/leviathan/binsnoop ./internal/updater ./internal/githubclient ./internal/secrets ./internal/recording ./internal/sshclient ./internal/sessions -count=1`
|
||||
- `go test ./... -count=1`
|
||||
- `npm --prefix frontend test`
|
||||
- `npm --prefix frontend run build`
|
||||
- `git diff --check`
|
||||
|
||||
New tests cover oversized GitHub/update bodies, oversized spectrogram images, bounded Leviathan document reads, chunked patched-file writes, oversized public keys, oversized vault files, oversized PDFs, and BinSnoop file caps.
|
||||
+177
-27
@@ -6,6 +6,7 @@ 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'
|
||||
@@ -16,6 +17,111 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
MIN_GO_VERSION="1.25.0"
|
||||
WAILS_VERSION="$(awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' go.mod)"
|
||||
WAILS_BIN=""
|
||||
WAILS_USE_GO_RUN=0
|
||||
BUILT_TARGETS=0
|
||||
SKIPPED_TARGETS=0
|
||||
|
||||
if [ -x "$SCRIPT_DIR/.tools/go/bin/go" ]; then
|
||||
export PATH="$SCRIPT_DIR/.tools/go/bin:$PATH"
|
||||
fi
|
||||
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
|
||||
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
|
||||
mkdir -p "$GOCACHE" "$GOPATH"
|
||||
export PATH="$GOPATH/bin:$PATH"
|
||||
|
||||
version_at_least() {
|
||||
local have="${1#go}"
|
||||
local want="$2"
|
||||
local IFS=.
|
||||
local have_parts want_parts
|
||||
read -r -a have_parts <<< "$have"
|
||||
read -r -a want_parts <<< "$want"
|
||||
for i in 0 1 2; do
|
||||
local h="${have_parts[$i]:-0}"
|
||||
local w="${want_parts[$i]:-0}"
|
||||
if (( h > w )); then return 0; fi
|
||||
if (( h < w )); then return 1; fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
check_go() {
|
||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
||||
|
||||
local version
|
||||
version="$(go env GOVERSION 2>/dev/null || go version | awk '{print $3}')"
|
||||
if ! version_at_least "$version" "$MIN_GO_VERSION"; then
|
||||
err "Go ${MIN_GO_VERSION}+ is required by go.mod; found ${version} at $(command -v go)"
|
||||
err "Install a newer Go, or place it at .tools/go/bin/go for this project."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_wails() {
|
||||
if WAILS_BIN="$(command -v wails 2>/dev/null)"; then
|
||||
WAILS_USE_GO_RUN=0
|
||||
return
|
||||
fi
|
||||
|
||||
local gopath
|
||||
gopath="$(go env GOPATH 2>/dev/null || true)"
|
||||
if [ -n "$gopath" ] && [ -x "$gopath/bin/wails" ]; then
|
||||
WAILS_BIN="$gopath/bin/wails"
|
||||
WAILS_USE_GO_RUN=0
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$WAILS_VERSION" ]; then
|
||||
WAILS_BIN="github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}"
|
||||
WAILS_USE_GO_RUN=1
|
||||
warn "Wails CLI not found; using pinned ${WAILS_BIN} via go run"
|
||||
return
|
||||
fi
|
||||
|
||||
err "Wails is not installed and no Wails version was found in go.mod"
|
||||
exit 1
|
||||
}
|
||||
|
||||
wails_cmd() {
|
||||
if [ "$WAILS_USE_GO_RUN" -eq 1 ]; then
|
||||
go run "$WAILS_BIN" "$@"
|
||||
else
|
||||
"$WAILS_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_upx() {
|
||||
if ! nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
|
||||
return
|
||||
fi
|
||||
if command -v upx >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
warn "UPX is not installed; compressed targets will be skipped unless the target does not use UPX."
|
||||
}
|
||||
|
||||
linux_deps_ok() {
|
||||
command -v pkg-config >/dev/null 2>&1 || return 1
|
||||
pkg-config --exists gtk+-3.0 || return 1
|
||||
if ! pkg-config --exists webkit2gtk-4.1 && ! pkg-config --exists webkit2gtk-4.0; then
|
||||
return 1
|
||||
fi
|
||||
if ! pkg-config --exists ayatana-appindicator3-0.1 && ! pkg-config --exists appindicator3-0.1; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_tags() {
|
||||
if command -v pkg-config >/dev/null 2>&1 \
|
||||
&& pkg-config --exists webkit2gtk-4.1 \
|
||||
&& ! pkg-config --exists webkit2gtk-4.0; then
|
||||
echo "webkit2_41"
|
||||
fi
|
||||
}
|
||||
|
||||
build_version() {
|
||||
local tag
|
||||
local commit
|
||||
@@ -34,22 +140,15 @@ build_ldflags() {
|
||||
}
|
||||
|
||||
check_deps() {
|
||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
||||
check_go
|
||||
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; }
|
||||
|
||||
if ! command -v wails >/dev/null 2>&1; then
|
||||
if [ -f "$HOME/go/bin/wails" ]; then
|
||||
export PATH="$PATH:$HOME/go/bin"
|
||||
else
|
||||
err "Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
resolve_wails
|
||||
ensure_upx
|
||||
|
||||
log "Go: $(go version)"
|
||||
log "Node: $(node --version)"
|
||||
log "Wails: $(wails version 2>/dev/null | head -1)"
|
||||
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
|
||||
}
|
||||
|
||||
install_deps() {
|
||||
@@ -57,7 +156,11 @@ install_deps() {
|
||||
go mod download
|
||||
|
||||
log "Installing frontend dependencies…"
|
||||
cd frontend && npm install && cd ..
|
||||
if [ -f frontend/package-lock.json ]; then
|
||||
(cd frontend && npm ci)
|
||||
else
|
||||
(cd frontend && npm install)
|
||||
fi
|
||||
}
|
||||
|
||||
host_os() {
|
||||
@@ -68,6 +171,14 @@ host_os() {
|
||||
esac
|
||||
}
|
||||
|
||||
host_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "amd64" ;;
|
||||
arm64|aarch64) echo "arm64" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pick_cc() {
|
||||
local target_os="$1"
|
||||
local target_arch="$2"
|
||||
@@ -97,27 +208,57 @@ build_target() {
|
||||
local target_os="$1"
|
||||
local target_arch="$2"
|
||||
local host="$3"
|
||||
local version="$4"
|
||||
local host_arch_value="$4"
|
||||
local version="$5"
|
||||
local platform="${target_os}/${target_arch}"
|
||||
nexus_configure_release_cgo
|
||||
local ldflags
|
||||
ldflags="$(build_ldflags "${version}")"
|
||||
local tags
|
||||
tags="$(build_tags)"
|
||||
local build_args=("-platform" "$target_os/$target_arch" "-trimpath")
|
||||
if nexus_use_upx_for_target "$target_os"; then
|
||||
if ! command -v upx >/dev/null 2>&1; then
|
||||
warn "Skipping ${platform} (NEXUS_COMPRESS=1 requires UPX)"
|
||||
((SKIPPED_TARGETS += 1))
|
||||
return 0
|
||||
fi
|
||||
build_args+=("-upx")
|
||||
if [ -n "${NEXUS_UPX_FLAGS:-}" ]; then
|
||||
build_args+=("-upxflags" "$NEXUS_UPX_FLAGS")
|
||||
fi
|
||||
elif nexus_skips_upx_for_target "$target_os"; then
|
||||
warn "Skipping UPX compression for ${platform}; distribute the zipped .app instead, or set NEXUS_FORCE_MACOS_UPX=1 with suitable NEXUS_UPX_FLAGS."
|
||||
fi
|
||||
if [ -n "$tags" ]; then
|
||||
build_args+=("-tags" "$tags")
|
||||
fi
|
||||
build_args+=("-ldflags" "$ldflags")
|
||||
|
||||
local platform="${target_os}/${target_arch}"
|
||||
local cc=""
|
||||
|
||||
if [ "$target_os" != "$host" ]; then
|
||||
if [ "$target_os" = "linux" ] && ! linux_deps_ok; then
|
||||
warn "Skipping ${platform} (missing Linux GTK/WebKit build dependencies)"
|
||||
((SKIPPED_TARGETS += 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$target_os" != "$host" ] || [ "$target_arch" != "$host_arch_value" ]; then
|
||||
cc="$(pick_cc "$target_os" "$target_arch")"
|
||||
if [ -n "$cc" ] && ! command -v "$cc" >/dev/null 2>&1; then
|
||||
warn "Skipping ${platform} (missing C compiler: ${cc})"
|
||||
((SKIPPED_TARGETS += 1))
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Building ${platform}…"
|
||||
if [ -n "$cc" ]; then
|
||||
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
|
||||
CC="$cc" CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
|
||||
else
|
||||
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
|
||||
CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
|
||||
fi
|
||||
((BUILT_TARGETS += 1))
|
||||
}
|
||||
|
||||
main() {
|
||||
@@ -129,24 +270,33 @@ main() {
|
||||
|
||||
local host
|
||||
host="$(host_os)"
|
||||
if [ "$host" = "unknown" ]; then
|
||||
err "Unsupported host OS for this script. Use macOS or Linux."
|
||||
local arch
|
||||
arch="$(host_arch)"
|
||||
if [ "$host" = "unknown" ] || [ "$arch" = "unknown" ]; then
|
||||
err "Unsupported host platform: $(uname -s)/$(uname -m)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build host targets first
|
||||
build_target "$host" "amd64" "$host" "$version"
|
||||
build_target "$host" "arm64" "$host" "$version"
|
||||
build_target "$host" "$arch" "$host" "$arch" "$version"
|
||||
|
||||
# Cross-build Linux and Windows targets where toolchains are available
|
||||
if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then
|
||||
build_target "linux" "amd64" "$host" "$version"
|
||||
build_target "linux" "arm64" "$host" "$version"
|
||||
build_target "windows" "amd64" "$host" "$version"
|
||||
build_target "windows" "arm64" "$host" "$version"
|
||||
for target in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
local target_os="${target%%/*}"
|
||||
local target_arch="${target##*/}"
|
||||
if [ "$target_os" = "$host" ] && [ "$target_arch" = "$arch" ]; then
|
||||
continue
|
||||
fi
|
||||
build_target "$target_os" "$target_arch" "$host" "$arch" "$version"
|
||||
done
|
||||
fi
|
||||
|
||||
log "Builds complete. Output: build/bin/"
|
||||
if [ "$BUILT_TARGETS" -eq 0 ]; then
|
||||
err "No targets were built; ${SKIPPED_TARGETS} target(s) were skipped because required system toolchains are missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Builds complete (${BUILT_TARGETS} built, ${SKIPPED_TARGETS} skipped). Output: build/bin/"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -7,6 +7,10 @@ function Write-Info($Message) { Write-Host "[nexus] $Message" -ForegroundColor G
|
||||
function Write-Warn($Message) { Write-Host "[nexus] $Message" -ForegroundColor Yellow }
|
||||
function Write-Err($Message) { Write-Host "[nexus] $Message" -ForegroundColor Red }
|
||||
|
||||
function Test-BoolEnabled([string]$Value) {
|
||||
return $Value -match '^(1|true|TRUE|yes|YES|on|ON)$'
|
||||
}
|
||||
|
||||
function Get-WailsPath {
|
||||
$wails = Get-Command wails -ErrorAction SilentlyContinue
|
||||
if ($wails) { return $wails.Source }
|
||||
@@ -67,7 +71,7 @@ function Get-BuildVersion {
|
||||
}
|
||||
|
||||
function Get-Ldflags([string]$Version) {
|
||||
$flags = "-s -w -X github.com/andydixon/nexus/internal/api.appVersion=$Version"
|
||||
$flags = "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$Version"
|
||||
if ($env:NEXUS_UPDATE_URL) {
|
||||
$flags += " -X github.com/andydixon/nexus/internal/api.appUpdateURL=$($env:NEXUS_UPDATE_URL)"
|
||||
}
|
||||
@@ -88,8 +92,23 @@ function Build {
|
||||
$ldflags = Get-Ldflags $version
|
||||
|
||||
$env:CGO_ENABLED = '1'
|
||||
if (-not $env:CGO_CFLAGS) { $env:CGO_CFLAGS = '-O2 -g0' }
|
||||
if (-not $env:CGO_CXXFLAGS) { $env:CGO_CXXFLAGS = '-O2 -g0' }
|
||||
|
||||
$buildArgs = @('build', '-trimpath')
|
||||
if (Test-BoolEnabled $env:NEXUS_COMPRESS) {
|
||||
if (-not (Get-Command upx -ErrorAction SilentlyContinue)) {
|
||||
throw 'NEXUS_COMPRESS=1 requires UPX. Install it first.'
|
||||
}
|
||||
$buildArgs += '-upx'
|
||||
if ($env:NEXUS_UPX_FLAGS) {
|
||||
$buildArgs += @('-upxflags', $env:NEXUS_UPX_FLAGS)
|
||||
}
|
||||
}
|
||||
$buildArgs += @('-ldflags', $ldflags)
|
||||
|
||||
Write-Info 'Building production binary (CGO_ENABLED=1)...'
|
||||
& $wailsPath build -ldflags $ldflags
|
||||
& $wailsPath @buildArgs
|
||||
Write-Info 'Build complete: build\bin\'
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
source "$SCRIPT_DIR/scripts/build-common.sh"
|
||||
|
||||
# Colours for output
|
||||
RED='\033[0;31m'
|
||||
@@ -17,42 +18,154 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
MIN_GO_VERSION="1.25.0"
|
||||
WAILS_VERSION="$(nexus_wails_version go.mod)"
|
||||
WAILS_BIN=""
|
||||
WAILS_USE_GO_RUN=0
|
||||
|
||||
if [ -x "$SCRIPT_DIR/.tools/go/bin/go" ]; then
|
||||
export PATH="$SCRIPT_DIR/.tools/go/bin:$PATH"
|
||||
fi
|
||||
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
|
||||
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
|
||||
mkdir -p "$GOCACHE" "$GOPATH"
|
||||
export PATH="$GOPATH/bin:$PATH"
|
||||
|
||||
version_at_least() {
|
||||
local have="${1#go}"
|
||||
local want="$2"
|
||||
local IFS=.
|
||||
local have_parts want_parts
|
||||
read -r -a have_parts <<< "$have"
|
||||
read -r -a want_parts <<< "$want"
|
||||
for i in 0 1 2; do
|
||||
local h="${have_parts[$i]:-0}"
|
||||
local w="${want_parts[$i]:-0}"
|
||||
if (( h > w )); then return 0; fi
|
||||
if (( h < w )); then return 1; fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
check_go() {
|
||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
||||
|
||||
local version
|
||||
version="$(go env GOVERSION 2>/dev/null || go version | awk '{print $3}')"
|
||||
if ! version_at_least "$version" "$MIN_GO_VERSION"; then
|
||||
err "Go ${MIN_GO_VERSION}+ is required by go.mod; found ${version} at $(command -v go)"
|
||||
err "Install a newer Go, or place it at .tools/go/bin/go for this project."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_wails() {
|
||||
if WAILS_BIN="$(command -v wails 2>/dev/null)"; then
|
||||
WAILS_USE_GO_RUN=0
|
||||
return
|
||||
fi
|
||||
|
||||
local gopath
|
||||
gopath="$(go env GOPATH 2>/dev/null || true)"
|
||||
if [ -n "$gopath" ] && [ -x "$gopath/bin/wails" ]; then
|
||||
WAILS_BIN="$gopath/bin/wails"
|
||||
WAILS_USE_GO_RUN=0
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$WAILS_VERSION" ]; then
|
||||
WAILS_BIN="github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}"
|
||||
WAILS_USE_GO_RUN=1
|
||||
warn "Wails CLI not found; using pinned ${WAILS_BIN} via go run"
|
||||
return
|
||||
fi
|
||||
|
||||
err "Wails is not installed and no Wails version was found in go.mod"
|
||||
exit 1
|
||||
}
|
||||
|
||||
wails_cmd() {
|
||||
if [ "$WAILS_USE_GO_RUN" -eq 1 ]; then
|
||||
go run "$WAILS_BIN" "$@"
|
||||
else
|
||||
"$WAILS_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_upx() {
|
||||
if ! nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
|
||||
return
|
||||
fi
|
||||
if nexus_skips_upx_for_target "$(host_os)"; then
|
||||
return
|
||||
fi
|
||||
if command -v upx >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
err "NEXUS_COMPRESS=1 requires UPX. Install it first, for example: brew install upx"
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_linux_deps() {
|
||||
if [ "$(uname -s)" != "Linux" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
command -v pkg-config >/dev/null 2>&1 || {
|
||||
err "pkg-config is required for Linux Wails builds."
|
||||
err "On Ubuntu/Pop!_OS install: sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev"
|
||||
exit 1
|
||||
}
|
||||
|
||||
local missing=()
|
||||
pkg-config --exists gtk+-3.0 || missing+=("gtk+-3.0")
|
||||
if ! pkg-config --exists webkit2gtk-4.1 && ! pkg-config --exists webkit2gtk-4.0; then
|
||||
missing+=("webkit2gtk-4.1 or webkit2gtk-4.0")
|
||||
fi
|
||||
if ! pkg-config --exists ayatana-appindicator3-0.1 && ! pkg-config --exists appindicator3-0.1; then
|
||||
missing+=("ayatana-appindicator3-0.1 or appindicator3-0.1")
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
err "Missing Linux build dependencies: ${missing[*]}"
|
||||
err "On Ubuntu/Pop!_OS install: sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_tags() {
|
||||
nexus_build_tags
|
||||
}
|
||||
|
||||
host_os() {
|
||||
case "$(uname -s)" in
|
||||
Darwin) echo "darwin" ;;
|
||||
Linux) echo "linux" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_version() {
|
||||
local tag
|
||||
local commit
|
||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
|
||||
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
echo "${tag}-${commit}"
|
||||
nexus_build_version
|
||||
}
|
||||
|
||||
build_ldflags() {
|
||||
local version="$1"
|
||||
local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}"
|
||||
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
|
||||
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
|
||||
fi
|
||||
echo "${flags}"
|
||||
nexus_build_ldflags "$1"
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
check_deps() {
|
||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
||||
check_go
|
||||
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; }
|
||||
|
||||
if ! command -v wails >/dev/null 2>&1; then
|
||||
if [ -f "$HOME/go/bin/wails" ]; then
|
||||
export PATH="$PATH:$HOME/go/bin"
|
||||
else
|
||||
err "Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
check_linux_deps
|
||||
resolve_wails
|
||||
ensure_upx
|
||||
|
||||
log "All prerequisites found"
|
||||
log "Go: $(go version)"
|
||||
log "Node: $(node --version)"
|
||||
log "Wails: $(wails version 2>/dev/null | head -1)"
|
||||
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
|
||||
}
|
||||
|
||||
# Install dependencies
|
||||
@@ -61,7 +174,11 @@ install_deps() {
|
||||
go mod download
|
||||
|
||||
log "Installing frontend dependencies…"
|
||||
cd frontend && npm install && cd ..
|
||||
if [ -f frontend/package-lock.json ]; then
|
||||
(cd frontend && npm ci)
|
||||
else
|
||||
(cd frontend && npm install)
|
||||
fi
|
||||
|
||||
log "Dependencies installed"
|
||||
}
|
||||
@@ -71,7 +188,7 @@ dev() {
|
||||
check_deps
|
||||
install_deps
|
||||
log "Starting development mode…"
|
||||
wails dev
|
||||
wails_cmd dev
|
||||
}
|
||||
|
||||
# Production build
|
||||
@@ -81,14 +198,47 @@ build() {
|
||||
log "Building production binary…"
|
||||
local version
|
||||
version="$(build_version)"
|
||||
wails build -ldflags "$(build_ldflags "${version}")"
|
||||
local tags
|
||||
tags="$(build_tags)"
|
||||
nexus_configure_release_cgo
|
||||
local build_args=("-trimpath")
|
||||
if nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
|
||||
if nexus_skips_upx_for_target "$(host_os)"; then
|
||||
warn "Skipping UPX compression on macOS; distribute the zipped .app instead, or set NEXUS_FORCE_MACOS_UPX=1 with suitable NEXUS_UPX_FLAGS."
|
||||
else
|
||||
log "Using UPX compression"
|
||||
build_args+=("-upx")
|
||||
if [ -n "${NEXUS_UPX_FLAGS:-}" ]; then
|
||||
build_args+=("-upxflags" "$NEXUS_UPX_FLAGS")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$tags" ]; then
|
||||
log "Using Wails build tags: ${tags}"
|
||||
wails_cmd build "${build_args[@]}" -tags "$tags" -ldflags "$(build_ldflags "${version}")"
|
||||
else
|
||||
wails_cmd build "${build_args[@]}" -ldflags "$(build_ldflags "${version}")"
|
||||
fi
|
||||
|
||||
# Replace Wails-generated icon with custom app icon on macOS.
|
||||
if [[ "$OSTYPE" == darwin* ]]; then
|
||||
local app_bundle="build/bin/nexus-terminal.app"
|
||||
local custom_icns="build/darwin/AppIcon.icns"
|
||||
if [ -d "$app_bundle" ] && [ -f "$custom_icns" ]; then
|
||||
cp "$custom_icns" "$app_bundle/Contents/Resources/iconfile.icns"
|
||||
log "Replaced app icon with custom AppIcon.icns"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Build complete: build/bin/"
|
||||
}
|
||||
|
||||
# Run tests
|
||||
run_tests() {
|
||||
check_go
|
||||
log "Running tests…"
|
||||
go test ./... -v -count=1
|
||||
(cd frontend && npm test)
|
||||
}
|
||||
|
||||
# Clean build artefacts
|
||||
@@ -97,8 +247,9 @@ clean() {
|
||||
rm -rf build/bin
|
||||
rm -rf frontend/node_modules
|
||||
rm -rf frontend/src/js/lib
|
||||
rm -rf frontend/src/css/lib
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
go clean -testcache
|
||||
fi
|
||||
log "Clean complete"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Build and Release
|
||||
|
||||
This project uses [Wails](https://wails.io/) for desktop packaging.
|
||||
|
||||
## Ubuntu 24.04 prerequisites
|
||||
|
||||
Install the Linux build toolchain and Wails runtime headers with:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
pkg-config \
|
||||
zip
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Ubuntu 24.04 does not ship `libwebkit2gtk-4.0-dev`.
|
||||
- Wails `v2.12.0` supports Ubuntu 24.04 by building with the `webkit2_41` tag.
|
||||
- The local build scripts detect that package layout automatically and add the tag when needed.
|
||||
|
||||
You will also need these tools available in `PATH`:
|
||||
|
||||
- `go`
|
||||
- `node`
|
||||
- `npm`
|
||||
- `wails`
|
||||
|
||||
Install the Wails CLI with:
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||
```
|
||||
|
||||
## Build commands
|
||||
|
||||
Build the app binary for the current host:
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Build a packaged release for the current host platform:
|
||||
|
||||
```bash
|
||||
./release.sh
|
||||
```
|
||||
|
||||
By default, `./release.sh` now builds only the current host OS/architecture. On this Ubuntu 24.04 `amd64` host that means `linux/amd64`.
|
||||
|
||||
## Optional cross-compilers
|
||||
|
||||
If you want to add extra Linux/Windows targets from Ubuntu, install the relevant cross-compiler packages first:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y \
|
||||
gcc-aarch64-linux-gnu \
|
||||
gcc-mingw-w64-x86-64
|
||||
```
|
||||
|
||||
Then request explicit targets:
|
||||
|
||||
```bash
|
||||
NEXUS_RELEASE_TARGETS=linux/amd64,windows/amd64 ./release.sh
|
||||
```
|
||||
|
||||
## Platform caveats
|
||||
|
||||
- macOS targets cannot be cross-built from Linux with Wails. Build those on macOS.
|
||||
- `windows/arm64` is not covered by the default Ubuntu toolchain here.
|
||||
- `linux/arm64` needs more than `gcc-aarch64-linux-gnu`; it also needs an `arm64` sysroot and target GTK/WebKit development libraries for CGO to link cleanly.
|
||||
- Tested on Ubuntu 24.04 `amd64`: `linux/amd64` and `windows/amd64` build successfully; `linux/arm64` does not in the default host setup above.
|
||||
+295
-14
@@ -1,26 +1,307 @@
|
||||
# Nexus Terminal Overview
|
||||
|
||||
Nexus Terminal is a desktop client for managing SSH, Telnet, RAW TCP, and GitHub sessions in one place. It is designed for people who work across multiple servers, environments, or repositories and want a fast, focused workflow with strong credential handling.
|
||||
Nexus Terminal is a desktop connection and analysis workspace for people who manage servers, network services, remote desktops, repositories, and binary artefacts every day. It brings SSH, Telnet, RAW TCP, VNC, GitHub, local shell, and Leviathan binary-analysis workflows into one focused app with secure credential handling, multi-session organisation, file transfer, automation, recording, and inspection tooling built in.
|
||||
|
||||
## What it does
|
||||
## Product positioning
|
||||
|
||||
- Connect to SSH, Telnet, and RAW TCP endpoints with saved profiles or quick connect.
|
||||
- Manage multiple live sessions with tabs, split panes, and broadcast input.
|
||||
- Browse and move files on SSH hosts with a built-in SFTP browser.
|
||||
- Record sessions for later playback.
|
||||
- Store credentials securely with OS keychain support and an encrypted vault fallback.
|
||||
Nexus is more than an SSH client. It is a single command centre for remote access, terminal operations, file movement, raw socket inspection, GitHub repository visibility, binary triage, and repeatable operational workflows.
|
||||
|
||||
## Highlights
|
||||
Use Nexus when you need to:
|
||||
|
||||
- Multi-session tab bar with pinning, reordering, and split panes.
|
||||
- SFTP browser with drag-and-drop uploads and a directory picker.
|
||||
- Clipboard-aware terminal with search, snippets, and right-click actions.
|
||||
- Auto-reconnect, login scripts, and SSH extras such as jump hosts and port forwarding.
|
||||
- GitHub view for browsing repositories by organisation.
|
||||
- Connect quickly to many systems without re-entering credentials.
|
||||
- Keep multiple live sessions organised with tabs, pinned tabs, split panes, and status indicators.
|
||||
- Move files over SSH without leaving the terminal workspace.
|
||||
- Work safely with host key verification, encrypted secrets, TOTP support, and audit logging.
|
||||
- Reuse commands, automate login flows, record sessions, and react to terminal output.
|
||||
- Support mixed estates that include SSH servers, Telnet devices, raw TCP services, VNC desktops, GitHub repositories, local shells, firmware images, binaries, archives, and other artefacts.
|
||||
|
||||
## Main feature groups
|
||||
|
||||
### Multi-protocol connections
|
||||
|
||||
- **SSH** for secure remote terminal access.
|
||||
- **Telnet** for legacy devices and services.
|
||||
- **RAW TCP** for direct socket interaction, protocol testing, and device troubleshooting.
|
||||
- **VNC** for remote desktop access.
|
||||
- **GitHub** for repository browsing and download workflows.
|
||||
- **Local terminal** for opening a shell on the local machine.
|
||||
- **Leviathan** for binary inspection, editing, carving, transforms, and spectrogram workflows.
|
||||
- **Quick connect** for one-off sessions without saving a profile.
|
||||
- **Saved profiles** for repeat connections.
|
||||
|
||||
### Profile management
|
||||
|
||||
- Create, edit, duplicate, and delete connection profiles.
|
||||
- Store display names, descriptions, notes, groups, tags, environment labels, and favourites.
|
||||
- Search profiles by name, host, or tag.
|
||||
- Filter profiles by protocol, group, tag, and favourite status.
|
||||
- Test basic host and port reachability before connecting.
|
||||
- Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files, and jump hosts.
|
||||
- Export and import profiles using encrypted `.nexus` bundles.
|
||||
- Choose import behaviour for duplicates: skip, overwrite, or duplicate.
|
||||
|
||||
### Multi-session workspace
|
||||
|
||||
- Tabbed sessions with connection state indicators.
|
||||
- Pin tabs to keep important systems visible.
|
||||
- Rename sessions.
|
||||
- Reorder tabs.
|
||||
- Scroll overflowing tabs and hover them to see full names and connection start times.
|
||||
- Close one session or all sessions.
|
||||
- Split panes vertically or horizontally.
|
||||
- Resize split panes.
|
||||
- Switch tabs by keyboard shortcut.
|
||||
- Broadcast input to multiple connected sessions.
|
||||
- Show disconnected overlays with a one-click reconnect action.
|
||||
- Auto-reconnect support for SSH, Telnet, and RAW TCP profiles.
|
||||
- Command palette for fast access to connections, settings, terminal actions, security tools, import/export, snippets, triggers, and local terminal.
|
||||
|
||||
### Terminal experience
|
||||
|
||||
- xterm.js-powered terminal rendering.
|
||||
- Configurable terminal font family, font size, scrollback, cursor style, and colour theme.
|
||||
- Search within terminal output.
|
||||
- Search options for case sensitivity, regular expressions, and whole-word matching.
|
||||
- Copy, paste, select all, clear, and reset from the terminal context menu.
|
||||
- Clipboard-aware paste behaviour.
|
||||
- Clickable web links where supported.
|
||||
- Alternate-screen scroll handling for tools such as `tmux`, `screen`, and `byobu`.
|
||||
- Export terminal scrollback as plain text.
|
||||
- Send break signals where supported.
|
||||
- Ping active sessions to check responsiveness.
|
||||
|
||||
### SSH capabilities
|
||||
|
||||
- Password authentication.
|
||||
- Public key authentication.
|
||||
- Keyboard-interactive authentication.
|
||||
- SSH agent authentication.
|
||||
- SSH agent forwarding.
|
||||
- Private key path discovery from `~/.ssh`.
|
||||
- SSH key manager for listing, generating, copying, and deleting key pairs.
|
||||
- Ed25519, RSA, and ECDSA key generation.
|
||||
- Optional private key passphrase handling.
|
||||
- TOTP / 2FA secret storage and code generation for keyboard-interactive login flows.
|
||||
- Host key verification policies per profile: Strict, Ask, Accept new, and Insecure.
|
||||
- Known hosts storage and management.
|
||||
- Jump host support for bastion and multi-hop access.
|
||||
- SOCKS5 proxy support.
|
||||
- HTTP CONNECT proxy support.
|
||||
- Local and remote port forwarding.
|
||||
- Runtime port forward management for active SSH sessions.
|
||||
- Startup commands.
|
||||
- Custom environment variables.
|
||||
- Terminal type configuration.
|
||||
- Keepalive interval and missed-keepalive settings.
|
||||
- Compression option.
|
||||
- Expect-style login scripts with wait and send steps.
|
||||
- Built-in SFTP browser for file transfer.
|
||||
|
||||
### SFTP file browser
|
||||
|
||||
- Open a side panel for SSH file browsing.
|
||||
- Navigate remote folders with breadcrumbs.
|
||||
- Go directly to a remote path.
|
||||
- Bookmark remote locations.
|
||||
- Refresh remote listings.
|
||||
- Create folders.
|
||||
- Upload files.
|
||||
- Download single files or multiple selected files.
|
||||
- Rename files and folders.
|
||||
- Delete files and folders.
|
||||
- Multi-select support for batch actions.
|
||||
- Resizable SFTP panel alongside the terminal.
|
||||
- Drag and drop files into an SSH terminal.
|
||||
- Choose whether dropped files are uploaded or pasted into the terminal as text.
|
||||
- Upload directory picker with folder filtering, breadcrumbs, keyboard navigation, and permission badges.
|
||||
- Permission labels for read-only and inaccessible folders.
|
||||
|
||||
### Telnet capabilities
|
||||
|
||||
- Telnet option negotiation.
|
||||
- Terminal type negotiation.
|
||||
- NAWS window-size updates.
|
||||
- Local echo option.
|
||||
- Line mode option.
|
||||
- Configurable line endings: LF, CR, CRLF, or none.
|
||||
- Configurable encoding: UTF-8, ASCII, and ISO 8859-1.
|
||||
- Negotiation debug mode.
|
||||
- Expect-style login scripts.
|
||||
- Auto-reconnect with configurable retry count.
|
||||
- Break signal support.
|
||||
|
||||
### RAW TCP capabilities
|
||||
|
||||
- Direct TCP socket sessions.
|
||||
- Text, hex, and split data views.
|
||||
- Send data as text, hex, or escaped bytes.
|
||||
- Quick insert controls for common bytes such as NUL, CR, LF, ETX, and EOT.
|
||||
- Payload presets for reusable test messages.
|
||||
- Optional timestamps.
|
||||
- Optional send/receive direction labels.
|
||||
- Pause and resume display updates.
|
||||
- Clear the capture buffer.
|
||||
- Save RAW session captures.
|
||||
- Configurable encoding and line endings.
|
||||
- Connect and read timeouts.
|
||||
- Auto-reconnect option.
|
||||
|
||||
### VNC capabilities
|
||||
|
||||
- VNC remote desktop sessions.
|
||||
- noVNC-based viewer inside the Nexus workspace.
|
||||
- Password prompt and password storage modes.
|
||||
- View-only mode.
|
||||
- Scale-to-fit display.
|
||||
- Shared-session option.
|
||||
- Quality setting.
|
||||
- SSH tunnel option for VNC traffic.
|
||||
- SSH tunnel authentication by password, public key, or SSH agent.
|
||||
|
||||
### GitHub capabilities
|
||||
|
||||
- GitHub profile type using a personal access token.
|
||||
- GitHub.com and GitHub Enterprise API base URL support.
|
||||
- Repository viewer grouped by owner or organisation.
|
||||
- Repository filtering by name, description, or language.
|
||||
- Repositories sorted by latest commit activity.
|
||||
- Shows repository metadata such as language, stars, private/fork/archived state, latest committer, and latest commit time.
|
||||
- Manual refresh.
|
||||
- Configurable refresh interval.
|
||||
- Open repositories in the browser.
|
||||
- Download repository ZIP archives.
|
||||
|
||||
### Leviathan binary workbench
|
||||
|
||||
- Switch from **Nexus Terminal** to **Leviathan** from the sidebar header.
|
||||
- Load a local file into a working document, inspect it with multiple tools, then save the patched working copy.
|
||||
- Tools open as normal tabs beside connection sessions.
|
||||
- Leviathan tabs close immediately without connection-disconnect confirmation.
|
||||
- Shared undo, redo, and revert actions for byte-level edits.
|
||||
- File-backed document model for large local files, with chunked save/export where supported.
|
||||
- Clear limits for tools that require bounded in-memory buffers, such as image/audio spectrogram conversion and some parsers.
|
||||
|
||||
### Leviathan editor and triage tools
|
||||
|
||||
- **Hex Editor** with editable byte cells, live ASCII preview, 16-byte and 32-byte rows, byte search, string/hex search, previous/next match navigation, and range opening from analysis views.
|
||||
- **Overview** for first-pass summary across hashes, entropy, strings, indicators, and carve candidates.
|
||||
- **Hashes** for CRC32, MD5, SHA-1, SHA-256, and SHA-512.
|
||||
- **Entropy** graph over the whole file, with selectable ranges that can open a new hex editor tab.
|
||||
- **Strings** extraction for ASCII and UTF-16 text.
|
||||
- **IOCs** for URLs, domains, paths, hashes, and other indicators.
|
||||
- **Search** and **Multi-search** for text, hex, and regular-expression matching.
|
||||
- Exportable results from tools that find files, ranges, or structured artefacts.
|
||||
|
||||
### Leviathan structure, recovery, and transform tools
|
||||
|
||||
- **Structures** parser for common binary formats such as PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1, and certificates.
|
||||
- **Disasm** heuristic disassembly for common CPU families, with raw byte fallback for unsupported architectures.
|
||||
- **PDF** object, stream, xref, and suspicious-surface inspection.
|
||||
- **SquashFS** superblock and filesystem metadata inspection with human-readable bad-magic errors.
|
||||
- **Carve**, **Keys**, **Timestamps**, **Stego**, and **Metadata** tools for embedded artefacts and recovery workflows.
|
||||
- **BinSnoop** deep signature scanning across the whole file, with export for discovered files and ranges.
|
||||
- **Rules** for YARA-like rule scans.
|
||||
- **Patch Plan** and **Patch Apply** for validating and applying patch scripts.
|
||||
- **Transforms** for hex, Base64, URL, XOR, inflate, and cipher pipelines.
|
||||
- **Case Export** for bundling useful artefacts from a working investigation.
|
||||
|
||||
### Leviathan spectrogram tools
|
||||
|
||||
- Convert an image into WAV audio so the image appears in a spectrogram.
|
||||
- Insert an encoded image into an existing WAV or MP3 carrier.
|
||||
- Visualise WAV or MP3 audio as a spectrogram image.
|
||||
- Control FFT size, height, maximum width, aspect ratio, frequency range, brightness, and contrast.
|
||||
|
||||
### Automation and reusable workflows
|
||||
|
||||
- Snippet library for saved commands.
|
||||
- Snippet folders, descriptions, and tags.
|
||||
- Send snippets to active sessions.
|
||||
- Snippet variables before sending commands.
|
||||
- Broadcast input mode for running the same command across connected sessions.
|
||||
- Trigger rules that watch terminal output.
|
||||
- Trigger matching by text or regular expression.
|
||||
- Trigger actions for notifications, tab highlighting, sound, or combined notify/highlight behaviour.
|
||||
- Expect-style login scripts for SSH and Telnet.
|
||||
- Startup commands for SSH sessions.
|
||||
|
||||
### Session recording
|
||||
|
||||
- Start and stop terminal recording per session.
|
||||
- Recording indicator on active tabs.
|
||||
- Recordings saved in asciicast v2-compatible format.
|
||||
- Recording browser for listing saved recordings.
|
||||
- Playback controls.
|
||||
- Delete saved recordings.
|
||||
- Recording metadata includes title, dimensions, duration, size, creation time, and file path.
|
||||
|
||||
### Security and credential handling
|
||||
|
||||
- OS keychain first where available, including macOS Keychain, Linux Secret Service, and Windows Credential Manager.
|
||||
- Encrypted vault fallback.
|
||||
- Vault encryption with AES-256-GCM and Argon2id-derived keys.
|
||||
- Secret store lock and unlock workflow.
|
||||
- Per-profile secret modes: Saved, Session only, and Ask every time.
|
||||
- Encrypted storage for TOTP secrets.
|
||||
- Known host verification and known host management.
|
||||
- Audit log for security-relevant operations.
|
||||
- Audit entries for connections, disconnections, profile changes, secret access, imports, and exports.
|
||||
- Secrets are not written into audit entries.
|
||||
- Encrypted profile exports with password-based AES-256-GCM encryption and Argon2id key derivation.
|
||||
- Plain profile export can redact inline secrets.
|
||||
|
||||
### Settings and maintenance
|
||||
|
||||
- Application theme setting.
|
||||
- Terminal theme selection.
|
||||
- Default protocol preference.
|
||||
- Default terminal font, size, scrollback, and cursor settings.
|
||||
- Confirm-on-close and confirm-on-disconnect preferences.
|
||||
- Status bar visibility setting.
|
||||
- Sidebar width preference.
|
||||
- Native paste preference.
|
||||
- Local shell selection.
|
||||
- Secret store settings.
|
||||
- Known hosts management.
|
||||
- Help and troubleshooting content inside settings.
|
||||
- Background and manual update checks.
|
||||
- Update checks report availability, current version, latest version, release notes, release URL, and publish date.
|
||||
|
||||
## Website-ready summary
|
||||
|
||||
### Suggested headline
|
||||
|
||||
Nexus Terminal: one desktop workspace for SSH, files, sockets, remote desktops, repositories, and binary artefacts.
|
||||
|
||||
### Suggested subheadline
|
||||
|
||||
Manage SSH, Telnet, RAW TCP, VNC, GitHub, local shell, and Leviathan binary-analysis sessions from a fast tabbed interface with secure credentials, SFTP file transfer, split panes, snippets, recordings, automation, and artefact-inspection tools built in.
|
||||
|
||||
### Short product description
|
||||
|
||||
Nexus Terminal helps developers, operators, support engineers, reverse engineers, firmware analysts, and homelab users manage many connections and artefacts without juggling separate tools. Save profiles, organise them with groups and tags, connect in tabs or split panes, move files over SFTP, inspect raw TCP traffic, open VNC desktops, browse GitHub repositories, triage binaries with Leviathan, and keep credentials protected with keychain and encrypted-vault storage.
|
||||
|
||||
### Product page feature bullets
|
||||
|
||||
- All-in-one connection and analysis workspace for SSH, Telnet, RAW TCP, VNC, GitHub, local shells, and Leviathan tools.
|
||||
- Fast multi-session interface with tabs, pinned sessions, split panes, reconnect, and broadcast input.
|
||||
- Rich SSH support including agent auth, TOTP, jump hosts, proxies, port forwarding, startup commands, login scripts, and host key policies.
|
||||
- Built-in SFTP browser with upload, download, rename, delete, folders, bookmarks, drag-and-drop upload, and permission-aware directory picking.
|
||||
- RAW TCP tooling with text, hex, split views, payload presets, captures, timestamps, and byte-level sending.
|
||||
- VNC viewer with scale-to-fit, view-only mode, quality controls, shared sessions, and optional SSH tunnelling.
|
||||
- GitHub repository browser with organisation grouping, filtering, activity sorting, metadata, ZIP downloads, and GitHub Enterprise support.
|
||||
- Leviathan binary workbench with hex editing, hashes, entropy, strings, IOCs, carving, BinSnoop, structures, PDF/SquashFS inspection, transforms, rules, patching, case export, and spectrogram image/audio tools.
|
||||
- Secure credential model using OS keychain first, encrypted vault fallback, per-profile secret modes, known hosts, encrypted exports, and audit logging.
|
||||
- Productivity tools including command palette, snippets, trigger rules, session recording, terminal search, scrollback export, and local terminal.
|
||||
|
||||
## Who it is for
|
||||
|
||||
Nexus Terminal suits operators, developers, and support teams who need to connect to many endpoints quickly, keep sessions organised, and avoid re-entering credentials.
|
||||
- Developers who connect to many application servers, staging environments, and production hosts.
|
||||
- Platform and DevOps teams who need organised, secure access across infrastructure.
|
||||
- Support teams who troubleshoot customer systems, network appliances, and legacy Telnet devices.
|
||||
- Network and hardware engineers who need RAW TCP and Telnet tools beside normal SSH workflows.
|
||||
- Homelab users who want SSH, VNC, file transfer, and repo access in one desktop app.
|
||||
- Reverse engineers, firmware analysts, and security researchers who want binary triage beside their live operational sessions.
|
||||
|
||||
## Learn more
|
||||
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
# Nexus Terminal Quick Start
|
||||
|
||||
Nexus Terminal is a desktop client for SSH, Telnet, RAW TCP, and GitHub sessions. This page is a shareable quick start for everyday use.
|
||||
Nexus Terminal is a desktop client for SSH, Telnet, RAW TCP, VNC, GitHub, local terminal sessions, and Leviathan binary analysis. This page is a shareable quick start for everyday use.
|
||||
|
||||
## Get connected in minutes
|
||||
|
||||
@@ -14,11 +14,20 @@ One-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** for quick connect.
|
||||
## Daily workflow
|
||||
|
||||
- **Tabs and splits**: Drag tabs to reorder, and split the workspace to view multiple sessions at once.
|
||||
- **Scrollable tabs**: Use the tab bar scrollbar or mouse wheel/trackpad over the tab strip when many tabs are open. Hover a tab to see its full name and connection start time.
|
||||
- **Broadcast input**: Toggle with **Cmd+Shift+B** (macOS) or **Ctrl+Shift+B** (Windows/Linux) to send the same command to all connected sessions.
|
||||
- **Snippets**: Save common commands and run them with one click.
|
||||
- **Search**: Press **Cmd+F** (macOS) or **Ctrl+F** (Windows/Linux) to search within the active terminal.
|
||||
- **Reconnect quickly**: If a session drops, click **Reconnect** on the disconnected overlay.
|
||||
|
||||
## VNC remote desktops
|
||||
|
||||
Create a **VNC** profile for remote desktop sessions. Use SSH tunnelling when possible, especially outside trusted networks. Direct VNC is available for isolated or trusted networks, but classic VNC traffic may not be encrypted.
|
||||
|
||||
## Leviathan binary tools
|
||||
|
||||
Click **Leviathan** beside **Nexus Terminal** in the sidebar header to switch from connection profiles to binary-analysis tools. Use **Load** to choose a file, then open tools such as Hex Editor, Overview, Hashes, Entropy, Strings, Carve, BinSnoop, Structures, PDF, SquashFS, Transforms, and Spectrogram tools. Tools open in normal tabs and can be closed without a disconnect confirmation.
|
||||
|
||||
## File transfers on SSH
|
||||
|
||||
Click the **folder** icon on an SSH tab to open the SFTP browser. You can browse, upload, download, rename, and delete files.
|
||||
|
||||
+95
-7
@@ -12,9 +12,10 @@
|
||||
8. SFTP file browser
|
||||
9. Drag-and-drop uploads and paste
|
||||
10. Session recordings
|
||||
11. Security and credentials
|
||||
12. Settings
|
||||
13. Troubleshooting
|
||||
11. Leviathan binary workbench
|
||||
12. Security and credentials
|
||||
13. Settings
|
||||
14. Troubleshooting
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +51,7 @@ Tip: You can drag the divider between split panes to rebalance space.
|
||||
### Create a new profile
|
||||
|
||||
1. Click **New** in the sidebar.
|
||||
2. Choose the protocol (SSH, Telnet, RAW, GitHub).
|
||||
2. Choose the protocol (SSH, Telnet, RAW, VNC, or GitHub).
|
||||
3. Fill in the required fields and optional settings.
|
||||
4. Save and connect.
|
||||
|
||||
@@ -86,6 +87,15 @@ Tip: Use the search box to filter profiles by name, host, or tag.
|
||||
- Text, hex, and split views for binary-safe data.
|
||||
- Payload presets and configurable encoding and line endings.
|
||||
|
||||
### VNC
|
||||
|
||||
- Remote desktop viewer embedded in the Nexus workspace.
|
||||
- Password prompt and saved-password modes.
|
||||
- View-only mode, scale-to-fit, shared sessions, and quality controls.
|
||||
- Optional SSH tunnel for safer VNC transport.
|
||||
|
||||
Use SSH tunnelling for VNC whenever possible. Direct VNC is useful on trusted or isolated networks, but many classic VNC deployments do not provide strong transport encryption.
|
||||
|
||||
### GitHub
|
||||
|
||||
- Browse repositories by organisation.
|
||||
@@ -97,6 +107,9 @@ Tip: Use the search box to filter profiles by name, host, or tag.
|
||||
|
||||
- **Reorder** tabs by dragging them.
|
||||
- **Pin** important sessions to keep them at the front.
|
||||
- **Scroll** the tab bar with the scrollbar or mouse wheel/trackpad when many tabs are open.
|
||||
- **Hover** a tab to see the full name and, for connected sessions, the connection start time.
|
||||
- **Close** tabs with the close button on the left side of each tab. Leviathan tool tabs close without a disconnect confirmation.
|
||||
- **Split** the workspace vertically or horizontally to view multiple sessions at once.
|
||||
- **Broadcast** input to multiple connected sessions when needed.
|
||||
|
||||
@@ -190,7 +203,69 @@ Screenshot: Recordings list with playback controls.
|
||||
|
||||
---
|
||||
|
||||
## 11. Security and credentials
|
||||
## 11. Leviathan binary workbench
|
||||
|
||||
Leviathan is the integrated binary-analysis and editing workspace inside Nexus Terminal. It is useful for firmware images, unknown binaries, archives, captures, embedded files, suspicious documents, and audio spectrogram experiments.
|
||||
|
||||
### Switching to Leviathan
|
||||
|
||||
Click **Leviathan** in the top-left sidebar header. The normal connection list changes into a tool list. Click **Nexus Terminal** to return to saved servers and groups.
|
||||
|
||||
Use **Load** to select a local file. The selected file becomes the current Leviathan working document. Use **Save** to write the patched working copy to a chosen destination.
|
||||
|
||||
Leviathan tools open as regular tabs. You can open multiple tool tabs at once, switch between them, and close them without a connection-disconnect confirmation.
|
||||
|
||||
### Hex Editor
|
||||
|
||||
The Hex Editor is the primary byte-level view.
|
||||
|
||||
- Choose 16 or 32 bytes per row.
|
||||
- Edit byte cells directly; typing two hex digits advances to the next byte.
|
||||
- The ASCII preview updates as bytes are edited.
|
||||
- Paste hex runs such as `de ad be ef` or `deadbeef` into consecutive cells.
|
||||
- Use Search to find strings or hex values, then move through matches with previous/next.
|
||||
- Undo, redo, or revert edits from the Leviathan panel header.
|
||||
|
||||
### Triage tools
|
||||
|
||||
- **Overview**: first-pass summary across hashes, entropy, strings, indicators, and carve candidates.
|
||||
- **Hashes**: CRC32, MD5, SHA-1, SHA-256, and SHA-512.
|
||||
- **Entropy**: graph entropy across the whole file; select a region to open that range in a new Hex Editor tab.
|
||||
- **Strings**: ASCII and UTF-16 string extraction.
|
||||
- **IOCs**: URLs, domains, paths, hashes, and other indicators.
|
||||
- **Search** and **Multi-search**: text, hex, and regular-expression matching.
|
||||
|
||||
### Structure and recovery tools
|
||||
|
||||
- **Structures** parses common formats such as PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1, and certificates.
|
||||
- **Disasm** provides heuristic disassembly for common CPU families and raw byte fallback where decoding is not implemented.
|
||||
- **PDF** inspects objects, streams, xrefs, and suspicious surfaces.
|
||||
- **SquashFS** inspects filesystem superblocks and metadata; non-SquashFS input shows a human-readable bad-magic error.
|
||||
- **Carve** and **BinSnoop** scan for embedded files and signatures and can export discovered files or ranges.
|
||||
- **Keys**, **Timestamps**, **Stego**, and **Metadata** help recover common artefacts from binaries and containers.
|
||||
|
||||
### Editing, transforms, and cases
|
||||
|
||||
- **Patch Plan** validates patch scripts without modifying the working copy.
|
||||
- **Patch Apply** applies validated patch scripts.
|
||||
- **Transforms** runs pipelines such as hex, Base64, URL, XOR, inflate, and cipher transforms over selected data.
|
||||
- **Rules** runs YARA-like scans over the current document.
|
||||
- **Case Export** packages investigation artefacts from the working document.
|
||||
|
||||
### Spectrogram tools
|
||||
|
||||
- **Spectrogram Encode** converts an image into WAV audio so the image appears in a spectrogram.
|
||||
- It can also insert an encoded image into an existing WAV or MP3 carrier.
|
||||
- **Spectrogram View** renders WAV or MP3 audio as a spectrogram image.
|
||||
- Controls include FFT size, height, maximum width, aspect ratio, frequency range, brightness, and contrast.
|
||||
|
||||
### Large file behaviour
|
||||
|
||||
Leviathan keeps loaded files file-backed where possible and writes patched working copies in chunks. Some tools still need bounded in-memory buffers because the underlying parser or decoder works on complete byte slices; those tools reject oversized inputs with a clear message rather than trying to allocate unbounded memory.
|
||||
|
||||
---
|
||||
|
||||
## 12. Security and credentials
|
||||
|
||||
Nexus Terminal stores credentials securely:
|
||||
|
||||
@@ -214,7 +289,7 @@ Screenshot: TOTP configuration in the profile editor.
|
||||
|
||||
---
|
||||
|
||||
## 12. Settings
|
||||
## 13. Settings
|
||||
|
||||
Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux) and configure:
|
||||
|
||||
@@ -227,7 +302,7 @@ Screenshot: Settings panel with appearance and security options.
|
||||
|
||||
---
|
||||
|
||||
## 13. Troubleshooting
|
||||
## 14. Troubleshooting
|
||||
|
||||
### Session shows as disconnected
|
||||
|
||||
@@ -239,6 +314,19 @@ Screenshot: Settings panel with appearance and security options.
|
||||
- Check the folder is writable; **READ ONLY** indicates no write permission.
|
||||
- Confirm the SSH session is connected.
|
||||
|
||||
### VNC will not connect
|
||||
|
||||
- Confirm the VNC host and port are correct. The common VNC port is `5900` plus the display number.
|
||||
- If using a password, 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, and SSH host key policy.
|
||||
- If direct VNC is enabled, test from a trusted network first and prefer an SSH tunnel for untrusted networks.
|
||||
|
||||
### Leviathan cannot load or save a file
|
||||
|
||||
- Check that the file still exists and that your user account can read it.
|
||||
- When saving, choose a writable destination and avoid overwriting a file that another process has locked.
|
||||
- Some tools enforce explicit size limits for safety. If a tool rejects an oversized input, try selecting a smaller range or using a tool that works on the file-backed document.
|
||||
|
||||
### Cannot read a folder
|
||||
|
||||
- **NO READ** indicates insufficient permissions.
|
||||
|
||||
+34
-1
@@ -22,6 +22,8 @@
|
||||
<link rel="stylesheet" href="/src/css/forms.css" />
|
||||
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||
<link rel="stylesheet" href="/src/css/github.css" />
|
||||
<link rel="stylesheet" href="/src/css/vnc.css" />
|
||||
<link rel="stylesheet" href="/src/css/leviathan.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app">
|
||||
@@ -46,7 +48,22 @@
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
<span class="sidebar-title">Nexus Terminal</span>
|
||||
<span class="sidebar-product-switch" aria-label="Application mode">
|
||||
<button
|
||||
class="sidebar-title sidebar-mode-toggle is-active"
|
||||
id="mode-nexus"
|
||||
type="button"
|
||||
>
|
||||
Nexus Terminal
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-title sidebar-mode-toggle"
|
||||
id="mode-leviathan"
|
||||
type="button"
|
||||
>
|
||||
Leviathan
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +93,13 @@
|
||||
>
|
||||
<span>+ New</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
id="btn-command-palette"
|
||||
title="Command Palette (⌘K)"
|
||||
>
|
||||
<span>⌘K</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-import"
|
||||
@@ -267,6 +291,7 @@
|
||||
Disconnected
|
||||
</span>
|
||||
<span id="status-protocol"></span>
|
||||
<span id="status-latency"></span>
|
||||
<span id="status-session-count">0 sessions</span>
|
||||
<span class="status-spacer"></span>
|
||||
<button
|
||||
@@ -293,6 +318,9 @@
|
||||
<script src="/src/js/lib/xterm-addon-unicode11.min.js"></script>
|
||||
<script src="/src/js/lib/xterm-addon-web-links.min.js"></script>
|
||||
|
||||
<!-- Wails service bridge; loaded before application code. -->
|
||||
<script src="/src/js/wails-service.js"></script>
|
||||
|
||||
<!-- Application scripts (loaded in dependency order) -->
|
||||
<script src="/src/js/utils.js"></script>
|
||||
<script src="/src/js/state.js"></script>
|
||||
@@ -304,6 +332,8 @@
|
||||
<script src="/src/js/profiles.js"></script>
|
||||
<script src="/src/js/raw.js"></script>
|
||||
<script src="/src/js/github.js"></script>
|
||||
<script src="/src/js/lib/novnc.min.js"></script>
|
||||
<script src="/src/js/vnc.js"></script>
|
||||
<script src="/src/js/settings.js"></script>
|
||||
<script src="/src/js/commands.js"></script>
|
||||
<script src="/src/js/secrets.js"></script>
|
||||
@@ -311,6 +341,9 @@
|
||||
<script src="/src/js/sftp.js"></script>
|
||||
<script src="/src/js/splitpane.js"></script>
|
||||
<script src="/src/js/snippets.js"></script>
|
||||
<script src="/src/js/sshkeys.js"></script>
|
||||
<script src="/src/js/triggers.js"></script>
|
||||
<script src="/src/js/leviathan.js"></script>
|
||||
<script src="/src/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+525
-45
@@ -9,58 +9,538 @@
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"xterm": "5.3.0",
|
||||
"xterm-addon-fit": "0.8.0",
|
||||
"xterm-addon-search": "0.13.0",
|
||||
"xterm-addon-unicode11": "0.6.0",
|
||||
"xterm-addon-web-links": "0.9.0"
|
||||
"@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/xterm": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||
"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-fit": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
"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-search": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz",
|
||||
"integrity": "sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-search instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-unicode11": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-unicode11/-/xterm-addon-unicode11-0.6.0.tgz",
|
||||
"integrity": "sha512-5pkb8YoS/deRtNqQRw8t640mu+Ga8B2MG3RXGQu0bwgcfr8XiXIRI880TWM49ICAHhTmnOLPzIIBIjEnCq7k2A==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-unicode11 instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-web-links": {
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
|
||||
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.",
|
||||
"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",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -5,15 +5,21 @@
|
||||
"description": "Frontend for Nexus Terminal 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",
|
||||
"postinstall": "npm run install:assets"
|
||||
"test": "node scripts/test-regressions.js",
|
||||
"postinstall": "npm run install:assets && npm run bundle:novnc"
|
||||
},
|
||||
"dependencies": {
|
||||
"xterm": "5.3.0",
|
||||
"xterm-addon-fit": "0.8.0",
|
||||
"xterm-addon-search": "0.13.0",
|
||||
"xterm-addon-unicode11": "0.6.0",
|
||||
"xterm-addon-web-links": "0.9.0"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
ce0d75b28075a7688ba4535c94b4784c
|
||||
2f3e27ceb2ed8494c242e0b06d9f4da3
|
||||
@@ -28,6 +28,7 @@ const requiredFiles = [
|
||||
'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',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,14 @@ function copyDirSync(src, dest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -55,6 +64,53 @@ function cleanDir(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
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/.
|
||||
@@ -65,6 +121,10 @@ function copyAssets() {
|
||||
cwd: frontendDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
execSync('node scripts/bundle-novnc.js', {
|
||||
cwd: frontendDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +132,7 @@ function copyAssets() {
|
||||
* 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)
|
||||
*/
|
||||
@@ -94,6 +155,13 @@ function buildDist() {
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -151,6 +219,7 @@ try {
|
||||
}
|
||||
|
||||
buildDist();
|
||||
cleanupDistAssets();
|
||||
|
||||
if (!validate()) {
|
||||
console.error('[build] Validation failed — some required files are missing.');
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -14,27 +14,27 @@ const frontendDir = path.resolve(__dirname, "..");
|
||||
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
|
||||
const assets = [
|
||||
{
|
||||
src: "xterm/css/xterm.css",
|
||||
src: "@xterm/xterm/css/xterm.css",
|
||||
dest: "src/css/lib/xterm.css",
|
||||
},
|
||||
{
|
||||
src: "xterm/lib/xterm.js",
|
||||
src: "@xterm/xterm/lib/xterm.js",
|
||||
dest: "src/js/lib/xterm.min.js",
|
||||
},
|
||||
{
|
||||
src: "xterm-addon-fit/lib/xterm-addon-fit.js",
|
||||
src: "@xterm/addon-fit/lib/addon-fit.js",
|
||||
dest: "src/js/lib/xterm-addon-fit.min.js",
|
||||
},
|
||||
{
|
||||
src: "xterm-addon-search/lib/xterm-addon-search.js",
|
||||
src: "@xterm/addon-search/lib/addon-search.js",
|
||||
dest: "src/js/lib/xterm-addon-search.min.js",
|
||||
},
|
||||
{
|
||||
src: "xterm-addon-unicode11/lib/xterm-addon-unicode11.js",
|
||||
src: "@xterm/addon-unicode11/lib/addon-unicode11.js",
|
||||
dest: "src/js/lib/xterm-addon-unicode11.min.js",
|
||||
},
|
||||
{
|
||||
src: "xterm-addon-web-links/lib/xterm-addon-web-links.js",
|
||||
src: "@xterm/addon-web-links/lib/addon-web-links.js",
|
||||
dest: "src/js/lib/xterm-addon-web-links.min.js",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,438 @@
|
||||
#!/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 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",
|
||||
);
|
||||
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 Nexus Terminal",
|
||||
);
|
||||
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",
|
||||
"!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",
|
||||
"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",
|
||||
);
|
||||
assertContains(
|
||||
"internal/api/leviathan.go",
|
||||
"LeviathanRunTool",
|
||||
"Nexus 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/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",
|
||||
"NEXUS_COMPRESS",
|
||||
"local builds must expose an explicit compression switch",
|
||||
);
|
||||
assertContains(
|
||||
"scripts/build-common.sh",
|
||||
"nexus_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",
|
||||
"-O2 -g0",
|
||||
"production builds must remove cgo debug output by default",
|
||||
);
|
||||
assertContains(
|
||||
"Makefile",
|
||||
"build-compressed",
|
||||
"Makefile must expose an explicit compressed build target",
|
||||
);
|
||||
assertContains(
|
||||
"Makefile",
|
||||
"NEXUS_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");
|
||||
+373
-44
@@ -103,7 +103,7 @@
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
}
|
||||
.btn-danger:active {
|
||||
background: rgba(239, 83, 80, 0.20);
|
||||
background: rgba(239, 83, 80, 0.2);
|
||||
}
|
||||
|
||||
.btn-danger-filled {
|
||||
@@ -116,6 +116,19 @@
|
||||
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;
|
||||
@@ -172,7 +185,6 @@
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
FORM CONTROLS
|
||||
======================================================================= */
|
||||
@@ -191,7 +203,7 @@
|
||||
}
|
||||
|
||||
.form-label-required::after {
|
||||
content: ' *';
|
||||
content: " *";
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
@@ -279,7 +291,7 @@
|
||||
.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.20);
|
||||
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2);
|
||||
}
|
||||
|
||||
/* Inline layout (label beside control) */
|
||||
@@ -325,7 +337,7 @@
|
||||
}
|
||||
|
||||
.form-checkbox input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 5px;
|
||||
@@ -365,7 +377,7 @@
|
||||
}
|
||||
|
||||
.form-toggle input[type="checkbox"]::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
@@ -373,7 +385,9 @@
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
transition: transform var(--transition-normal), background var(--transition-normal);
|
||||
transition:
|
||||
transform var(--transition-normal),
|
||||
background var(--transition-normal);
|
||||
}
|
||||
|
||||
.form-toggle input[type="checkbox"]:checked {
|
||||
@@ -385,7 +399,6 @@
|
||||
background: var(--text-inverse);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
BADGES
|
||||
======================================================================= */
|
||||
@@ -429,23 +442,36 @@
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.badge-status::before {
|
||||
content: '';
|
||||
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; }
|
||||
.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
|
||||
@@ -478,7 +504,6 @@
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
DROPDOWN / CONTEXT MENU
|
||||
======================================================================= */
|
||||
@@ -530,7 +555,9 @@
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
@@ -545,7 +572,7 @@
|
||||
color: var(--error);
|
||||
}
|
||||
.dropdown-item.is-danger:hover {
|
||||
background: rgba(239, 83, 80, 0.10);
|
||||
background: rgba(239, 83, 80, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item.is-disabled {
|
||||
@@ -581,7 +608,6 @@
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
NOTIFICATIONS (toast)
|
||||
======================================================================= */
|
||||
@@ -617,12 +643,24 @@
|
||||
}
|
||||
|
||||
@keyframes notification-in {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
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); }
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
@@ -640,10 +678,18 @@
|
||||
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-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;
|
||||
@@ -673,7 +719,9 @@
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
.notification-close:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -681,11 +729,18 @@
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
|
||||
.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
|
||||
@@ -705,7 +760,9 @@
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
background: none;
|
||||
@@ -743,7 +800,6 @@
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
SEARCH INPUT
|
||||
======================================================================= */
|
||||
@@ -757,7 +813,9 @@
|
||||
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);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
@@ -780,7 +838,7 @@
|
||||
}
|
||||
|
||||
.search-wrapper::before {
|
||||
content: '⌕';
|
||||
content: "⌕";
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
top: 50%;
|
||||
@@ -790,7 +848,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
EMPTY STATE
|
||||
======================================================================= */
|
||||
@@ -832,7 +889,6 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
TOOLTIP
|
||||
======================================================================= */
|
||||
@@ -858,7 +914,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
DIVIDER
|
||||
======================================================================= */
|
||||
@@ -876,7 +931,6 @@
|
||||
margin: 0 var(--space-2);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
LOADING / SPINNER
|
||||
======================================================================= */
|
||||
@@ -897,5 +951,280 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -467,6 +467,16 @@
|
||||
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);
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
.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;
|
||||
}
|
||||
@@ -52,13 +54,51 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.tab-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
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;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -169,6 +209,20 @@
|
||||
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)
|
||||
----------------------------------------------------------------------- */
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
/* Leviathan mode inside the Nexus 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);
|
||||
}
|
||||
}
|
||||
@@ -112,10 +112,6 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
@@ -140,7 +136,7 @@
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -152,6 +148,21 @@
|
||||
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;
|
||||
@@ -207,3 +218,68 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,34 @@
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -66,6 +94,12 @@
|
||||
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;
|
||||
|
||||
@@ -419,3 +419,96 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
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;
|
||||
@@ -19,6 +20,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo svg {
|
||||
@@ -35,6 +38,51 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.sidebar-mode-toggle.is-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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
|
||||
----------------------------------------------------------------------- */
|
||||
@@ -253,6 +301,12 @@
|
||||
.sidebar-item .protocol-indicator.proto-github::before {
|
||||
content: "GH";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-vnc::before {
|
||||
content: "⊞";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-local::before {
|
||||
content: "›_";
|
||||
}
|
||||
|
||||
.sidebar-item .protocol-indicator.proto-ssh {
|
||||
background: rgba(78, 205, 196, 0.18);
|
||||
@@ -270,6 +324,21 @@
|
||||
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-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 {
|
||||
@@ -380,6 +449,52 @@
|
||||
/* -----------------------------------------------------------------------
|
||||
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);
|
||||
|
||||
@@ -34,13 +34,13 @@
|
||||
}
|
||||
|
||||
/* Active tab */
|
||||
.tab.active {
|
||||
background: var(--bg-base);
|
||||
.tab.tab-active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active indicator — accent bar at the bottom */
|
||||
.tab.active::after {
|
||||
.tab.tab-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -65,7 +65,7 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tab.active .tab-icon {
|
||||
.tab.tab-active .tab-icon {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@
|
||||
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-local {
|
||||
background: rgba(139, 195, 74, 0.15);
|
||||
color: var(--protocol-local);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Status dot (connection state)
|
||||
@@ -163,6 +171,49 @@
|
||||
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)
|
||||
----------------------------------------------------------------------- */
|
||||
@@ -189,7 +240,7 @@
|
||||
}
|
||||
|
||||
.tab:hover .tab-sftp,
|
||||
.tab.active .tab-sftp {
|
||||
.tab.tab-active .tab-sftp {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -217,11 +268,15 @@
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
margin-left: 2px;
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab:hover .tab-close,
|
||||
.tab.active .tab-close {
|
||||
.tab.tab-active .tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -405,6 +460,28 @@
|
||||
/* 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)
|
||||
----------------------------------------------------------------------- */
|
||||
|
||||
+15
-10
@@ -57,6 +57,8 @@
|
||||
--protocol-telnet: #ffa726;
|
||||
--protocol-raw: #ab47bc;
|
||||
--protocol-github: #7c8aff;
|
||||
--protocol-vnc: #e85d4a;
|
||||
--protocol-local: #8bc34a;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Spacing scale (4 px base unit)
|
||||
@@ -75,10 +77,11 @@
|
||||
/* -----------------------------------------------------------------------
|
||||
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;
|
||||
--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;
|
||||
@@ -108,10 +111,10 @@
|
||||
/* -----------------------------------------------------------------------
|
||||
Shadows
|
||||
----------------------------------------------------------------------- */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.40);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.50);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.60);
|
||||
--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
|
||||
@@ -136,7 +139,7 @@
|
||||
----------------------------------------------------------------------- */
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-width-collapsed: 48px;
|
||||
--tab-bar-height: 36px;
|
||||
--tab-bar-height: 44px;
|
||||
--toolbar-height: 40px;
|
||||
--status-bar-height: 24px;
|
||||
}
|
||||
@@ -145,7 +148,9 @@
|
||||
Global input overrides — disable macOS/WebKit auto-capitalisation,
|
||||
autocorrect, and spellcheck on all text fields.
|
||||
----------------------------------------------------------------------- */
|
||||
input, textarea, select {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
autocapitalize: none;
|
||||
autocorrect: off;
|
||||
spellcheck: false;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* 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);
|
||||
}
|
||||
+444
-34
@@ -20,6 +20,9 @@
|
||||
ExportImportUI,
|
||||
SFTPBrowser,
|
||||
SnippetLibrary,
|
||||
SSHKeyManager,
|
||||
TriggerEngine,
|
||||
LeviathanWorkbench,
|
||||
stateLabel,
|
||||
} = window.NexusTerminal;
|
||||
|
||||
@@ -40,7 +43,11 @@
|
||||
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);
|
||||
this.updateInfo = null;
|
||||
this.mode = "nexus";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,10 +73,9 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls for `window.go` and `window.runtime` to become available.
|
||||
* Wails injects these into the webview; on fast machines they are
|
||||
* present before DOMContentLoaded, but on slower starts there may
|
||||
* be a brief delay.
|
||||
* 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<void>}
|
||||
* @private
|
||||
*/
|
||||
@@ -78,7 +84,11 @@
|
||||
const maxWait = 5000;
|
||||
const start = Date.now();
|
||||
const check = () => {
|
||||
if (window.go && window.runtime) {
|
||||
const hasBridge =
|
||||
typeof window.NexusTerminal?.hasWailsService === "function"
|
||||
? window.NexusTerminal.hasWailsService()
|
||||
: Boolean(window.NexusTerminal?.wailsService && window.runtime);
|
||||
if (window.NexusTerminal?.wailsService && hasBridge) {
|
||||
resolve();
|
||||
} else if (Date.now() - start < maxWait) {
|
||||
setTimeout(check, 50);
|
||||
@@ -91,7 +101,9 @@
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
check();
|
||||
Promise.resolve(window.NexusTerminal?.wailsServiceReady)
|
||||
.catch(() => null)
|
||||
.finally(check);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +114,7 @@
|
||||
*/
|
||||
async loadProfiles() {
|
||||
try {
|
||||
const profiles = await window.go.api.Service.ListProfiles();
|
||||
const profiles = await window.NexusTerminal.wailsService.ListProfiles();
|
||||
this.state.setProfiles(profiles || []);
|
||||
this.sidebar.render();
|
||||
} catch (err) {
|
||||
@@ -115,7 +127,7 @@
|
||||
*/
|
||||
async loadPreferences() {
|
||||
try {
|
||||
const prefs = await window.go.api.Service.GetPreferences();
|
||||
const prefs = await window.NexusTerminal.wailsService.GetPreferences();
|
||||
this.state.setPreferences(prefs || {});
|
||||
this._applyPreferences();
|
||||
} catch (err) {
|
||||
@@ -130,10 +142,10 @@
|
||||
async checkSecretStore() {
|
||||
try {
|
||||
const initialised =
|
||||
await window.go.api.Service.IsSecretStoreInitialised();
|
||||
await window.NexusTerminal.wailsService.IsSecretStoreInitialised();
|
||||
this.state.setSecretStoreInitialised(!!initialised);
|
||||
if (initialised) {
|
||||
const unlocked = await window.go.api.Service.IsSecretStoreUnlocked();
|
||||
const unlocked = await window.NexusTerminal.wailsService.IsSecretStoreUnlocked();
|
||||
this.state.setSecretStoreState(!!unlocked);
|
||||
} else {
|
||||
this.state.setSecretStoreState(false);
|
||||
@@ -159,6 +171,14 @@
|
||||
// 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)
|
||||
@@ -203,7 +223,7 @@
|
||||
if (entry) {
|
||||
const { cols, rows } = entry.term;
|
||||
try {
|
||||
window.go.api.Service.ResizeSession(
|
||||
window.NexusTerminal.wailsService.ResizeSession(
|
||||
payload.sessionID,
|
||||
cols,
|
||||
rows,
|
||||
@@ -213,15 +233,19 @@
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
@@ -256,6 +280,14 @@
|
||||
);
|
||||
});
|
||||
|
||||
// VNC WebSocket proxy ready — connect the viewer
|
||||
window.runtime.EventsOn("session:vnc:ready", (payload) => {
|
||||
window.NexusTerminal.VNCViewer.connect(
|
||||
payload.sessionID,
|
||||
payload.wsURL,
|
||||
);
|
||||
});
|
||||
|
||||
// App-wide commands from the native menu
|
||||
window.runtime.EventsOn("app:open-about", () => {
|
||||
this.settings.openTab("about");
|
||||
@@ -289,14 +321,12 @@
|
||||
this.recordings._updateRecordingIndicator(payload.sessionID, false);
|
||||
});
|
||||
|
||||
// Telnet negotiation debug messages
|
||||
window.runtime.EventsOn("session:debug", (payload) => {
|
||||
console.debug("[session:debug]", payload);
|
||||
});
|
||||
// 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", () => {
|
||||
console.log("Backend is ready");
|
||||
// Lifecycle signal only.
|
||||
});
|
||||
|
||||
// Host key verification requests
|
||||
@@ -308,17 +338,24 @@
|
||||
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 {
|
||||
@@ -346,7 +383,7 @@
|
||||
*/
|
||||
async closeSessionImmediate(sessionId) {
|
||||
try {
|
||||
await window.go.api.Service.DisconnectSession(sessionId);
|
||||
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
|
||||
} catch (_) {
|
||||
// Ignore failures; we still want to clean up locally.
|
||||
}
|
||||
@@ -354,8 +391,9 @@
|
||||
this.terminals.destroyTerminal(sessionId);
|
||||
this.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
this.state.removeSession(sessionId);
|
||||
window.NexusTerminal.VNCViewer.destroyViewer(sessionId);
|
||||
this.tabs.removeTab(sessionId);
|
||||
this.state.removeSession(sessionId);
|
||||
this.updateStatusBar();
|
||||
}
|
||||
|
||||
@@ -501,12 +539,29 @@
|
||||
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-nexus", () => this.setMode("nexus"));
|
||||
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());
|
||||
|
||||
@@ -542,6 +597,33 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── App mode switching ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Switches the sidebar between Nexus profiles and Leviathan tools.
|
||||
* @param {"nexus"|"leviathan"} mode
|
||||
*/
|
||||
setMode(mode) {
|
||||
this.mode = mode === "leviathan" ? "leviathan" : "nexus";
|
||||
const root = document.getElementById("app");
|
||||
if (root) {
|
||||
root.classList.toggle("mode-leviathan", this.mode === "leviathan");
|
||||
}
|
||||
document
|
||||
.getElementById("mode-nexus")
|
||||
?.classList.toggle("is-active", this.mode === "nexus");
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -565,6 +647,9 @@
|
||||
*/
|
||||
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);
|
||||
@@ -579,13 +664,122 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a modal to select which sessions receive broadcast input.
|
||||
*/
|
||||
selectBroadcastTargets() {
|
||||
const { createElement, escapeHtml } = window.NexusTerminal;
|
||||
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 += `
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;font-size:var(--text-sm);cursor:pointer;">
|
||||
<input type="checkbox" class="broadcast-target-check" data-session-id="${s.id}" ${checked} />
|
||||
<span style="color:var(--text-primary);">${escapeHtml(label)}</span>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Broadcast Targets</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-2);">
|
||||
Select which sessions should receive broadcast input:
|
||||
</p>
|
||||
${listHtml}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm btn-ghost broadcast-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-secondary broadcast-all">Select All</button>
|
||||
<button class="btn btn-sm btn-primary broadcast-apply">Apply & Enable</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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) {
|
||||
async connectProfile(profileId, options = {}) {
|
||||
const profile = this.state.getProfile(profileId);
|
||||
if (!profile) {
|
||||
this.notifications.error("Profile not found");
|
||||
@@ -615,9 +809,17 @@
|
||||
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.go.api.Service.ConnectSession(profileId);
|
||||
await window.NexusTerminal.wailsService.ConnectSession(profileId);
|
||||
this.state.addSession(sessionInfo);
|
||||
this.tabs.addTab(sessionInfo);
|
||||
|
||||
@@ -634,6 +836,15 @@
|
||||
profile.displayName || profile.host,
|
||||
);
|
||||
}
|
||||
} else if (profile.protocol === "vnc") {
|
||||
const container = document.getElementById(panelId);
|
||||
if (container) {
|
||||
window.NexusTerminal.VNCViewer.createViewer(
|
||||
sessionInfo.id,
|
||||
container,
|
||||
profile,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
||||
}
|
||||
@@ -646,6 +857,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a local terminal session.
|
||||
*/
|
||||
async connectLocal() {
|
||||
try {
|
||||
const sessionInfo = await window.NexusTerminal.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.
|
||||
*/
|
||||
@@ -754,7 +989,7 @@
|
||||
displayName: result.host,
|
||||
tags: [],
|
||||
};
|
||||
const sessionInfo = await window.go.api.Service.ConnectQuick(profile);
|
||||
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
|
||||
this.state.addSession(sessionInfo);
|
||||
this.tabs.addTab(sessionInfo);
|
||||
|
||||
@@ -785,7 +1020,9 @@
|
||||
*/
|
||||
_confirmReuseSession(profile, session) {
|
||||
const createElement = window.NexusTerminal.createElement;
|
||||
const label = (profile.displayName || profile.host || "Untitled").trim();
|
||||
const label = window.NexusTerminal.escapeHtml(
|
||||
(profile.displayName || profile.host || "Untitled").trim(),
|
||||
);
|
||||
const stateLabel =
|
||||
session.state === "connected" ? "connected" : "connecting";
|
||||
|
||||
@@ -841,6 +1078,74 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean>}
|
||||
*/
|
||||
confirmDirectVNCProfile(profile, action) {
|
||||
const { createElement, createModalShell } = window.NexusTerminal;
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -865,6 +1170,7 @@
|
||||
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;
|
||||
@@ -884,6 +1190,20 @@
|
||||
'<span class="status-dot is-disconnected"></span> 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
|
||||
@@ -905,13 +1225,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
_startLatencyPing(sessionID) {
|
||||
// Don't restart if already pinging the same session.
|
||||
if (this._latencySessionID === sessionID && this._latencyTimer) return;
|
||||
this._stopLatencyPing();
|
||||
this._latencySessionID = sessionID;
|
||||
|
||||
const doPing = async () => {
|
||||
if (!window.NexusTerminal?.wailsService) return;
|
||||
try {
|
||||
const ms = await window.NexusTerminal.wailsService.PingSession(sessionID);
|
||||
const el = document.getElementById("status-latency");
|
||||
if (el && this._latencySessionID === sessionID) {
|
||||
el.textContent = ms + " ms";
|
||||
el.className =
|
||||
ms < 100
|
||||
? "latency-good"
|
||||
: ms < 250
|
||||
? "latency-warn"
|
||||
: "latency-bad";
|
||||
}
|
||||
} catch (_) {
|
||||
// Session may have disconnected — clear indicator.
|
||||
const el = document.getElementById("status-latency");
|
||||
if (el) {
|
||||
el.textContent = "";
|
||||
el.className = "";
|
||||
}
|
||||
this._stopLatencyPing();
|
||||
}
|
||||
};
|
||||
|
||||
doPing();
|
||||
this._latencyTimer = setInterval(doPing, 10000);
|
||||
}
|
||||
|
||||
_stopLatencyPing() {
|
||||
if (this._latencyTimer) {
|
||||
clearInterval(this._latencyTimer);
|
||||
this._latencyTimer = null;
|
||||
}
|
||||
this._latencySessionID = null;
|
||||
}
|
||||
|
||||
async checkForUpdates(silent) {
|
||||
if (!window.go || !window.go.api || !window.go.api.Service) {
|
||||
if (!window.NexusTerminal?.wailsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await window.go.api.Service.CheckForUpdates();
|
||||
const info = await window.NexusTerminal.wailsService.CheckForUpdates();
|
||||
this.setUpdateInfo(info);
|
||||
if (!silent && info && info.available) {
|
||||
this.notifications.info(
|
||||
@@ -1183,29 +1546,76 @@
|
||||
|
||||
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(),
|
||||
);
|
||||
count.textContent = input.value
|
||||
? found
|
||||
? "Match found"
|
||||
: "No matches"
|
||||
: "";
|
||||
updateCount(found);
|
||||
};
|
||||
const doPrev = () => {
|
||||
matchIndex = Math.max(0, matchIndex - 2);
|
||||
const found = this.terminals.searchPrevious(
|
||||
sessionId,
|
||||
input.value,
|
||||
searchOpts(),
|
||||
);
|
||||
count.textContent = input.value
|
||||
? found
|
||||
? "Match found"
|
||||
: "No matches"
|
||||
: "";
|
||||
updateCount(found);
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
|
||||
+154
-95
@@ -18,7 +18,7 @@ class CommandPalette {
|
||||
this._overlay = null;
|
||||
|
||||
/** @type {string} Current filter query. */
|
||||
this._query = '';
|
||||
this._query = "";
|
||||
|
||||
/** @type {number} Index of the highlighted command. */
|
||||
this._selectedIndex = 0;
|
||||
@@ -42,7 +42,7 @@ class CommandPalette {
|
||||
*/
|
||||
open() {
|
||||
if (this._overlay) return;
|
||||
this._query = '';
|
||||
this._query = "";
|
||||
this._selectedIndex = 0;
|
||||
this._render();
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class CommandPalette {
|
||||
cmds.push({
|
||||
id: `connect-${p.id}`,
|
||||
label: `Connect to ${p.displayName || p.host}`,
|
||||
section: 'Connections',
|
||||
section: "Connections",
|
||||
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
|
||||
action: () => this.app.connectProfile(p.id),
|
||||
});
|
||||
@@ -80,56 +80,64 @@ class CommandPalette {
|
||||
|
||||
// New connections
|
||||
cmds.push({
|
||||
id: 'new-ssh',
|
||||
label: 'New SSH Connection',
|
||||
section: 'Actions',
|
||||
shortcut: '⌘N',
|
||||
action: () => this.app.profileEditor.openNew('ssh'),
|
||||
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'),
|
||||
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'),
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
id: "prev-tab",
|
||||
label: "Previous Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘⇧Tab",
|
||||
action: () => {
|
||||
const prev = this.app.tabs.getPrevTab();
|
||||
if (prev) this.app.tabs.setActive(prev);
|
||||
@@ -138,73 +146,106 @@ class CommandPalette {
|
||||
|
||||
// Settings & utility
|
||||
cmds.push({
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
section: 'Application',
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
section: "Application",
|
||||
action: () => this.app.settings.open(),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'import-profiles',
|
||||
label: 'Import Profiles',
|
||||
section: 'Application',
|
||||
id: "import-profiles",
|
||||
label: "Import Profiles",
|
||||
section: "Application",
|
||||
action: () => this.app.importProfiles(),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'export-profiles',
|
||||
label: 'Export Profiles',
|
||||
section: 'Application',
|
||||
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',
|
||||
id: "lock-secrets",
|
||||
label: "Lock Secret Store",
|
||||
section: "Security",
|
||||
action: async () => {
|
||||
try {
|
||||
await window.go.api.Service.LockSecretStore();
|
||||
await window.NexusTerminal.wailsService.LockSecretStore();
|
||||
this.app.state.setSecretStoreState(false);
|
||||
this.app.notifications.info('Secret store locked');
|
||||
this.app.notifications.info("Secret store locked");
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to lock: ' + err);
|
||||
this.app.notifications.error("Failed to lock: " + err);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
cmds.push({
|
||||
id: 'unlock-secrets',
|
||||
label: 'Unlock Secret Store',
|
||||
section: 'Security',
|
||||
id: "unlock-secrets",
|
||||
label: "Unlock Secret Store",
|
||||
section: "Security",
|
||||
action: async () => {
|
||||
try {
|
||||
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
||||
await window.go.api.Service.UnlockSecretStore(pw);
|
||||
const pw =
|
||||
await this.app.secretPrompt.promptMasterPassword(false);
|
||||
await window.NexusTerminal.wailsService.UnlockSecretStore(pw);
|
||||
this.app.state.setSecretStoreState(true);
|
||||
this.app.notifications.success('Secret store unlocked');
|
||||
this.app.notifications.success("Secret store unlocked");
|
||||
} catch (err) {
|
||||
if (err.message !== 'User cancelled') {
|
||||
this.app.notifications.error('Unlock failed: ' + 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: 'clear-terminal',
|
||||
label: 'Clear Terminal',
|
||||
section: 'Terminal',
|
||||
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
|
||||
id: "split-vertical",
|
||||
label: "Split Pane Right",
|
||||
section: "Terminal",
|
||||
shortcut: "⌘⇧D",
|
||||
action: () => this.app.splitPane("vertical"),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'reset-terminal',
|
||||
label: 'Reset Terminal',
|
||||
section: 'Terminal',
|
||||
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,7 +261,10 @@ class CommandPalette {
|
||||
if (!query) return this.getCommands();
|
||||
return this.getCommands()
|
||||
.map((cmd) => {
|
||||
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
|
||||
const result = fuzzyMatch(
|
||||
cmd.label + " " + (cmd.detail || ""),
|
||||
query,
|
||||
);
|
||||
return { ...cmd, ...result };
|
||||
})
|
||||
.filter((c) => c.match)
|
||||
@@ -233,7 +277,7 @@ class CommandPalette {
|
||||
*/
|
||||
execute(command) {
|
||||
this.close();
|
||||
if (command && typeof command.action === 'function') {
|
||||
if (command && typeof command.action === "function") {
|
||||
command.action();
|
||||
}
|
||||
}
|
||||
@@ -242,21 +286,21 @@ class CommandPalette {
|
||||
|
||||
/** @private */
|
||||
_render() {
|
||||
const overlay = createElement('div', 'palette-overlay');
|
||||
const palette = createElement('div', 'palette');
|
||||
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',
|
||||
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');
|
||||
const list = createElement("div", "palette-list");
|
||||
palette.appendChild(list);
|
||||
|
||||
overlay.appendChild(palette);
|
||||
@@ -264,32 +308,35 @@ class CommandPalette {
|
||||
this._overlay = overlay;
|
||||
|
||||
// Initial render
|
||||
this._renderList(list, this.filter(''));
|
||||
this._renderList(list, this.filter(""));
|
||||
|
||||
// Focus the input
|
||||
input.focus();
|
||||
|
||||
// Event handlers
|
||||
input.addEventListener('input', () => {
|
||||
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');
|
||||
input.addEventListener("keydown", (e) => {
|
||||
const items = list.querySelectorAll(".palette-item");
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
|
||||
this._selectedIndex = Math.min(
|
||||
this._selectedIndex + 1,
|
||||
items.length - 1,
|
||||
);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case 'Enter': {
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const selected = items[this._selectedIndex];
|
||||
if (selected && selected._command) {
|
||||
@@ -297,7 +344,7 @@ class CommandPalette {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
@@ -305,7 +352,7 @@ class CommandPalette {
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
overlay.addEventListener('click', (e) => {
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) this.close();
|
||||
});
|
||||
}
|
||||
@@ -317,11 +364,13 @@ class CommandPalette {
|
||||
* @private
|
||||
*/
|
||||
_renderList(list, commands) {
|
||||
list.innerHTML = '';
|
||||
list.innerHTML = "";
|
||||
let currentSection = null;
|
||||
|
||||
if (commands.length === 0) {
|
||||
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
|
||||
const empty = createElement("div", "palette-empty", {
|
||||
textContent: "No commands found",
|
||||
});
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -332,33 +381,44 @@ class CommandPalette {
|
||||
// Section header
|
||||
if (cmd.section && cmd.section !== currentSection) {
|
||||
currentSection = cmd.section;
|
||||
const sectionEl = createElement('div', 'palette-section', { textContent: 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' : ''}`);
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
const badge = createElement("span", "palette-item-shortcut", {
|
||||
textContent: cmd.shortcut,
|
||||
});
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => this.execute(cmd));
|
||||
item.addEventListener('mouseenter', () => {
|
||||
item.addEventListener("click", () => this.execute(cmd));
|
||||
item.addEventListener("mouseenter", () => {
|
||||
this._selectedIndex = i;
|
||||
this._highlightItem(list.querySelectorAll('.palette-item'));
|
||||
this._highlightItem(list.querySelectorAll(".palette-item"));
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
@@ -372,16 +432,15 @@ class CommandPalette {
|
||||
*/
|
||||
_highlightItem(items) {
|
||||
items.forEach((el, i) => {
|
||||
el.classList.toggle('palette-item-active', i === this._selectedIndex);
|
||||
el.classList.toggle("palette-item-active", i === this._selectedIndex);
|
||||
});
|
||||
const active = items[this._selectedIndex];
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'nearest' });
|
||||
active.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.CommandPalette = CommandPalette;
|
||||
|
||||
})();
|
||||
|
||||
@@ -425,7 +425,7 @@
|
||||
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
||||
|
||||
try {
|
||||
await window.go.api.Service.DownloadGitHubRepoArchive(
|
||||
await window.NexusTerminal.wailsService.DownloadGitHubRepoArchive(
|
||||
this.sessionID,
|
||||
repo.fullName,
|
||||
ref,
|
||||
@@ -441,7 +441,7 @@
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.textContent = "Refreshing…";
|
||||
try {
|
||||
await window.go.api.Service.RefreshGitHubRepos(this.sessionID);
|
||||
await window.NexusTerminal.wailsService.RefreshGitHubRepos(this.sessionID);
|
||||
} catch (err) {
|
||||
this.showError(err.message || "Refresh failed");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,2 +1,2 @@
|
||||
!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()}(self,(()=>(()=>{"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,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
!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
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,i={}){this._terminal=e,this._regex=t,this._handler=n,this._options=i}provideLinks(e,t){const i=n.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:i}=e;this._options.hover(t,n,i)}},e)))}};class n{static computeLink(e,t,i,r){const o=new RegExp(t.source,(t.flags||"")+"g"),[s,a]=n._getWindowedLineStrings(e-1,i),c=s.join("");let d;const l=[];for(;d=o.exec(c);){const e=d[0];try{const t=new URL(e),n=decodeURI(t.toString());if(e!==n&&e+"/"!==n)continue}catch(e){continue}const[t,o]=n._mapStrIdx(i,a,0,d.index),[s,c]=n._mapStrIdx(i,t,o,e.length);if(-1===t||-1===o||-1===s||-1===c)continue;const p={start:{x:o+1,y:t+1},end:{x:c,y:s+1}};l.push({range:p,text:e,activate:r})}return l}static _getWindowedLineStrings(e,t){let n,i=e,r=e,o=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(o=0;(n=t.buffer.active.getLine(--i))&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),o=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,i]}static _mapStrIdx(e,t,n,i){const r=e.buffer.active,o=r.getNullCell();let s=n;for(;i;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,o);const s=o.getChars();if(o.getWidth()&&(i-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,o),2===o.getWidth()&&(i+=1))}if(i<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=n}},t={};function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,n),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function o(e,t){const n=window.open();if(n){try{n.opener=null}catch(e){}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=o,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,i=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,i,this._handler,n))}dispose(){var e;null===(e=this._linkProvider)||void 0===e||e.dispose()}}})(),i})()));
|
||||
//# sourceMappingURL=xterm-addon-web-links.js.map
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"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<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{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
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+215
-22
@@ -65,6 +65,27 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class ProfileEditor {
|
||||
@@ -141,7 +162,7 @@
|
||||
drawer.appendChild(header);
|
||||
|
||||
// ── Protocol selector tabs ──────────────────────────────
|
||||
const protocols = ["ssh", "telnet", "raw", "github"];
|
||||
const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
|
||||
const tabBar = createElement("div", "drawer-protocol-tabs");
|
||||
for (const proto of protocols) {
|
||||
const label = proto === "github" ? "GitHub" : proto.toUpperCase();
|
||||
@@ -184,10 +205,17 @@
|
||||
body.appendChild(s),
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
|
||||
break;
|
||||
}
|
||||
|
||||
// Terminal section (SSH and Telnet only)
|
||||
if (profile.protocol !== "raw" && profile.protocol !== "github") {
|
||||
if (
|
||||
profile.protocol !== "raw" &&
|
||||
profile.protocol !== "github" &&
|
||||
profile.protocol !== "vnc"
|
||||
) {
|
||||
body.appendChild(this._renderTerminalSection(profile));
|
||||
}
|
||||
|
||||
@@ -409,10 +437,10 @@
|
||||
|
||||
_loadSSHKeyOptions() {
|
||||
if (this._sshKeyOptionsLoading || this._sshKeyOptionsLoaded) return;
|
||||
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
||||
if (!window.NexusTerminal?.wailsService) return;
|
||||
|
||||
this._sshKeyOptionsLoading = true;
|
||||
window.go.api.Service.ListSSHKeys()
|
||||
window.NexusTerminal.wailsService.ListSSHKeys()
|
||||
.then((keys) => {
|
||||
this._sshKeyOptions = Array.isArray(keys) ? keys : [];
|
||||
this._sshKeyOptionsLoaded = true;
|
||||
@@ -922,6 +950,131 @@
|
||||
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 */
|
||||
_renderTerminalSection(profile) {
|
||||
return this._renderSection("Terminal", [
|
||||
@@ -1062,7 +1215,7 @@
|
||||
const profileId = profile.id;
|
||||
if (profileId) {
|
||||
try {
|
||||
const code = await window.go.api.Service.GetTOTPCode(profileId);
|
||||
const code = await window.NexusTerminal.wailsService.GetTOTPCode(profileId);
|
||||
testBtn.textContent = code;
|
||||
setTimeout(() => {
|
||||
testBtn.textContent = "Test";
|
||||
@@ -1101,7 +1254,7 @@
|
||||
if (!secret) {
|
||||
// Delete existing secret
|
||||
try {
|
||||
await window.go.api.Service.DeleteTOTPSecret(profileId);
|
||||
await window.NexusTerminal.wailsService.DeleteTOTPSecret(profileId);
|
||||
NexusTerminal.Notifications.show("TOTP secret removed.", "info");
|
||||
} catch (err) {
|
||||
NexusTerminal.Notifications.show(
|
||||
@@ -1112,7 +1265,7 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.go.api.Service.SetTOTPSecret(profileId, secret);
|
||||
await window.NexusTerminal.wailsService.SetTOTPSecret(profileId, secret);
|
||||
NexusTerminal.Notifications.show(
|
||||
"TOTP secret saved securely.",
|
||||
"success",
|
||||
@@ -1142,7 +1295,7 @@
|
||||
const profileId = profile.id;
|
||||
if (!profileId) return;
|
||||
try {
|
||||
await window.go.api.Service.DeleteTOTPSecret(profileId);
|
||||
await window.NexusTerminal.wailsService.DeleteTOTPSecret(profileId);
|
||||
NexusTerminal.Notifications.show("TOTP secret removed.", "info");
|
||||
secretInput.placeholder =
|
||||
"Base32-encoded secret (e.g. from authenticator app setup)";
|
||||
@@ -1166,7 +1319,7 @@
|
||||
if (profile.id) {
|
||||
(async () => {
|
||||
try {
|
||||
const has = await window.go.api.Service.HasTOTPSecret(profile.id);
|
||||
const has = await window.NexusTerminal.wailsService.HasTOTPSecret(profile.id);
|
||||
if (has) {
|
||||
secretInput.placeholder = "••••••••• (secret saved)";
|
||||
}
|
||||
@@ -1610,8 +1763,8 @@
|
||||
});
|
||||
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
browseBtn.addEventListener("click", () => {
|
||||
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
||||
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
|
||||
if (!window.NexusTerminal?.wailsService) return;
|
||||
window.NexusTerminal.wailsService.SelectFile("Select SSH Private Key", "*")
|
||||
.then((path) => {
|
||||
if (path) input.value = path;
|
||||
})
|
||||
@@ -1678,8 +1831,8 @@
|
||||
|
||||
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
browseBtn.addEventListener("click", () => {
|
||||
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
||||
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
|
||||
if (!window.NexusTerminal?.wailsService) return;
|
||||
window.NexusTerminal.wailsService.SelectFile("Select SSH Private Key", "*")
|
||||
.then((path) => {
|
||||
if (path) keyInput.value = path;
|
||||
this._validateKeyPath(keyInput, error);
|
||||
@@ -1755,10 +1908,10 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!window.go || !window.go.api || !window.go.api.Service) {
|
||||
if (!window.NexusTerminal?.wailsService) {
|
||||
return;
|
||||
}
|
||||
window.go.api.Service.FileExists(value)
|
||||
window.NexusTerminal.wailsService.FileExists(value)
|
||||
.then((exists) => {
|
||||
if (!exists) {
|
||||
errorEl.textContent = "Key file not found.";
|
||||
@@ -1769,7 +1922,7 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
return window.go.api.Service.FileIsPrivateKey(value).then((isKey) => {
|
||||
return window.NexusTerminal.wailsService.FileIsPrivateKey(value).then((isKey) => {
|
||||
if (!isKey) {
|
||||
errorEl.textContent = "File is not a recognised private key.";
|
||||
errorEl.style.display = "block";
|
||||
@@ -2102,6 +2255,26 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
@@ -2114,7 +2287,14 @@
|
||||
*/
|
||||
_validate(profile) {
|
||||
const errors = [];
|
||||
if (!profile.host || !profile.host.trim()) {
|
||||
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) {
|
||||
@@ -2134,14 +2314,25 @@
|
||||
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.go.api.Service.CreateProfile(this._profile);
|
||||
saved = await window.NexusTerminal.wailsService.CreateProfile(this._profile);
|
||||
this.app.state.addProfile(saved);
|
||||
this.app.notifications.success("Profile created");
|
||||
} else {
|
||||
saved = await window.go.api.Service.UpdateProfile(this._profile);
|
||||
saved = await window.NexusTerminal.wailsService.UpdateProfile(this._profile);
|
||||
this.app.state.updateProfile(saved);
|
||||
this.app.notifications.success("Profile updated");
|
||||
}
|
||||
@@ -2149,7 +2340,7 @@
|
||||
this.close();
|
||||
|
||||
if (andConnect && saved && saved.id) {
|
||||
this.app.connectProfile(saved.id);
|
||||
this.app.connectProfile(saved.id, { skipDirectVNCWarning: true });
|
||||
}
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Save failed: " + err);
|
||||
@@ -2162,7 +2353,7 @@
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await window.go.api.Service.DeleteProfile(this._profile.id);
|
||||
await window.NexusTerminal.wailsService.DeleteProfile(this._profile.id);
|
||||
this.app.state.removeProfile(this._profile.id);
|
||||
this.app.sidebar.render();
|
||||
this.app.notifications.success("Profile deleted");
|
||||
@@ -2186,7 +2377,7 @@
|
||||
this.app.notifications.info(`Testing connection to ${host}:${port}…`);
|
||||
|
||||
try {
|
||||
const result = await window.go.api.Service.TestConnection(host, port);
|
||||
const result = await window.NexusTerminal.wailsService.TestConnection(host, port);
|
||||
this.app.notifications.success(
|
||||
`Reachable — ${result.latencyMs}ms latency`,
|
||||
);
|
||||
@@ -2283,6 +2474,8 @@
|
||||
ssh: null,
|
||||
telnet: null,
|
||||
raw: null,
|
||||
github: null,
|
||||
vnc: null,
|
||||
};
|
||||
|
||||
const defaults = DEFAULTS[protocol] || {};
|
||||
|
||||
+155
-100
@@ -5,7 +5,8 @@
|
||||
* along with a send bar, pause/resume, and capture saving.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, formatTimestamp, formatBytes } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, formatTimestamp, formatBytes } =
|
||||
window.NexusTerminal;
|
||||
|
||||
/**
|
||||
* Maximum number of data entries retained in the scrollback buffer.
|
||||
@@ -40,52 +41,66 @@ class RawViewer {
|
||||
if (!container) return;
|
||||
|
||||
const state = {
|
||||
mode: 'text', // text | hex | split
|
||||
mode: "text", // text | hex | split
|
||||
paused: false,
|
||||
buffer: [],
|
||||
totalBytes: 0,
|
||||
sendFormat: 'text', // text | hex | escaped
|
||||
sendFormat: "text", // text | hex | escaped
|
||||
};
|
||||
|
||||
// ── Toolbar ──────────────────────────────────────────────
|
||||
const toolbar = createElement('div', 'raw-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' : ''}`, {
|
||||
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));
|
||||
},
|
||||
);
|
||||
btn.addEventListener("click", () => this._setMode(sessionId, mode));
|
||||
modeGroup.appendChild(btn);
|
||||
}
|
||||
toolbar.appendChild(modeGroup);
|
||||
|
||||
// Controls
|
||||
const controls = createElement('div', 'raw-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));
|
||||
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));
|
||||
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));
|
||||
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' });
|
||||
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');
|
||||
const display = createElement("div", "raw-display");
|
||||
container.appendChild(display);
|
||||
|
||||
// ── Send bar ─────────────────────────────────────────────
|
||||
@@ -116,7 +131,7 @@ class RawViewer {
|
||||
try {
|
||||
rawStr = atob(payload.data);
|
||||
} catch (_) {
|
||||
rawStr = payload.data || '';
|
||||
rawStr = payload.data || "";
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(rawStr.length);
|
||||
@@ -125,7 +140,7 @@ class RawViewer {
|
||||
}
|
||||
|
||||
const entry = {
|
||||
direction: payload.direction || 'recv', // 'send' | 'recv'
|
||||
direction: payload.direction || "recv", // 'send' | 'recv'
|
||||
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
|
||||
data: rawStr,
|
||||
bytes,
|
||||
@@ -158,8 +173,8 @@ class RawViewer {
|
||||
if (!viewer) return;
|
||||
|
||||
viewer.paused = !viewer.paused;
|
||||
viewer.pauseBtn.textContent = viewer.paused ? '▶ Resume' : '⏸ Pause';
|
||||
viewer.pauseBtn.classList.toggle('active', 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) {
|
||||
@@ -177,8 +192,8 @@ class RawViewer {
|
||||
|
||||
viewer.buffer = [];
|
||||
viewer.totalBytes = 0;
|
||||
viewer.display.innerHTML = '';
|
||||
viewer.counter.textContent = '0 B';
|
||||
viewer.display.innerHTML = "";
|
||||
viewer.counter.textContent = "0 B";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,21 +203,21 @@ class RawViewer {
|
||||
async saveCapture(sessionId) {
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer || viewer.buffer.length === 0) {
|
||||
this.app.notifications.warning('Nothing to save');
|
||||
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';
|
||||
const dir = e.direction === "send" ? "TX" : "RX";
|
||||
return `[${ts}] [${dir}] ${e.data}`;
|
||||
});
|
||||
const content = lines.join('\n');
|
||||
await window.go.api.Service.SaveSessionCapture(sessionId, content);
|
||||
this.app.notifications.success('Capture saved');
|
||||
const content = lines.join("\n");
|
||||
await window.NexusTerminal.wailsService.SaveSessionCapture(sessionId, content);
|
||||
this.app.notifications.success("Capture saved");
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Save failed: ' + err);
|
||||
this.app.notifications.error("Save failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,21 +229,21 @@ class RawViewer {
|
||||
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');
|
||||
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';
|
||||
const format = formatSelect?.value || "text";
|
||||
let dataToSend;
|
||||
|
||||
switch (format) {
|
||||
case 'hex':
|
||||
case "hex":
|
||||
dataToSend = this._hexToString(rawValue);
|
||||
break;
|
||||
case 'escaped':
|
||||
case "escaped":
|
||||
dataToSend = this._unescapeString(rawValue);
|
||||
break;
|
||||
default:
|
||||
@@ -236,19 +251,24 @@ class RawViewer {
|
||||
}
|
||||
|
||||
try {
|
||||
const b64 = btoa(dataToSend);
|
||||
await window.go.api.Service.WriteSession(sessionId, b64);
|
||||
input.value = '';
|
||||
// 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.NexusTerminal.utf8ToBase64(dataToSend);
|
||||
await window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||
input.value = "";
|
||||
|
||||
// Also show in viewer as sent data
|
||||
this.handleData({
|
||||
sessionID: sessionId,
|
||||
data: b64,
|
||||
direction: 'send',
|
||||
direction: "send",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Send failed: ' + err);
|
||||
this.app.notifications.error("Send failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,38 +290,48 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderSendBar(sessionId, profile) {
|
||||
const bar = createElement('div', 'raw-send-bar');
|
||||
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 });
|
||||
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…',
|
||||
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);
|
||||
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 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' },
|
||||
{ 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', () => {
|
||||
const btn = createElement("button", "btn btn-xs btn-outline", {
|
||||
textContent: cb.label,
|
||||
title: cb.value,
|
||||
});
|
||||
btn.addEventListener("click", () => {
|
||||
input.value += cb.value;
|
||||
input.focus();
|
||||
});
|
||||
@@ -312,22 +342,31 @@ class RawViewer {
|
||||
// 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…' });
|
||||
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 });
|
||||
const opt = createElement("option", "", {
|
||||
value: ps.payload,
|
||||
textContent: ps.name,
|
||||
});
|
||||
opt.dataset.format = ps.format;
|
||||
presetSelect.appendChild(opt);
|
||||
}
|
||||
presetSelect.addEventListener('change', () => {
|
||||
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 = '';
|
||||
presetSelect.value = "";
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
@@ -335,8 +374,10 @@ class RawViewer {
|
||||
}
|
||||
|
||||
// Send button
|
||||
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send' });
|
||||
sendBtn.addEventListener('click', () => this.send(sessionId));
|
||||
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
|
||||
textContent: "Send",
|
||||
});
|
||||
sendBtn.addEventListener("click", () => this.send(sessionId));
|
||||
bar.appendChild(sendBtn);
|
||||
|
||||
return bar;
|
||||
@@ -351,10 +392,10 @@ class RawViewer {
|
||||
_renderEntry(viewer, entry) {
|
||||
let el;
|
||||
switch (viewer.mode) {
|
||||
case 'hex':
|
||||
case "hex":
|
||||
el = this._renderHexView(entry);
|
||||
break;
|
||||
case 'split':
|
||||
case "split":
|
||||
el = this._renderSplitView(entry, viewer.profile);
|
||||
break;
|
||||
default:
|
||||
@@ -369,7 +410,7 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderAllEntries(viewer) {
|
||||
viewer.display.innerHTML = '';
|
||||
viewer.display.innerHTML = "";
|
||||
for (const entry of viewer.buffer) {
|
||||
this._renderEntry(viewer, entry);
|
||||
}
|
||||
@@ -384,18 +425,21 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderTextView(entry, profile) {
|
||||
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
|
||||
const row = createElement(
|
||||
"div",
|
||||
`raw-entry raw-entry-${entry.direction}`,
|
||||
);
|
||||
const raw = profile?.raw || {};
|
||||
|
||||
let prefix = '';
|
||||
let prefix = "";
|
||||
if (raw.showTimestamps) {
|
||||
prefix += `[${formatTimestamp(entry.timestamp)}] `;
|
||||
}
|
||||
if (raw.showDirection) {
|
||||
prefix += entry.direction === 'send' ? '[TX] ' : '[RX] ';
|
||||
prefix += entry.direction === "send" ? "[TX] " : "[RX] ";
|
||||
}
|
||||
|
||||
const pre = createElement('pre', 'raw-text');
|
||||
const pre = createElement("pre", "raw-text");
|
||||
pre.textContent = prefix + entry.data;
|
||||
row.appendChild(pre);
|
||||
return row;
|
||||
@@ -408,8 +452,11 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderHexView(entry) {
|
||||
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
|
||||
const pre = createElement('pre', 'raw-hex');
|
||||
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;
|
||||
@@ -423,25 +470,29 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderSplitView(entry, profile) {
|
||||
const row = createElement('div', `raw-entry raw-entry-split raw-entry-${entry.direction}`);
|
||||
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';
|
||||
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 panes = createElement("div", "raw-split-panes");
|
||||
|
||||
const textPane = createElement('pre', 'raw-text raw-split-text');
|
||||
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');
|
||||
const hexPane = createElement("pre", "raw-hex raw-split-hex");
|
||||
hexPane.textContent = this.formatHexDump(entry.bytes);
|
||||
panes.appendChild(hexPane);
|
||||
|
||||
@@ -463,32 +514,32 @@ class RawViewer {
|
||||
const chunk = bytes.slice(offset, offset + bytesPerLine);
|
||||
|
||||
// Offset column
|
||||
const offsetStr = offset.toString(16).padStart(8, '0');
|
||||
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'));
|
||||
hexParts.push(chunk[i].toString(16).padStart(2, "0"));
|
||||
} else {
|
||||
hexParts.push(' ');
|
||||
hexParts.push(" ");
|
||||
}
|
||||
// Extra space after 8th byte for readability
|
||||
if (i === 7) hexParts.push('');
|
||||
if (i === 7) hexParts.push("");
|
||||
}
|
||||
const hexStr = hexParts.join(' ');
|
||||
const hexStr = hexParts.join(" ");
|
||||
|
||||
// ASCII column
|
||||
let ascii = '';
|
||||
let ascii = "";
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const b = chunk[i];
|
||||
ascii += (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.';
|
||||
ascii += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : ".";
|
||||
}
|
||||
|
||||
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────
|
||||
@@ -506,8 +557,8 @@ class RawViewer {
|
||||
viewer.mode = mode;
|
||||
|
||||
// Update active button
|
||||
viewer.toolbar.querySelectorAll('.raw-mode-btn').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
viewer.toolbar.querySelectorAll(".raw-mode-btn").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.mode === mode);
|
||||
});
|
||||
|
||||
this._renderAllEntries(viewer);
|
||||
@@ -531,8 +582,8 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_hexToString(hex) {
|
||||
const cleaned = hex.replace(/\s+/g, '');
|
||||
let str = '';
|
||||
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));
|
||||
}
|
||||
@@ -548,14 +599,19 @@ class RawViewer {
|
||||
_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 '\\';
|
||||
case "n":
|
||||
return "\n";
|
||||
case "r":
|
||||
return "\r";
|
||||
case "t":
|
||||
return "\t";
|
||||
case "0":
|
||||
return "\0";
|
||||
case "\\":
|
||||
return "\\";
|
||||
default:
|
||||
// \\xHH
|
||||
if (seq.startsWith('x')) {
|
||||
if (seq.startsWith("x")) {
|
||||
return String.fromCharCode(parseInt(seq.slice(1), 16));
|
||||
}
|
||||
return match;
|
||||
@@ -566,5 +622,4 @@ class RawViewer {
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.RawViewer = RawViewer;
|
||||
|
||||
})();
|
||||
|
||||
@@ -41,7 +41,7 @@ class RecordingManager {
|
||||
const cols = entry ? entry.term.cols : 80;
|
||||
const rows = entry ? entry.term.rows : 24;
|
||||
|
||||
await window.go.api.Service.StartRecording(sessionId, cols, rows);
|
||||
await window.NexusTerminal.wailsService.StartRecording(sessionId, cols, rows);
|
||||
this.activeSessions.add(sessionId);
|
||||
this._updateRecordingIndicator(sessionId, true);
|
||||
this.app.notifications.success('Recording started');
|
||||
@@ -56,7 +56,7 @@ class RecordingManager {
|
||||
*/
|
||||
async stop(sessionId) {
|
||||
try {
|
||||
await window.go.api.Service.StopRecording(sessionId);
|
||||
await window.NexusTerminal.wailsService.StopRecording(sessionId);
|
||||
this.activeSessions.delete(sessionId);
|
||||
this._updateRecordingIndicator(sessionId, false);
|
||||
this.app.notifications.success('Recording saved');
|
||||
@@ -70,8 +70,11 @@ class RecordingManager {
|
||||
*/
|
||||
async openBrowser() {
|
||||
try {
|
||||
const recordings = await window.go.api.Service.ListRecordings();
|
||||
this._showBrowserModal(recordings || []);
|
||||
const [recordings, storageInfo] = await Promise.all([
|
||||
window.NexusTerminal.wailsService.ListRecordings(),
|
||||
window.NexusTerminal.wailsService.GetRecordingStorageInfo(),
|
||||
]);
|
||||
this._showBrowserModal(recordings || [], storageInfo || {});
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to list recordings: ' + err);
|
||||
}
|
||||
@@ -83,7 +86,7 @@ class RecordingManager {
|
||||
*/
|
||||
async playback(id) {
|
||||
try {
|
||||
const data = await window.go.api.Service.GetRecording(id);
|
||||
const data = await window.NexusTerminal.wailsService.GetRecording(id);
|
||||
this._showPlaybackModal(data);
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to load recording: ' + err);
|
||||
@@ -96,7 +99,7 @@ class RecordingManager {
|
||||
*/
|
||||
async deleteRecording(id) {
|
||||
try {
|
||||
await window.go.api.Service.DeleteRecording(id);
|
||||
await window.NexusTerminal.wailsService.DeleteRecording(id);
|
||||
this.app.notifications.success('Recording deleted');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to delete recording: ' + err);
|
||||
@@ -133,9 +136,10 @@ class RecordingManager {
|
||||
/**
|
||||
* Renders a modal listing all saved recordings.
|
||||
* @param {Array} recordings
|
||||
* @param {Object} storageInfo
|
||||
* @private
|
||||
*/
|
||||
_showBrowserModal(recordings) {
|
||||
_showBrowserModal(recordings, storageInfo = {}) {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-lg');
|
||||
|
||||
@@ -164,12 +168,36 @@ class RecordingManager {
|
||||
listHTML += '</div>';
|
||||
}
|
||||
|
||||
const totalSize = _formatBytes(storageInfo.totalBytes || 0);
|
||||
const count = storageInfo.count ?? recordings.length;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Session Recordings</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
|
||||
<div class="recording-storage">
|
||||
<div class="recording-storage-summary">
|
||||
<span>${count} recording${count === 1 ? '' : 's'}</span>
|
||||
<span>${totalSize}</span>
|
||||
</div>
|
||||
<div class="recording-cleanup-controls">
|
||||
<select class="form-input recording-cleanup-age">
|
||||
<option value="30">Older than 30 days</option>
|
||||
<option value="7">Older than 7 days</option>
|
||||
<option value="90">Older than 90 days</option>
|
||||
<option value="0">Ignore age</option>
|
||||
</select>
|
||||
<select class="form-input recording-cleanup-size">
|
||||
<option value="1073741824">Keep under 1 GB</option>
|
||||
<option value="5368709120">Keep under 5 GB</option>
|
||||
<option value="0">Ignore size</option>
|
||||
</select>
|
||||
<button class="btn btn-sm recording-cleanup">Clean up</button>
|
||||
</div>
|
||||
<div class="form-help recording-cleanup-status"></div>
|
||||
</div>
|
||||
${listHTML}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -189,6 +217,26 @@ class RecordingManager {
|
||||
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.NexusTerminal.wailsService.CleanupRecordings(ageDays, maxBytes);
|
||||
status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`;
|
||||
const fresh = await window.NexusTerminal.wailsService.ListRecordings();
|
||||
close();
|
||||
this._showBrowserModal(fresh || [], await window.NexusTerminal.wailsService.GetRecordingStorageInfo());
|
||||
} catch (err) {
|
||||
status.textContent = 'Cleanup failed: ' + err;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Play buttons
|
||||
modal.querySelectorAll('.rec-play').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -508,7 +556,7 @@ class ExportImportUI {
|
||||
}
|
||||
|
||||
try {
|
||||
const path = await window.go.api.Service.ExportProfilesEncrypted(selectedIds, password);
|
||||
const path = await window.NexusTerminal.wailsService.ExportProfilesEncrypted(selectedIds, password);
|
||||
this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`);
|
||||
close();
|
||||
} catch (err) {
|
||||
@@ -545,6 +593,7 @@ class ExportImportUI {
|
||||
<option value="duplicate">Create duplicates</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-error import-status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm import-cancel">Cancel</button>
|
||||
@@ -567,14 +616,19 @@ class ExportImportUI {
|
||||
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) {
|
||||
this.app.notifications.error('Please enter the decryption password.');
|
||||
status.textContent = 'Please enter the decryption password.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy);
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Importing...';
|
||||
const count = await window.NexusTerminal.wailsService.ImportProfilesEncrypted(password, strategy);
|
||||
this.app.notifications.success(`Imported ${count} profile(s)`);
|
||||
await this.app.loadProfiles();
|
||||
close();
|
||||
@@ -582,10 +636,19 @@ class ExportImportUI {
|
||||
if (String(err).includes('cancelled')) {
|
||||
return;
|
||||
}
|
||||
this.app.notifications.error('Import failed: ' + err);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -280,7 +280,11 @@ class SecretPrompt {
|
||||
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();
|
||||
|
||||
+145
-18
@@ -48,11 +48,17 @@
|
||||
this._activeTab = tabKey || this._activeTab || "general";
|
||||
|
||||
try {
|
||||
this._knownHosts = (await window.go.api.Service.ListKnownHosts()) || [];
|
||||
this._knownHosts = (await window.NexusTerminal.wailsService.ListKnownHosts()) || [];
|
||||
} catch (_) {
|
||||
this._knownHosts = [];
|
||||
}
|
||||
|
||||
try {
|
||||
this._defaultShell = await window.NexusTerminal.wailsService.GetDefaultShell();
|
||||
} catch (_) {
|
||||
this._defaultShell = "";
|
||||
}
|
||||
|
||||
this._render();
|
||||
}
|
||||
|
||||
@@ -82,7 +88,7 @@
|
||||
this._collectValues();
|
||||
|
||||
try {
|
||||
await window.go.api.Service.SavePreferences(this._prefs);
|
||||
await window.NexusTerminal.wailsService.SavePreferences(this._prefs);
|
||||
this.app.state.setPreferences(this._prefs);
|
||||
|
||||
// Apply terminal colour theme change immediately.
|
||||
@@ -281,6 +287,45 @@
|
||||
{ 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.NexusTerminal.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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,7 +429,7 @@
|
||||
initBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const pw = await this.app.secretPrompt.promptMasterPassword(true);
|
||||
await window.go.api.Service.InitialiseSecretStore(pw);
|
||||
await window.NexusTerminal.wailsService.InitialiseSecretStore(pw);
|
||||
this.app.state.setSecretStoreInitialised(true);
|
||||
this.app.state.setSecretStoreState(true);
|
||||
this.app.notifications.success("Secret store created");
|
||||
@@ -403,7 +448,7 @@
|
||||
unlockBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
||||
await window.go.api.Service.UnlockSecretStore(pw);
|
||||
await window.NexusTerminal.wailsService.UnlockSecretStore(pw);
|
||||
this.app.state.setSecretStoreState(true);
|
||||
this.app.notifications.success("Secret store unlocked");
|
||||
this._renderTabContent(body);
|
||||
@@ -420,7 +465,7 @@
|
||||
});
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.go.api.Service.LockSecretStore();
|
||||
await window.NexusTerminal.wailsService.LockSecretStore();
|
||||
this.app.state.setSecretStoreState(false);
|
||||
this.app.notifications.info("Secret store locked");
|
||||
this._renderTabContent(body);
|
||||
@@ -477,7 +522,7 @@
|
||||
});
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.go.api.Service.DeleteKnownHost(kh.id);
|
||||
await window.NexusTerminal.wailsService.DeleteKnownHost(kh.id);
|
||||
this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
|
||||
this._renderTabContent(body);
|
||||
this.app.notifications.success("Known host removed");
|
||||
@@ -504,15 +549,17 @@
|
||||
id: "getting-started",
|
||||
title: "Getting Started",
|
||||
content: `
|
||||
<p>Welcome to <strong>Nexus Terminal</strong> — a desktop application for connecting to remote servers via SSH, Telnet, and RAW TCP, as well as browsing your GitHub repositories.</p>
|
||||
<p>Welcome to <strong>Nexus Terminal</strong> — a desktop application for SSH, Telnet, RAW TCP, VNC, GitHub, local terminal, and Leviathan binary-analysis workflows.</p>
|
||||
<h4>Creating Your First Connection</h4>
|
||||
<ol>
|
||||
<li>Click the <strong>+</strong> button at the top of the sidebar, or press <kbd>⌘N</kbd> (Mac) / <kbd>Ctrl+N</kbd> (Windows/Linux).</li>
|
||||
<li>Choose a protocol (SSH, Telnet, RAW, or GitHub).</li>
|
||||
<li>Choose a protocol (SSH, Telnet, RAW, VNC, or GitHub).</li>
|
||||
<li>Fill in the hostname, port, and any credentials required.</li>
|
||||
<li>Optionally assign the connection to a <strong>group</strong> to keep your sidebar organised.</li>
|
||||
<li>Click <strong>Save</strong>, then double-click the entry in the sidebar to connect.</li>
|
||||
</ol>
|
||||
<h4>Leviathan Tools</h4>
|
||||
<p>Click <strong>Leviathan</strong> beside <strong>Nexus Terminal</strong> in the sidebar header to switch from connection profiles to binary-analysis tools. Use <strong>Load</strong> to choose a local file, then open tools such as Hex Editor, Overview, Entropy, Carve, BinSnoop, Structures, and Spectrogram View.</p>
|
||||
<h4>Quick Connect</h4>
|
||||
<p>Need to connect quickly without saving a profile? Press <kbd>⌘⇧Q</kbd> / <kbd>Ctrl+Shift+Q</kbd> to open the quick connect dialog. Choose SSH, Telnet, or RAW, enter the host and port, and you're connected immediately.</p>
|
||||
<h4>Managing Connections</h4>
|
||||
@@ -586,6 +633,22 @@
|
||||
</ul>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "vnc",
|
||||
title: "VNC Connections",
|
||||
content: `
|
||||
<p>VNC profiles open a remote desktop viewer inside the Nexus workspace.</p>
|
||||
<ul>
|
||||
<li><strong>Password handling</strong> — ask each time, use the session only, or save the password in the encrypted secret store.</li>
|
||||
<li><strong>View-only mode</strong> — observe a desktop without sending mouse or keyboard input.</li>
|
||||
<li><strong>Scale-to-fit</strong> — resize the remote desktop to fit the available workspace.</li>
|
||||
<li><strong>Shared session</strong> — request a shared VNC session where the server supports it.</li>
|
||||
<li><strong>Quality</strong> — tune image quality for slower links.</li>
|
||||
<li><strong>SSH tunnel</strong> — carry VNC traffic through an SSH connection using password, public key, or SSH agent authentication.</li>
|
||||
</ul>
|
||||
<p>Prefer <strong>SSH tunnel</strong> 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.</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
title: "GitHub Connections",
|
||||
@@ -601,6 +664,35 @@
|
||||
<p>Your repositories are listed grouped by organisation or owner, sorted by last commit date (newest first). The list refreshes automatically every few minutes.</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "leviathan",
|
||||
title: "Leviathan Binary Tools",
|
||||
content: `
|
||||
<p>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.</p>
|
||||
<h4>Opening and Saving Files</h4>
|
||||
<p>Switch to <strong>Leviathan</strong> in the sidebar header, click <strong>Load</strong>, and select a local file. The file becomes the active working document. Use <strong>Save</strong> to write the patched working copy to a destination you choose.</p>
|
||||
<p>Leviathan tools open in normal tabs beside terminal and VNC sessions. They close immediately without a disconnect confirmation.</p>
|
||||
<h4>Hex Editor</h4>
|
||||
<ul>
|
||||
<li>Edit byte cells directly; two hex digits commit the byte and move to the next cell.</li>
|
||||
<li>The ASCII preview updates while editing.</li>
|
||||
<li>Switch between 16-byte and 32-byte rows.</li>
|
||||
<li>Search for strings or hex values and move through matches with previous/next.</li>
|
||||
<li>Paste runs such as <code>de ad be ef</code> or <code>deadbeef</code> into consecutive cells.</li>
|
||||
<li>Use Undo, Redo, and Revert from the Leviathan panel header.</li>
|
||||
</ul>
|
||||
<h4>Triage Tools</h4>
|
||||
<p><strong>Overview</strong>, <strong>Hashes</strong>, <strong>Entropy</strong>, <strong>Strings</strong>, <strong>IOCs</strong>, <strong>Search</strong>, and <strong>Multi-search</strong> 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.</p>
|
||||
<h4>Structure and Recovery Tools</h4>
|
||||
<p><strong>Structures</strong>, <strong>Disasm</strong>, <strong>PDF</strong>, <strong>SquashFS</strong>, <strong>Carve</strong>, <strong>Keys</strong>, <strong>Timestamps</strong>, <strong>Stego</strong>, <strong>Metadata</strong>, <strong>BinSnoop</strong>, and <strong>Rules</strong> help identify embedded formats, files, signatures, strings, keys, and suspicious structures. Carve and BinSnoop can export discovered files or ranges.</p>
|
||||
<h4>Edit, Transform, and Case Tools</h4>
|
||||
<p><strong>Patch Plan</strong> validates patch scripts, <strong>Patch Apply</strong> applies them, <strong>Transforms</strong> runs hex/Base64/URL/XOR/inflate/cipher pipelines, and <strong>Case Export</strong> packages investigation artefacts.</p>
|
||||
<h4>Spectrogram Tools</h4>
|
||||
<p><strong>Spectrogram Encode</strong> turns an image into WAV audio or inserts an encoded image into an existing WAV/MP3 carrier. <strong>Spectrogram View</strong> visualises WAV or MP3 audio as a spectrogram image with controls for FFT size, height, aspect ratio, frequency range, brightness, and contrast.</p>
|
||||
<h4>Large Files</h4>
|
||||
<p>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.</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "sessions",
|
||||
title: "Sessions & Tabs",
|
||||
@@ -610,7 +702,9 @@
|
||||
<li><strong>Rename</strong> — right-click a tab and choose "Rename".</li>
|
||||
<li><strong>Reorder</strong> — drag tabs left or right to rearrange.</li>
|
||||
<li><strong>Pin</strong> — right-click and pin a tab so it stays at the left of the bar.</li>
|
||||
<li><strong>Close</strong> — click the <strong>×</strong> on a tab, or press <kbd>⌘W</kbd> / <kbd>Ctrl+W</kbd>.</li>
|
||||
<li><strong>Scroll</strong> — use the tab bar scrollbar or mouse wheel/trackpad over the tab strip when many tabs are open.</li>
|
||||
<li><strong>Hover hints</strong> — hover a tab to see its full name and connected-since time where available.</li>
|
||||
<li><strong>Close</strong> — click the <strong>×</strong> on the left side of a tab, or press <kbd>⌘W</kbd> / <kbd>Ctrl+W</kbd>. Leviathan tabs close without a disconnect confirmation.</li>
|
||||
</ul>
|
||||
<h4>Split Panes</h4>
|
||||
<p>You can split the terminal into multiple panes:</p>
|
||||
@@ -752,12 +846,26 @@
|
||||
<li>If the server is genuinely unreachable, attempts will exhaust after the configured maximum (default 5).</li>
|
||||
<li>Check the reconnection settings in the profile's Advanced section.</li>
|
||||
</ul>
|
||||
<h4>VNC will not connect</h4>
|
||||
<ul>
|
||||
<li>Check the VNC host and port. The common port is <code>5900</code> plus the display number.</li>
|
||||
<li>If a password is required, check whether the profile is set to saved, session-only, or ask-every-time mode.</li>
|
||||
<li>If using an SSH tunnel, verify the tunnel host, username, authentication method, private key, and host-key policy.</li>
|
||||
<li>If using direct VNC, test from a trusted network and prefer SSH tunnelling on untrusted networks.</li>
|
||||
</ul>
|
||||
<h4>The terminal looks garbled or the wrong size</h4>
|
||||
<ul>
|
||||
<li>Try resizing the window — the terminal should re-fit automatically.</li>
|
||||
<li>If you're using split panes, drag the divider to give the terminal more space.</li>
|
||||
<li>Some older servers may not support modern terminal features. Try a different terminal theme in <strong>Settings → Terminal</strong>.</li>
|
||||
</ul>
|
||||
<h4>Leviathan cannot load, save, or run a tool</h4>
|
||||
<ul>
|
||||
<li>Check that the selected file still exists and that your account can read it.</li>
|
||||
<li>When saving, choose a writable destination and avoid files that are locked by another process.</li>
|
||||
<li>Some tools have explicit size limits because their parsers or decoders need a complete buffer. Use a smaller file or selected range when available.</li>
|
||||
<li>If SquashFS reports bad magic, the file is probably not a SquashFS image; inspect the header in Hex Editor or try Overview/BinSnoop first.</li>
|
||||
</ul>
|
||||
<h4>"Host key verification failed"</h4>
|
||||
<p>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 <strong>Settings → Known Hosts</strong>, find the entry, and remove it. You'll be prompted to accept the new key on your next connection.</p>
|
||||
<h4>I forgot my vault master password</h4>
|
||||
@@ -866,8 +974,8 @@
|
||||
|
||||
const versionEl = body.querySelector("[data-role='app-version']");
|
||||
if (versionEl) {
|
||||
if (window.go && window.go.api && window.go.api.Service) {
|
||||
window.go.api.Service.GetAppInfo()
|
||||
if (window.NexusTerminal?.wailsService) {
|
||||
window.NexusTerminal.wailsService.GetAppInfo()
|
||||
.then((info) => {
|
||||
const version = (info && info.version) || "dev";
|
||||
versionEl.textContent = `Version ${version}`;
|
||||
@@ -886,17 +994,35 @@
|
||||
updateBtn.disabled = true;
|
||||
updateBtn.textContent = "Checking…";
|
||||
try {
|
||||
const info = await window.go.api.Service.CheckForUpdates();
|
||||
const info = await window.NexusTerminal.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.innerHTML =
|
||||
`<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
|
||||
(info.releaseURL
|
||||
? ` — <a href="#" onclick="window.runtime.BrowserOpenURL('${info.releaseURL}');return false;" style="color:var(--accent)">View release</a>`
|
||||
: "");
|
||||
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.";
|
||||
}
|
||||
@@ -916,7 +1042,7 @@
|
||||
importBtn.disabled = true;
|
||||
importBtn.textContent = "Importing…";
|
||||
try {
|
||||
const count = await window.go.api.Service.ImportSSHConfig(
|
||||
const count = await window.NexusTerminal.wailsService.ImportSSHConfig(
|
||||
"",
|
||||
"SSH Config",
|
||||
);
|
||||
@@ -1050,6 +1176,7 @@
|
||||
"defaultScrollback",
|
||||
"defaultCursorStyle",
|
||||
"useNativePaste",
|
||||
"localShell",
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
|
||||
+419
-25
@@ -231,6 +231,16 @@
|
||||
|
||||
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);
|
||||
@@ -271,9 +281,9 @@
|
||||
|
||||
try {
|
||||
if (path === null) {
|
||||
path = await window.go.api.Service.SFTPPwd(sessionId);
|
||||
path = await window.NexusTerminal.wailsService.SFTPPwd(sessionId);
|
||||
}
|
||||
var files = await window.go.api.Service.SFTPList(sessionId, path);
|
||||
var files = await window.NexusTerminal.wailsService.SFTPList(sessionId, path);
|
||||
state.cwd = path;
|
||||
state.files = this._sortFiles(files || []);
|
||||
state.loading = false;
|
||||
@@ -426,7 +436,7 @@
|
||||
if (!isParent) {
|
||||
row.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._selectFile(sessionId, entry, row);
|
||||
self._selectFile(sessionId, entry, row, e);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -468,52 +478,104 @@
|
||||
|
||||
/* ── Selection & context actions ──────────────────────────────── */
|
||||
|
||||
_selectFile(sessionId, entry, rowEl) {
|
||||
_selectFile(sessionId, entry, rowEl, event) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
|
||||
// Deselect previous
|
||||
var prev = state.el.querySelector(".sftp-file-row.is-selected");
|
||||
if (prev) prev.classList.remove("is-selected");
|
||||
// Initialise multi-select set if needed
|
||||
if (!state.selectedFiles) state.selectedFiles = new Map();
|
||||
|
||||
// If clicking the same entry, deselect
|
||||
if (state.selected === entry.path) {
|
||||
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.selected = entry.path;
|
||||
state.selectedFiles.clear();
|
||||
state.selectedFiles.set(entry.path, entry);
|
||||
rowEl.classList.add("is-selected");
|
||||
this._showContextBar(sessionId, entry);
|
||||
}
|
||||
|
||||
_showContextBar(sessionId, entry) {
|
||||
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: entry.name,
|
||||
title: entry.path,
|
||||
textContent: isSingle ? entry.name : count + " items selected",
|
||||
title: isSingle
|
||||
? entry.path
|
||||
: entries
|
||||
.map(function (e) {
|
||||
return e.name;
|
||||
})
|
||||
.join(", "),
|
||||
});
|
||||
ctxBar.appendChild(label);
|
||||
|
||||
var self = this;
|
||||
|
||||
if (!entry.isDir) {
|
||||
// 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: "↓ Download",
|
||||
textContent: dlLabel,
|
||||
});
|
||||
dlBtn.addEventListener("click", function () {
|
||||
self._download(sessionId, entry.path);
|
||||
self._downloadBatch(sessionId, downloadableFiles);
|
||||
});
|
||||
ctxBar.appendChild(dlBtn);
|
||||
}
|
||||
|
||||
if (isSingle) {
|
||||
var renameBtn = createElement("button", "sftp-ctx-btn", {
|
||||
textContent: "✎ Rename",
|
||||
});
|
||||
@@ -521,12 +583,18 @@
|
||||
self._promptRename(sessionId, entry);
|
||||
});
|
||||
ctxBar.appendChild(renameBtn);
|
||||
}
|
||||
|
||||
var delLabel = isSingle ? "✕ Delete" : "✕ Delete (" + count + ")";
|
||||
var delBtn = createElement("button", "sftp-ctx-btn is-danger", {
|
||||
textContent: "✕ Delete",
|
||||
textContent: delLabel,
|
||||
});
|
||||
delBtn.addEventListener("click", function () {
|
||||
if (isSingle) {
|
||||
self._confirmDelete(sessionId, entry);
|
||||
} else {
|
||||
self._confirmDeleteBatch(sessionId, entries);
|
||||
}
|
||||
});
|
||||
ctxBar.appendChild(delBtn);
|
||||
}
|
||||
@@ -535,7 +603,7 @@
|
||||
|
||||
async _download(sessionId, filePath) {
|
||||
try {
|
||||
await window.go.api.Service.SFTPDownload(sessionId, filePath);
|
||||
await window.NexusTerminal.wailsService.SFTPDownload(sessionId, filePath);
|
||||
this.app.notifications.success("Download started");
|
||||
} catch (err) {
|
||||
this.app.notifications.error(
|
||||
@@ -548,7 +616,7 @@
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
try {
|
||||
await window.go.api.Service.SFTPUpload(sessionId, state.cwd);
|
||||
await window.NexusTerminal.wailsService.SFTPUpload(sessionId, state.cwd);
|
||||
this.app.notifications.success("Upload complete");
|
||||
await this._refresh(sessionId);
|
||||
} catch (err) {
|
||||
@@ -565,7 +633,7 @@
|
||||
if (!state) return;
|
||||
var fullPath = pathJoin(state.cwd, name);
|
||||
try {
|
||||
await window.go.api.Service.SFTPMkdir(sessionId, fullPath);
|
||||
await window.NexusTerminal.wailsService.SFTPMkdir(sessionId, fullPath);
|
||||
this.app.notifications.success("Directory created");
|
||||
await this._refresh(sessionId);
|
||||
} catch (err) {
|
||||
@@ -578,7 +646,7 @@
|
||||
if (!state) return;
|
||||
var newPath = pathJoin(parentPath(oldPath), newName);
|
||||
try {
|
||||
await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath);
|
||||
await window.NexusTerminal.wailsService.SFTPRename(sessionId, oldPath, newPath);
|
||||
this.app.notifications.success("Renamed");
|
||||
await this._refresh(sessionId);
|
||||
} catch (err) {
|
||||
@@ -588,7 +656,7 @@
|
||||
|
||||
async _delete(sessionId, entry) {
|
||||
try {
|
||||
await window.go.api.Service.SFTPRemove(sessionId, entry.path);
|
||||
await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entry.path);
|
||||
this.app.notifications.success(
|
||||
(entry.isDir ? "Directory" : "File") + " deleted",
|
||||
);
|
||||
@@ -598,6 +666,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
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.NexusTerminal.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 <strong>" +
|
||||
entries.length +
|
||||
" selected items</strong>?",
|
||||
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.NexusTerminal.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) {
|
||||
@@ -784,6 +905,279 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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 =
|
||||
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);text-align:center;padding:var(--space-3);">No bookmarks yet. Navigate to a directory and click "Bookmark Current".</div>';
|
||||
} else {
|
||||
for (var i = 0; i < bookmarks.length; i++) {
|
||||
listHtml +=
|
||||
'<div class="sftp-bookmark-row" data-index="' +
|
||||
i +
|
||||
'" data-path="' +
|
||||
escapeHtml(bookmarks[i]) +
|
||||
'">';
|
||||
listHtml +=
|
||||
'<span style="flex:1;font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
|
||||
escapeHtml(bookmarks[i]) +
|
||||
"</span>";
|
||||
listHtml +=
|
||||
'<button class="btn btn-xs btn-ghost sftp-bookmark-del" data-index="' +
|
||||
i +
|
||||
'" title="Remove">✕</button>';
|
||||
listHtml += "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
modal.innerHTML =
|
||||
'<div class="modal-header">' +
|
||||
'<span class="modal-title">Directory Bookmarks</span>' +
|
||||
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
||||
"</div>" +
|
||||
'<div class="modal-body">' +
|
||||
'<div class="sftp-bookmark-list">' +
|
||||
listHtml +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Close</button>' +
|
||||
'<button class="btn btn-sm btn-primary sftp-bookmark-add">★ Bookmark Current</button>' +
|
||||
"</div>";
|
||||
|
||||
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) {
|
||||
|
||||
+90
-10
@@ -59,6 +59,10 @@
|
||||
*/
|
||||
render() {
|
||||
if (!this.el) return;
|
||||
if (this.app.mode === "leviathan") {
|
||||
this.app.leviathan.renderSidebar();
|
||||
return;
|
||||
}
|
||||
this.el.innerHTML = "";
|
||||
|
||||
const profiles = this._filteredProfiles();
|
||||
@@ -68,6 +72,9 @@
|
||||
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) {
|
||||
@@ -114,11 +121,7 @@
|
||||
|
||||
// Empty state
|
||||
if (profiles.length === 0) {
|
||||
const empty = createElement("div", "sidebar-empty");
|
||||
empty.innerHTML = `
|
||||
<p class="sidebar-empty-text">No profiles found</p>
|
||||
`;
|
||||
this.el.appendChild(empty);
|
||||
this.el.appendChild(this._renderEmptyState());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +162,53 @@
|
||||
|
||||
// ── 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
|
||||
@@ -391,7 +441,7 @@
|
||||
async _toggleFavourite(profile) {
|
||||
try {
|
||||
const newVal = !profile.favourite;
|
||||
await window.go.api.Service.SetFavourite(profile.id, newVal);
|
||||
await window.NexusTerminal.wailsService.SetFavourite(profile.id, newVal);
|
||||
profile.favourite = newVal;
|
||||
this.app.state.updateProfile(profile);
|
||||
this.render();
|
||||
@@ -407,7 +457,7 @@
|
||||
*/
|
||||
async _duplicateProfile(profile) {
|
||||
try {
|
||||
const dup = await window.go.api.Service.DuplicateProfile(profile.id);
|
||||
const dup = await window.NexusTerminal.wailsService.DuplicateProfile(profile.id);
|
||||
this.app.state.addProfile(dup);
|
||||
this.render();
|
||||
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
|
||||
@@ -428,7 +478,7 @@
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await window.go.api.Service.DeleteProfile(profile.id);
|
||||
await window.NexusTerminal.wailsService.DeleteProfile(profile.id);
|
||||
this.app.state.removeProfile(profile.id);
|
||||
this.render();
|
||||
this.app.notifications.success("Profile deleted");
|
||||
@@ -531,6 +581,36 @@
|
||||
);
|
||||
}
|
||||
|
||||
/** @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");
|
||||
@@ -607,7 +687,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await window.go.api.Service.UpdateProfile(updated);
|
||||
const saved = await window.NexusTerminal.wailsService.UpdateProfile(updated);
|
||||
this.app.state.updateProfile(saved);
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to update tags: " + err);
|
||||
@@ -628,7 +708,7 @@
|
||||
const updated = JSON.parse(JSON.stringify(p));
|
||||
updated.folder = folder;
|
||||
try {
|
||||
const saved = await window.go.api.Service.UpdateProfile(updated);
|
||||
const saved = await window.NexusTerminal.wailsService.UpdateProfile(updated);
|
||||
this.app.state.updateProfile(saved);
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to move group: " + err);
|
||||
|
||||
+224
-76
@@ -4,7 +4,8 @@
|
||||
* Provides a panel for managing and sending reusable commands to active sessions.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, dismissContextMenus } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, dismissContextMenus } =
|
||||
window.NexusTerminal;
|
||||
|
||||
class SnippetLibrary {
|
||||
/**
|
||||
@@ -14,7 +15,7 @@ class SnippetLibrary {
|
||||
this.app = app;
|
||||
/** @type {Array<Object>} */
|
||||
this.snippets = [];
|
||||
this.searchQuery = '';
|
||||
this.searchQuery = "";
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -29,26 +30,140 @@ class SnippetLibrary {
|
||||
|
||||
/**
|
||||
* 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');
|
||||
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');
|
||||
if (!session || session.state !== "connected") {
|
||||
this.app.notifications.error("Session is not connected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const b64 = btoa(command + '\n');
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
this.app.notifications.info('Snippet sent');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to send snippet: ' + err);
|
||||
|
||||
// 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.NexusTerminal.utf8ToBase64(command + "\n");
|
||||
window.NexusTerminal.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 += `
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">${escapeHtml(v)}</label>
|
||||
<input type="text" class="form-input snippet-var-input" data-var="${escapeHtml(v)}" placeholder="Enter value for ${escapeHtml(v)}" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Snippet Variables</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">${inputsHtml}</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm btn-ghost snippet-var-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary snippet-var-send">Send</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
@@ -56,10 +171,10 @@ class SnippetLibrary {
|
||||
/** @private */
|
||||
async _loadSnippets() {
|
||||
try {
|
||||
this.snippets = await window.go.api.Service.ListSnippets() || [];
|
||||
this.snippets = (await window.NexusTerminal.wailsService.ListSnippets()) || [];
|
||||
} catch (err) {
|
||||
this.snippets = [];
|
||||
console.error('Failed to load snippets:', err);
|
||||
console.error("Failed to load snippets:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +182,8 @@ class SnippetLibrary {
|
||||
|
||||
/** @private */
|
||||
_showModal() {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-lg');
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
@@ -94,28 +209,30 @@ class SnippetLibrary {
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
modal.classList.remove('is-visible');
|
||||
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(); });
|
||||
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', () => {
|
||||
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', () => {
|
||||
modal.querySelector(".snippet-add-btn").addEventListener("click", () => {
|
||||
this._showEditor(modal, null, close);
|
||||
});
|
||||
|
||||
@@ -124,87 +241,106 @@ class SnippetLibrary {
|
||||
|
||||
/** @private */
|
||||
_renderList(modal, closeFn) {
|
||||
const listEl = modal.querySelector('.snippet-list');
|
||||
const listEl = modal.querySelector(".snippet-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '';
|
||||
listEl.innerHTML = "";
|
||||
|
||||
const filtered = this.snippets.filter((sn) => {
|
||||
if (!this.searchQuery) return true;
|
||||
return sn.name.toLowerCase().includes(this.searchQuery) ||
|
||||
return (
|
||||
sn.name.toLowerCase().includes(this.searchQuery) ||
|
||||
sn.command.toLowerCase().includes(this.searchQuery) ||
|
||||
(sn.description || '').toLowerCase().includes(this.searchQuery);
|
||||
(sn.description || "").toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
|
||||
listEl.innerHTML =
|
||||
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sn of filtered) {
|
||||
const item = createElement('div', 'snippet-item', {
|
||||
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.style.cssText =
|
||||
"padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);";
|
||||
item.innerHTML = `
|
||||
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
|
||||
`;
|
||||
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 = ''; });
|
||||
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');
|
||||
const detailPanel = modal.querySelector(".snippet-detail-panel");
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = '';
|
||||
detailPanel.innerHTML = "";
|
||||
|
||||
const nameEl = createElement('div', '', {
|
||||
const nameEl = createElement("div", "", {
|
||||
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
|
||||
});
|
||||
detailPanel.appendChild(nameEl);
|
||||
|
||||
if (snippet.description) {
|
||||
const descEl = createElement('div', '', {
|
||||
const descEl = createElement("div", "", {
|
||||
textContent: snippet.description,
|
||||
});
|
||||
descEl.style.cssText = 'font-size:var(--text-sm);color:var(--text-secondary);';
|
||||
descEl.style.cssText =
|
||||
"font-size:var(--text-sm);color:var(--text-secondary);";
|
||||
detailPanel.appendChild(descEl);
|
||||
}
|
||||
|
||||
const cmdEl = createElement('pre', '', {
|
||||
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;';
|
||||
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 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', () => {
|
||||
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', () => {
|
||||
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 () => {
|
||||
const deleteBtn = createElement("button", "btn btn-sm btn-danger", {
|
||||
textContent: "Delete",
|
||||
});
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.go.api.Service.DeleteSnippet(snippet.id);
|
||||
await window.NexusTerminal.wailsService.DeleteSnippet(snippet.id);
|
||||
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
|
||||
this._renderList(modal, closeFn);
|
||||
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
|
||||
detailPanel.innerHTML =
|
||||
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to delete: ' + err);
|
||||
this.app.notifications.error("Failed to delete: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,30 +352,30 @@ class SnippetLibrary {
|
||||
|
||||
/** @private */
|
||||
_showEditor(modal, snippet, closeFn) {
|
||||
const detailPanel = modal.querySelector('.snippet-detail-panel');
|
||||
const detailPanel = modal.querySelector(".snippet-detail-panel");
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = '';
|
||||
detailPanel.innerHTML = "";
|
||||
|
||||
const isNew = !snippet;
|
||||
const title = isNew ? 'New Snippet' : 'Edit Snippet';
|
||||
const title = isNew ? "New Snippet" : "Edit Snippet";
|
||||
|
||||
detailPanel.innerHTML = `
|
||||
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-input snippet-ed-name" value="${isNew ? '' : escapeHtml(snippet.name)}" />
|
||||
<input type="text" class="form-input snippet-ed-name" value="${isNew ? "" : escapeHtml(snippet.name)}" />
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? '' : escapeHtml(snippet.description || '')}" />
|
||||
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? "" : escapeHtml(snippet.description || "")}" />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;display:flex;flex-direction:column;">
|
||||
<label class="form-label">Command</label>
|
||||
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? '' : escapeHtml(snippet.command)}</textarea>
|
||||
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? "" : escapeHtml(snippet.command)}</textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Folder</label>
|
||||
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? '' : escapeHtml(snippet.folder || '')}" placeholder="e.g. Networking, Debugging" />
|
||||
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? "" : escapeHtml(snippet.folder || "")}" placeholder="e.g. Networking, Debugging" />
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);">
|
||||
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
|
||||
@@ -247,54 +383,67 @@ class SnippetLibrary {
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
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');
|
||||
this.app.notifications.error("Name is required");
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
this.app.notifications.error('Command is required');
|
||||
this.app.notifications.error("Command is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
id: isNew ? '' : snippet.id,
|
||||
id: isNew ? "" : snippet.id,
|
||||
name,
|
||||
command,
|
||||
description,
|
||||
folder,
|
||||
tags: isNew ? [] : (snippet.tags || []),
|
||||
tags: isNew ? [] : snippet.tags || [],
|
||||
};
|
||||
|
||||
let saved;
|
||||
if (isNew) {
|
||||
saved = await window.go.api.Service.CreateSnippet(data);
|
||||
saved = await window.NexusTerminal.wailsService.CreateSnippet(data);
|
||||
this.snippets.push(saved);
|
||||
} else {
|
||||
saved = await window.go.api.Service.UpdateSnippet(data);
|
||||
saved = await window.NexusTerminal.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');
|
||||
this.app.notifications.info(
|
||||
isNew ? "Snippet created" : "Snippet updated",
|
||||
);
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to save snippet: ' + err);
|
||||
this.app.notifications.error("Failed to save snippet: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
detailPanel.querySelector('.snippet-cancel-btn').addEventListener('click', () => {
|
||||
detailPanel
|
||||
.querySelector(".snippet-cancel-btn")
|
||||
.addEventListener("click", () => {
|
||||
if (snippet) {
|
||||
this._showDetail(modal, snippet, closeFn);
|
||||
} else {
|
||||
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
|
||||
detailPanel.innerHTML =
|
||||
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -302,5 +451,4 @@ class SnippetLibrary {
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
|
||||
|
||||
})();
|
||||
|
||||
@@ -434,7 +434,7 @@ class SplitPaneManager {
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
// Connect via backend
|
||||
window.go.api.Service.ConnectSession(profile.id).then(function(sessionInfo) {
|
||||
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) {
|
||||
self.app.state.addSession(sessionInfo);
|
||||
|
||||
// Create a terminal container inside this pane
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — SSH Key Manager.
|
||||
* Panel for listing, generating, copying, and deleting SSH key pairs.
|
||||
*/
|
||||
|
||||
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
||||
|
||||
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 = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">SSH Key Manager</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sshkey-toolbar">
|
||||
<button class="btn btn-sm btn-accent sshkey-generate-btn">Generate New Key</button>
|
||||
</div>
|
||||
<div class="sshkey-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<div class="sshkey-loading">Loading keys…</div>';
|
||||
|
||||
try {
|
||||
const keys = await window.NexusTerminal.wailsService.ListSSHKeyPairs();
|
||||
if (keys.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
for (const key of keys) {
|
||||
list.appendChild(this._renderKeyRow(key));
|
||||
}
|
||||
} catch (err) {
|
||||
list.innerHTML = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_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.NexusTerminal.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.NexusTerminal.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 = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Key Name</label>
|
||||
<input type="text" class="form-input sshkey-name-input" placeholder="id_ed25519_nexus" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-input sshkey-type-select">
|
||||
<option value="ed25519" selected>Ed25519 (recommended)</option>
|
||||
<option value="rsa">RSA</option>
|
||||
<option value="ecdsa">ECDSA</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group sshkey-bits-group" style="display:none">
|
||||
<label class="form-label">Bits</label>
|
||||
<select class="form-input sshkey-bits-select">
|
||||
<option value="4096" selected>4096</option>
|
||||
<option value="2048">2048</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Passphrase (optional)</label>
|
||||
<input type="password" class="form-input sshkey-pass-input" placeholder="Leave blank for no passphrase" autocomplete="off" />
|
||||
</div>
|
||||
<div class="sshkey-form-actions">
|
||||
<button class="btn btn-sm sshkey-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-sm btn-accent sshkey-create-btn">Generate</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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.NexusTerminal.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");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-sm);color:var(--text-secondary)">${message}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-danger confirm-ok">${confirmLabel}</button>
|
||||
</div>
|
||||
`;
|
||||
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.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SSHKeyManager = SSHKeyManager;
|
||||
})();
|
||||
+22
-19
@@ -28,6 +28,9 @@ class State {
|
||||
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
|
||||
this.broadcastMode = false;
|
||||
|
||||
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
|
||||
this.broadcastTargets = null;
|
||||
|
||||
/** @type {Map<string, Set<Function>>} event name → callbacks */
|
||||
this._listeners = new Map();
|
||||
}
|
||||
@@ -84,7 +87,7 @@ class State {
|
||||
*/
|
||||
setProfiles(profiles) {
|
||||
this.profiles = profiles;
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,8 +96,8 @@ class State {
|
||||
*/
|
||||
addProfile(profile) {
|
||||
this.profiles.push(profile);
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:added', profile);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:added", profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,8 +109,8 @@ class State {
|
||||
if (idx !== -1) {
|
||||
this.profiles[idx] = profile;
|
||||
}
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:updated', profile);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:updated", profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,8 +119,8 @@ class State {
|
||||
*/
|
||||
removeProfile(id) {
|
||||
this.profiles = this.profiles.filter((p) => p.id !== id);
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:removed', id);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:removed", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,8 +140,8 @@ class State {
|
||||
*/
|
||||
addSession(sessionInfo) {
|
||||
this.sessions.set(sessionInfo.id, sessionInfo);
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:added', sessionInfo);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:added", sessionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,8 +153,8 @@ class State {
|
||||
const existing = this.sessions.get(id);
|
||||
if (existing) {
|
||||
Object.assign(existing, updates);
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:updated', existing);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:updated", existing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,10 +167,11 @@ class State {
|
||||
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.activeSessionId =
|
||||
remaining.length > 0 ? remaining[remaining.length - 1] : null;
|
||||
}
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:removed', id);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:removed", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +189,7 @@ class State {
|
||||
*/
|
||||
setActiveSession(id) {
|
||||
this.activeSessionId = id;
|
||||
this.emit('session:activated', id);
|
||||
this.emit("session:activated", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +208,7 @@ class State {
|
||||
*/
|
||||
setPreferences(prefs) {
|
||||
this.preferences = prefs;
|
||||
this.emit('preferences:changed', this.preferences);
|
||||
this.emit("preferences:changed", this.preferences);
|
||||
}
|
||||
|
||||
// ── Secret store ─────────────────────────────────────────────────
|
||||
@@ -215,7 +219,7 @@ class State {
|
||||
*/
|
||||
setSecretStoreState(unlocked) {
|
||||
this.secretStoreUnlocked = unlocked;
|
||||
this.emit('secretStore:changed', unlocked);
|
||||
this.emit("secretStore:changed", unlocked);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,11 +228,10 @@ class State {
|
||||
*/
|
||||
setSecretStoreInitialised(initialised) {
|
||||
this.secretStoreInitialised = initialised;
|
||||
this.emit('secretStore:init', initialised);
|
||||
this.emit("secretStore:init", initialised);
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.State = State;
|
||||
|
||||
})();
|
||||
|
||||
+271
-19
@@ -32,7 +32,13 @@
|
||||
/** @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 ───────────────────────────────────────────────────
|
||||
@@ -56,6 +62,7 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -64,7 +71,9 @@
|
||||
* @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
|
||||
@@ -77,8 +86,8 @@
|
||||
const panel = document.getElementById(`panel-${sessionId}`);
|
||||
if (panel) panel.remove();
|
||||
|
||||
// If this was the active tab, activate the nearest neighbour
|
||||
if (this.app.state.activeSessionId === sessionId) {
|
||||
// Activate the nearest neighbour
|
||||
if (wasActive) {
|
||||
const next =
|
||||
this.order.length > 0
|
||||
? this.order[Math.min(idx, this.order.length - 1)]
|
||||
@@ -91,6 +100,7 @@
|
||||
}
|
||||
|
||||
this._updateEmptyState();
|
||||
this._updateScrollState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,9 +110,12 @@
|
||||
setActive(sessionId) {
|
||||
this.app.state.setActiveSession(sessionId);
|
||||
|
||||
// Highlight active tab
|
||||
// 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
|
||||
@@ -115,12 +128,21 @@
|
||||
if (session) {
|
||||
this.app.sidebar.highlightActive(session.profileId);
|
||||
}
|
||||
this.app.leviathan?.handleTabFocus?.(sessionId);
|
||||
this._scrollTabIntoView(sessionId);
|
||||
|
||||
// Fit the terminal for the newly active session
|
||||
this.app.terminals.fitActive();
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,12 +167,14 @@
|
||||
* @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" &&
|
||||
this.app.state.preferences.confirmOnDisconnect
|
||||
) {
|
||||
const ok = await this._confirmClose(sessionId);
|
||||
@@ -158,7 +182,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
await window.go.api.Service.DisconnectSession(sessionId);
|
||||
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
|
||||
} catch (_) {
|
||||
// Session may already be closed — carry on
|
||||
}
|
||||
@@ -166,8 +190,9 @@
|
||||
this.app.terminals.destroyTerminal(sessionId);
|
||||
this.app.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
this.app.leviathan?.destroyPanel(sessionId);
|
||||
this.removeTab(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +203,7 @@
|
||||
*/
|
||||
_confirmClose(sessionId) {
|
||||
const createElement = window.NexusTerminal.createElement;
|
||||
const label = this._tabLabel(sessionId);
|
||||
const label = escapeHtml(this._tabLabel(sessionId));
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
@@ -418,6 +443,21 @@
|
||||
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}
|
||||
@@ -451,10 +491,24 @@
|
||||
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",
|
||||
@@ -481,6 +535,10 @@
|
||||
);
|
||||
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");
|
||||
@@ -500,17 +558,6 @@
|
||||
tab.appendChild(sftpBtn);
|
||||
}
|
||||
|
||||
// Close button
|
||||
const closeBtn = createElement("button", "tab-close", {
|
||||
textContent: "×",
|
||||
"aria-label": "Close tab",
|
||||
});
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeTab(sessionInfo.id);
|
||||
});
|
||||
tab.appendChild(closeBtn);
|
||||
|
||||
// Click → activate
|
||||
tab.addEventListener("click", () => this.setActive(sessionInfo.id));
|
||||
|
||||
@@ -545,6 +592,7 @@
|
||||
const el = this.tabList.querySelector(`[data-session-id="${id}"]`);
|
||||
if (el) this.tabList.appendChild(el);
|
||||
}
|
||||
this._updateScrollState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -562,6 +610,210 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
+417
-46
@@ -7,6 +7,22 @@
|
||||
|
||||
/* 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
|
||||
@@ -47,6 +63,8 @@
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
windowsMode: false,
|
||||
scrollOnUserInput: true,
|
||||
altClickMovesCursor: true,
|
||||
});
|
||||
|
||||
// Load addons
|
||||
@@ -110,6 +128,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 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.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||
} catch (_) {
|
||||
/* non-critical */
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
|
||||
// Drag-and-drop file handling
|
||||
container.addEventListener("dragover", (e) => {
|
||||
if (
|
||||
@@ -128,36 +182,88 @@
|
||||
this._handleFileDrop(sessionId, term, files);
|
||||
});
|
||||
|
||||
// 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;
|
||||
const doFit = () => {
|
||||
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();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
};
|
||||
doFit();
|
||||
|
||||
// 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(doFit);
|
||||
requestAnimationFrame(() => scheduleFit(true));
|
||||
|
||||
const refreshTerminal = () => {
|
||||
try {
|
||||
term.refresh(0, term.rows - 1);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
// Forward user keystrokes to the backend
|
||||
term.onData((data) => {
|
||||
try {
|
||||
const b64 = btoa(data);
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
const b64 = utf8ToBase64(data);
|
||||
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||
|
||||
// Broadcast to all other connected sessions if enabled
|
||||
// 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.go.api.Service.WriteSession(otherId, b64);
|
||||
window.NexusTerminal.wailsService.WriteSession(otherId, b64);
|
||||
} catch (_) {
|
||||
/* non-critical */
|
||||
}
|
||||
@@ -225,19 +331,17 @@
|
||||
// Notify backend when terminal dimensions change
|
||||
term.onResize(({ cols, rows }) => {
|
||||
try {
|
||||
window.go.api.Service.ResizeSession(sessionId, cols, rows);
|
||||
window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows);
|
||||
} catch (err) {
|
||||
console.error("ResizeSession error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fit when the container itself resizes
|
||||
// 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(() => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (_) {
|
||||
// Ignore transient errors during layout shifts
|
||||
}
|
||||
scheduleFit();
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
@@ -247,6 +351,9 @@
|
||||
searchAddon,
|
||||
webLinksAddon,
|
||||
observer,
|
||||
scheduleFit,
|
||||
cancelScheduledFit,
|
||||
refreshTerminal,
|
||||
decoder:
|
||||
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
|
||||
});
|
||||
@@ -289,6 +396,7 @@
|
||||
if (!entry) return;
|
||||
|
||||
entry.observer.disconnect();
|
||||
entry.cancelScheduledFit();
|
||||
entry.term.dispose();
|
||||
this.terminals.delete(sessionId);
|
||||
}
|
||||
@@ -308,13 +416,7 @@
|
||||
// Re-fit the newly visible terminal
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fitAddon.fit();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
entry.scheduleFit(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,13 +435,7 @@
|
||||
fitSession(sessionId) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fitAddon.fit();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
entry.scheduleFit(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,6 +600,8 @@
|
||||
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
||||
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`;
|
||||
@@ -512,6 +610,7 @@
|
||||
const items = [
|
||||
{
|
||||
label: "Copy",
|
||||
shortcut: modKey + "C",
|
||||
disabled: !hasSelection,
|
||||
action: () => {
|
||||
const text = term.getSelection();
|
||||
@@ -527,6 +626,7 @@
|
||||
},
|
||||
{
|
||||
label: "Paste",
|
||||
shortcut: modKey + "V",
|
||||
action: () => {
|
||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||
window.runtime
|
||||
@@ -548,19 +648,41 @@
|
||||
{ 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(),
|
||||
},
|
||||
{
|
||||
@@ -574,9 +696,17 @@
|
||||
menu.appendChild(createElement("div", "context-menu-divider"));
|
||||
continue;
|
||||
}
|
||||
const btn = createElement("button", "context-menu-item", {
|
||||
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");
|
||||
@@ -627,7 +757,7 @@
|
||||
}
|
||||
|
||||
_promptFileDropAction(files, canUpload) {
|
||||
const { createElement, formatBytes } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, formatBytes } = window.NexusTerminal;
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
@@ -647,7 +777,7 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
|
||||
<strong>${title}</strong>${count === 1 && sizeLabel ? " (" + sizeLabel + ")" : ""}
|
||||
<strong>${escapeHtml(title)}</strong>${count === 1 && sizeLabel ? " (" + sizeLabel + ")" : ""}
|
||||
</p>
|
||||
<p style="font-size:var(--text-sm);color:var(--text-tertiary)">
|
||||
Choose what to do with the dropped ${count === 1 ? "file" : "files"}.
|
||||
@@ -738,14 +868,7 @@
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const file = files[i];
|
||||
try {
|
||||
const buffer = await this._readFileBuffer(file);
|
||||
const base64Data = this._arrayBufferToBase64(buffer);
|
||||
await window.go.api.Service.SFTPUploadData(
|
||||
sessionId,
|
||||
remoteDir,
|
||||
file.name,
|
||||
base64Data,
|
||||
);
|
||||
await this._uploadDroppedFileStream(sessionId, remoteDir, file);
|
||||
} catch (err) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
this.app.notifications.error(
|
||||
@@ -768,6 +891,46 @@
|
||||
});
|
||||
}
|
||||
|
||||
async _uploadDroppedFileStream(sessionId, remoteDir, file) {
|
||||
const totalBytes = typeof file.size === "number" ? file.size : 0;
|
||||
let uploadId = "";
|
||||
try {
|
||||
uploadId = await window.NexusTerminal.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.NexusTerminal.wailsService.SFTPUploadChunk(uploadId, base64Data);
|
||||
}
|
||||
|
||||
await window.NexusTerminal.wailsService.SFTPUploadFinish(uploadId);
|
||||
} catch (err) {
|
||||
if (uploadId) {
|
||||
try {
|
||||
await window.NexusTerminal.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();
|
||||
@@ -1004,12 +1167,12 @@
|
||||
renderLoading();
|
||||
try {
|
||||
const targetPath =
|
||||
path || (await window.go.api.Service.SFTPPwd(sessionId));
|
||||
const files = await window.go.api.Service.SFTPList(
|
||||
path || (await window.NexusTerminal.wailsService.SFTPPwd(sessionId));
|
||||
const files = await window.NexusTerminal.wailsService.SFTPList(
|
||||
sessionId,
|
||||
targetPath,
|
||||
);
|
||||
const stat = await window.go.api.Service.SFTPStat(
|
||||
const stat = await window.NexusTerminal.wailsService.SFTPStat(
|
||||
sessionId,
|
||||
targetPath,
|
||||
);
|
||||
@@ -1169,6 +1332,214 @@
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the terminal scrollback buffer as plain text.
|
||||
* @param {string} sessionId
|
||||
* @param {Terminal} term
|
||||
* @private
|
||||
*/
|
||||
_exportTerminalOutput(sessionId, term) {
|
||||
const { createElement } = window.NexusTerminal;
|
||||
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.NexusTerminal;
|
||||
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 = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Port Forwards</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="min-height:200px;">
|
||||
<div class="portfwd-list" style="margin-bottom:var(--space-3);"></div>
|
||||
<div class="portfwd-add" style="border-top:1px solid var(--border-subtle);padding-top:var(--space-3);">
|
||||
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);margin-bottom:var(--space-2);">Add New Forward</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr 60px 20px 1fr 60px;gap:var(--space-2);align-items:center;">
|
||||
<select class="form-input pf-direction" style="height:28px;font-size:var(--text-xs);">
|
||||
<option value="local">Local →</option>
|
||||
<option value="remote">Remote ←</option>
|
||||
</select>
|
||||
<input class="form-input pf-bind-host" type="text" placeholder="127.0.0.1" value="127.0.0.1" style="height:28px;font-size:var(--text-xs);" />
|
||||
<input class="form-input pf-bind-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
|
||||
<span style="text-align:center;color:var(--text-tertiary);">→</span>
|
||||
<input class="form-input pf-dest-host" type="text" placeholder="localhost" value="localhost" style="height:28px;font-size:var(--text-xs);" />
|
||||
<input class="form-input pf-dest-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);">
|
||||
<input class="form-input pf-label" type="text" placeholder="Label (optional)" style="flex:1;height:28px;font-size:var(--text-xs);" />
|
||||
<button class="btn btn-sm btn-primary pf-add-btn">Start Forward</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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.NexusTerminal.wailsService.ListPortForwards(sessionId)) || [];
|
||||
listEl.innerHTML = "";
|
||||
if (forwards.length === 0) {
|
||||
listEl.innerHTML =
|
||||
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-2);">No active port forwards</div>';
|
||||
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 = `
|
||||
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:${dirColor};width:16px;">${dirLabel}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);flex:1;">
|
||||
${escapeHtml(fwd.bindHost || "127.0.0.1")}:${fwd.bindPort} → ${escapeHtml(fwd.destHost || "localhost")}:${fwd.destPort}
|
||||
</span>
|
||||
${fwd.label ? `<span style="font-size:var(--text-xs);color:var(--text-tertiary);">${escapeHtml(fwd.label)}</span>` : ""}
|
||||
`;
|
||||
const stopBtn = createElement("button", "btn btn-xs btn-danger", {
|
||||
textContent: "Stop",
|
||||
});
|
||||
stopBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.NexusTerminal.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 =
|
||||
'<div style="color:var(--error);font-size:var(--text-sm);">Failed to load forwards</div>';
|
||||
}
|
||||
};
|
||||
|
||||
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.NexusTerminal.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}
|
||||
|
||||
+273
-190
@@ -11,226 +11,310 @@
|
||||
*/
|
||||
const THEMES = {
|
||||
charcoal: {
|
||||
label: 'Charcoal (Default)',
|
||||
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',
|
||||
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',
|
||||
"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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
"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',
|
||||
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',
|
||||
"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',
|
||||
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',
|
||||
"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',
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -242,7 +326,7 @@ const THEMES = {
|
||||
* @returns {Object} xterm.js theme object
|
||||
*/
|
||||
function getTerminalTheme(name) {
|
||||
const entry = THEMES[name || 'charcoal'];
|
||||
const entry = THEMES[name || "charcoal"];
|
||||
return entry ? entry.theme : THEMES.charcoal.theme;
|
||||
}
|
||||
|
||||
@@ -260,5 +344,4 @@ function listTerminalThemes() {
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.getTerminalTheme = getTerminalTheme;
|
||||
window.NexusTerminal.listTerminalThemes = listTerminalThemes;
|
||||
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — Trigger Rules Engine.
|
||||
* Monitors terminal output and fires actions (highlight, notify, sound)
|
||||
* when patterns match. Rules are stored in localStorage.
|
||||
*/
|
||||
|
||||
const { createElement } = window.NexusTerminal;
|
||||
|
||||
const STORAGE_KEY = "nexus: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 = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Trigger Rules</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-3)">
|
||||
Rules match against terminal output and fire actions when a pattern is detected.
|
||||
</p>
|
||||
<div class="trigger-toolbar">
|
||||
<button class="btn btn-sm btn-accent trigger-add-btn">Add Rule</button>
|
||||
</div>
|
||||
<div class="trigger-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 =
|
||||
'<div class="trigger-empty">No trigger rules defined</div>';
|
||||
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 = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name (optional)</label>
|
||||
<input type="text" class="form-input trigger-name-input" placeholder="e.g. Error alert" value="${existing ? existing.name || "" : ""}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Pattern</label>
|
||||
<input type="text" class="form-input trigger-pattern-input" placeholder="e.g. ERROR|FATAL|panic" value="${existing ? existing.pattern : ""}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Match Type</label>
|
||||
<select class="form-input trigger-type-select">
|
||||
<option value="text"${existing && !existing.isRegex ? " selected" : ""}>Plain text</option>
|
||||
<option value="regex"${existing && existing.isRegex ? " selected" : ""}>Regex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Action</label>
|
||||
<select class="form-input trigger-action-select">
|
||||
<option value="notify"${existing && existing.action === "notify" ? " selected" : ""}>Notify</option>
|
||||
<option value="highlight"${existing && existing.action === "highlight" ? " selected" : ""}>Highlight tab</option>
|
||||
<option value="both"${existing && existing.action === "both" ? " selected" : ""}>Notify + Highlight</option>
|
||||
<option value="sound"${existing && existing.action === "sound" ? " selected" : ""}>Sound</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<input type="checkbox" class="trigger-case-check"${existing && existing.caseSensitive ? " checked" : ""} />
|
||||
Case-sensitive
|
||||
</label>
|
||||
</div>
|
||||
<div class="trigger-form-actions">
|
||||
<button class="btn btn-sm trigger-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-sm btn-accent trigger-save-btn">${existing ? "Save" : "Add"}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.TriggerEngine = TriggerEngine;
|
||||
})();
|
||||
+158
-49
@@ -35,15 +35,15 @@ function createElement(tag, className, attrs = {}) {
|
||||
el.className = className;
|
||||
}
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'textContent') {
|
||||
if (key === "textContent") {
|
||||
el.textContent = value;
|
||||
} else if (key === 'innerHTML') {
|
||||
} else if (key === "innerHTML") {
|
||||
el.innerHTML = value;
|
||||
} else if (key === 'dataset') {
|
||||
} else if (key === "dataset") {
|
||||
for (const [dKey, dVal] of Object.entries(value)) {
|
||||
el.dataset[dKey] = dVal;
|
||||
}
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
} else if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else {
|
||||
el.setAttribute(key, value);
|
||||
@@ -52,19 +52,65 @@ function createElement(tag, className, attrs = {}) {
|
||||
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 '';
|
||||
if (!str) return "";
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return str.replace(/[&<>"']/g, (ch) => map[ch]);
|
||||
}
|
||||
@@ -75,21 +121,33 @@ function escapeHtml(str) {
|
||||
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
|
||||
*/
|
||||
function formatTimestamp(date) {
|
||||
if (!date) return '';
|
||||
if (!date) return "";
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
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');
|
||||
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'];
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`;
|
||||
}
|
||||
|
||||
@@ -151,6 +209,21 @@ 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
|
||||
@@ -174,8 +247,8 @@ function generateId() {
|
||||
* @returns {string} e.g. "1.23 KB"
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
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);
|
||||
@@ -189,15 +262,15 @@ function formatBytes(bytes) {
|
||||
* @returns {string}
|
||||
*/
|
||||
function truncate(str, len) {
|
||||
if (!str || str.length <= len) return str || '';
|
||||
return str.slice(0, 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());
|
||||
document.querySelectorAll(".context-menu").forEach((m) => m.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,11 +280,20 @@ function dismissContextMenus() {
|
||||
*/
|
||||
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';
|
||||
default: return '';
|
||||
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 "local":
|
||||
return "proto-local";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,24 +303,25 @@ function protocolColour(protocol) {
|
||||
* @returns {string} e.g. "3 hours ago", "2 days ago".
|
||||
*/
|
||||
function relativeTime(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
if (!isoDate) return "";
|
||||
const d = new Date(isoDate);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
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';
|
||||
if (secs < 60) return "just now";
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`;
|
||||
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`;
|
||||
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`;
|
||||
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`;
|
||||
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`;
|
||||
return years === 1 ? "1 year ago" : `${years} years ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,27 +331,52 @@ function relativeTime(isoDate) {
|
||||
*/
|
||||
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';
|
||||
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.NexusTerminal = window.NexusTerminal || {};
|
||||
Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, base64Decode, generateId, formatBytes, truncate, dismissContextMenus, protocolColour, stateLabel, relativeTime });
|
||||
Object.assign(window.NexusTerminal, {
|
||||
$,
|
||||
$$,
|
||||
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');
|
||||
el.setAttribute("autocapitalize", "none");
|
||||
el.setAttribute("autocorrect", "off");
|
||||
el.setAttribute("spellcheck", "false");
|
||||
}
|
||||
|
||||
// Apply to existing inputs
|
||||
document.querySelectorAll('input, textarea').forEach(disableAutoCapitalise);
|
||||
document.querySelectorAll("input, textarea").forEach(disableAutoCapitalise);
|
||||
|
||||
// Observe for dynamically added inputs
|
||||
var inputObserver = new MutationObserver(function (mutations) {
|
||||
@@ -277,15 +385,16 @@ var inputObserver = new MutationObserver(function(mutations) {
|
||||
for (var j = 0; j < nodes.length; j++) {
|
||||
var node = nodes[j];
|
||||
if (node.nodeType !== 1) continue;
|
||||
if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
|
||||
if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
|
||||
disableAutoCapitalise(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll('input, textarea').forEach(disableAutoCapitalise);
|
||||
node
|
||||
.querySelectorAll("input, textarea")
|
||||
.forEach(disableAutoCapitalise);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
inputObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — VNC viewer.
|
||||
* Renders a noVNC canvas inside a session panel, connecting to the local
|
||||
* WebSocket proxy started by the Go backend.
|
||||
*/
|
||||
|
||||
const { createElement, $ } = window.NexusTerminal;
|
||||
|
||||
class VNCViewer {
|
||||
constructor() {
|
||||
/** @type {Map<string, VNCViewerInstance>} 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._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:<port>)
|
||||
*/
|
||||
connect(wsURL) {
|
||||
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.rfb.addEventListener("connect", () => {
|
||||
this.statusEl.textContent = "Connected";
|
||||
this.statusEl.classList.remove("vnc-status-error");
|
||||
this.statusEl.classList.add("vnc-status-connected");
|
||||
});
|
||||
|
||||
this.rfb.addEventListener("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.rfb.addEventListener("credentialsrequired", () => {
|
||||
this.statusEl.textContent = "Credentials required";
|
||||
// TODO: prompt for VNC password via modal
|
||||
this._promptCredentials();
|
||||
});
|
||||
|
||||
this.rfb.addEventListener("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();
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
// Build a simple modal for VNC password
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">VNC Password</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-input" id="vnc-password-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="vnc-password-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="vnc-password-submit">Connect</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => {
|
||||
backdrop.remove();
|
||||
modal.remove();
|
||||
}, 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();
|
||||
});
|
||||
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (this.rfb) {
|
||||
this.rfb.disconnect();
|
||||
this.rfb = null;
|
||||
}
|
||||
this.container.innerHTML = "";
|
||||
this.container.classList.remove("vnc-viewer-panel");
|
||||
}
|
||||
}
|
||||
|
||||
// Export to global namespace.
|
||||
window.NexusTerminal.VNCViewer = new VNCViewer();
|
||||
})();
|
||||
@@ -0,0 +1,28 @@
|
||||
(function () {
|
||||
const root = (window.NexusTerminal = window.NexusTerminal || {});
|
||||
|
||||
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());
|
||||
})();
|
||||
Vendored
+55
@@ -1,16 +1,23 @@
|
||||
// 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<string>;
|
||||
|
||||
export function CancelReconnect(arg1:string):Promise<void>;
|
||||
|
||||
export function CheckForUpdates():Promise<Record<string, any>>;
|
||||
|
||||
export function CleanupRecordings(arg1:number,arg2:number):Promise<Record<string, any>>;
|
||||
|
||||
export function ClearSessionHistory(arg1:string):Promise<void>;
|
||||
|
||||
export function CloseAllSessions():Promise<void>;
|
||||
|
||||
export function ConnectLocal():Promise<models.SessionInfo>;
|
||||
|
||||
export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>;
|
||||
|
||||
export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
|
||||
@@ -27,6 +34,8 @@ export function DeleteProfileSecrets(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteRecording(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteSSHKey(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteSnippet(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteTOTPSecret(arg1:string):Promise<void>;
|
||||
@@ -45,20 +54,30 @@ export function FileExists(arg1:string):Promise<boolean>;
|
||||
|
||||
export function FileIsPrivateKey(arg1:string):Promise<boolean>;
|
||||
|
||||
export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
|
||||
|
||||
export function GetAppInfo():Promise<Record<string, string>>;
|
||||
|
||||
export function GetDefaultShell():Promise<string>;
|
||||
|
||||
export function GetPreferences():Promise<models.AppPreferences>;
|
||||
|
||||
export function GetProfile(arg1:string):Promise<models.Profile>;
|
||||
|
||||
export function GetRecording(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function GetRecordingStorageInfo():Promise<Record<string, any>>;
|
||||
|
||||
export function GetSSHPublicKey(arg1:string):Promise<string>;
|
||||
|
||||
export function GetSessionCwd(arg1:string):Promise<string>;
|
||||
|
||||
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function GetTOTPCode(arg1:string):Promise<string>;
|
||||
|
||||
export function GetVNCWebSocketURL(arg1:string):Promise<string>;
|
||||
|
||||
export function HasTOTPSecret(arg1:string):Promise<boolean>;
|
||||
|
||||
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
||||
@@ -75,6 +94,30 @@ export function IsSecretStoreInitialised():Promise<boolean>;
|
||||
|
||||
export function IsSecretStoreUnlocked():Promise<boolean>;
|
||||
|
||||
export function LeviathanExportJSON(arg1:string,arg2:Record<string, any>):Promise<string>;
|
||||
|
||||
export function LeviathanExportRange(arg1:string,arg2:number,arg3:number):Promise<string>;
|
||||
|
||||
export function LeviathanLoadFile(arg1:string,arg2:boolean):Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanOpenCompare():Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanOpenFile():Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanRedo():Promise<api.leviathanDocState>;
|
||||
|
||||
export function LeviathanRevert():Promise<api.leviathanDocState>;
|
||||
|
||||
export function LeviathanRunTool(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanSaveWorking():Promise<string>;
|
||||
|
||||
export function LeviathanSetByte(arg1:number,arg2:string):Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanState():Promise<api.leviathanDocState>;
|
||||
|
||||
export function LeviathanUndo():Promise<api.leviathanDocState>;
|
||||
|
||||
export function ListAuditEntries(arg1:number):Promise<Array<models.AuditEntry>>;
|
||||
|
||||
export function ListFavourites():Promise<Array<models.Profile>>;
|
||||
@@ -93,6 +136,8 @@ export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
|
||||
|
||||
export function ListRecordings():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function ListSSHKeyPairs():Promise<Array<api.SSHKeyInfo>>;
|
||||
|
||||
export function ListSSHKeys():Promise<Array<string>>;
|
||||
|
||||
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
||||
@@ -105,6 +150,8 @@ export function NewProfile(arg1:string):Promise<models.Profile>;
|
||||
|
||||
export function PinSession(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function PingSession(arg1:string):Promise<number>;
|
||||
|
||||
export function ReconnectSession(arg1:string):Promise<void>;
|
||||
|
||||
export function RefreshGitHubRepos(arg1:string):Promise<Array<models.GitHubRepo>>;
|
||||
@@ -131,8 +178,16 @@ export function SFTPStat(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||
|
||||
export function SFTPUpload(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadCancel(arg1:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadChunk(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadFinish(arg1:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise<string>;
|
||||
|
||||
export function SaveFile(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -10,6 +14,10 @@ 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);
|
||||
}
|
||||
@@ -18,6 +26,10 @@ 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);
|
||||
}
|
||||
@@ -50,6 +62,10 @@ 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);
|
||||
}
|
||||
@@ -86,10 +102,18 @@ 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']();
|
||||
}
|
||||
@@ -102,6 +126,14 @@ 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);
|
||||
}
|
||||
@@ -114,6 +146,10 @@ 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);
|
||||
}
|
||||
@@ -146,6 +182,54 @@ 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 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);
|
||||
}
|
||||
@@ -182,6 +266,10 @@ 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']();
|
||||
}
|
||||
@@ -206,6 +294,10 @@ 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);
|
||||
}
|
||||
@@ -258,10 +350,26 @@ 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 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);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,70 @@
|
||||
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 {
|
||||
@@ -13,6 +80,7 @@ export namespace models {
|
||||
defaultScrollback: number;
|
||||
defaultCursorStyle: string;
|
||||
useNativePaste: boolean;
|
||||
localShell: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AppPreferences(source);
|
||||
@@ -32,6 +100,7 @@ export namespace models {
|
||||
this.defaultScrollback = source["defaultScrollback"];
|
||||
this.defaultCursorStyle = source["defaultCursorStyle"];
|
||||
this.useNativePaste = source["useNativePaste"];
|
||||
this.localShell = source["localShell"];
|
||||
}
|
||||
}
|
||||
export class AuditEntry {
|
||||
@@ -222,6 +291,46 @@ export namespace models {
|
||||
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;
|
||||
@@ -410,6 +519,7 @@ export namespace models {
|
||||
telnet?: TelnetConfig;
|
||||
raw?: RAWConfig;
|
||||
github?: GitHubConfig;
|
||||
vnc?: VNCConfig;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Profile(source);
|
||||
@@ -442,6 +552,7 @@ export namespace models {
|
||||
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);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -523,5 +634,6 @@ export namespace models {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -3,44 +3,47 @@ module github.com/andydixon/nexus
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hajimehoshi/go-mp3 v0.3.4
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
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.5
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/danieljoos/wincred v1.2.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // 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.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // 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.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // 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.22 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
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/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
|
||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||
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 h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
|
||||
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
|
||||
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/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.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
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/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/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
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/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.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
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=
|
||||
@@ -40,13 +46,12 @@ github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQc
|
||||
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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
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=
|
||||
@@ -60,47 +65,48 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
|
||||
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.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
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.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
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.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
|
||||
github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+553
-3
@@ -8,8 +8,10 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -23,6 +25,7 @@ import (
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/exportimport"
|
||||
"github.com/andydixon/nexus/internal/localshell"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/preferences"
|
||||
"github.com/andydixon/nexus/internal/secrets"
|
||||
@@ -30,6 +33,7 @@ import (
|
||||
"github.com/andydixon/nexus/internal/sshconfig"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
"github.com/andydixon/nexus/internal/updater"
|
||||
"github.com/andydixon/nexus/internal/validation"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -39,6 +43,14 @@ 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)
|
||||
maxSSHPublicKeyBytes = 64 * 1024
|
||||
)
|
||||
|
||||
// Service is the main API surface exposed to the frontend via Wails bindings.
|
||||
type Service struct {
|
||||
store *storage.Store
|
||||
@@ -52,6 +64,19 @@ type Service struct {
|
||||
// secretChans maps request IDs to channels awaiting user secret input.
|
||||
secretMu sync.Mutex
|
||||
secretChans map[string]chan secretResponse
|
||||
|
||||
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.
|
||||
@@ -70,6 +95,8 @@ func NewService(
|
||||
sessions: sess,
|
||||
auditLog: auditLog,
|
||||
prefs: prefs,
|
||||
uploads: make(map[string]*sftpUpload),
|
||||
leviathan: &leviathanState{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +115,7 @@ 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
|
||||
@@ -96,6 +124,9 @@ func (s *Service) CreateProfile(profile models.Profile) (models.Profile, error)
|
||||
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)
|
||||
}
|
||||
@@ -114,7 +145,11 @@ func (s *Service) GetProfile(id string) (models.Profile, error) {
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -368,6 +403,24 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
|
||||
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
|
||||
}
|
||||
|
||||
// DisconnectSession closes a session by ID.
|
||||
func (s *Service) DisconnectSession(sessionID string) error {
|
||||
return s.sessions.Disconnect(sessionID)
|
||||
@@ -660,6 +713,9 @@ func (s *Service) ExportProfiles(includeSecrets bool) (string, error) {
|
||||
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 {
|
||||
@@ -676,11 +732,21 @@ func (s *Service) ExportProfiles(includeSecrets bool) (string, error) {
|
||||
// - "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 {
|
||||
@@ -912,6 +978,39 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
|
||||
return path, 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -931,6 +1030,11 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1003,6 +1107,39 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1093,6 +1230,17 @@ func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int
|
||||
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 {
|
||||
@@ -1241,21 +1389,132 @@ func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName st
|
||||
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 := filepath.Base(fileName)
|
||||
if safeName == "" || safeName == "." || safeName == "/" {
|
||||
return fmt.Errorf("invalid file name")
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
@@ -1452,3 +1711,294 @@ func derefProfiles(ptrs []*models.Profile) []models.Profile {
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if keyType == "" {
|
||||
keyType = "ed25519"
|
||||
}
|
||||
|
||||
// Build ssh-keygen arguments
|
||||
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
|
||||
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
|
||||
cmd := exec.Command("ssh-keygen", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
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 == "/" || strings.ContainsRune(safeName, 0) {
|
||||
return "", fmt.Errorf("invalid file name")
|
||||
}
|
||||
return safeName, nil
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/leviathan/document"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
)
|
||||
|
||||
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 TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
|
||||
got, err := safeSFTPUploadName(`../report <final>.txt`)
|
||||
if err != nil {
|
||||
t.Fatalf("safeSFTPUploadName() error = %v", err)
|
||||
}
|
||||
if got != "report <final>.txt" {
|
||||
t.Fatalf("safe name = %q, want HTML characters preserved in base name", got)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,8 +110,6 @@ func (a *App) Startup(ctx context.Context) {
|
||||
// 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)
|
||||
|
||||
log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir)
|
||||
}
|
||||
|
||||
// Shutdown is called by Wails when the application is closing. It ensures all
|
||||
@@ -134,8 +132,6 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
if a.SecretStore != nil {
|
||||
a.SecretStore.Lock()
|
||||
}
|
||||
|
||||
log.Println("Nexus Terminal shut down cleanly")
|
||||
}
|
||||
|
||||
// DomReady is called by Wails once the frontend DOM is fully loaded. It emits
|
||||
@@ -144,7 +140,6 @@ func (a *App) DomReady(ctx context.Context) {
|
||||
runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{
|
||||
"message": "Backend is ready",
|
||||
})
|
||||
log.Println("DOM ready – backend:ready event emitted")
|
||||
}
|
||||
|
||||
// EmitOpenAbout tells the frontend to open the About tab.
|
||||
|
||||
@@ -33,6 +33,8 @@ const (
|
||||
|
||||
saltLength = 32
|
||||
nonceLength = 12 // AES-GCM standard nonce size
|
||||
|
||||
maxEncryptedImportBytes = 10 * 1024 * 1024
|
||||
)
|
||||
|
||||
// ExportBundle is the cleartext payload that gets encrypted.
|
||||
@@ -131,6 +133,14 @@ func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
|
||||
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)
|
||||
|
||||
@@ -129,3 +129,24 @@ func TestDecryptCorruptFile(t *testing.T) {
|
||||
t.Fatal("expected error for corrupt file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsOversizedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "huge.nexus")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ import (
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
maxGitHubJSONBodyBytes = 4 << 20
|
||||
maxGitHubErrorBodyBytes = 64 << 10
|
||||
)
|
||||
|
||||
// Client wraps the GitHub REST API for repository listing.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
@@ -90,7 +95,7 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
|
||||
return nil, fmt.Errorf("fetching repos page %d: %w", page, err)
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
body, readErr := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", readErr)
|
||||
@@ -208,8 +213,7 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
|
||||
return "", "", fmt.Errorf("authentication failed (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
@@ -225,7 +229,11 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
|
||||
} `json:"commit"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -270,8 +278,7 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
|
||||
return fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
|
||||
}
|
||||
|
||||
file, err := os.Create(destPath)
|
||||
@@ -310,8 +317,31 @@ func (c *Client) ValidateToken(ctx context.Context) (string, error) {
|
||||
var user struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
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 "<response body unavailable: " + err.Error() + ">"
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package githubclient
|
||||
|
||||
import (
|
||||
"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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
|
||||
"github.com/andydixon/nexus/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
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/andydixon/nexus/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
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
|
||||
"github.com/andydixon/nexus/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
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/nexus/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
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"hash/crc32"
|
||||
|
||||
"github.com/andydixon/nexus/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) }
|
||||
@@ -0,0 +1,162 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"github.com/andydixon/nexus/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
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/nexus/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:
|
||||
// "<algo> <base64> [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))
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/nexus/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 <query>")
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"github.com/andydixon/nexus/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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/nexus/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)
|
||||
// - "<count> 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"):
|
||||
// "<n> 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
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
|
||||
"github.com/andydixon/nexus/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
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/andydixon/nexus/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)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/nexus/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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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, 0755); 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], 0644)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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:])
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package binsnoop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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, 0755); err != nil {
|
||||
return fmt.Errorf("carve: mkdir: %w", err)
|
||||
}
|
||||
return os.WriteFile(outputPath, data[offset:offset+size], 0644)
|
||||
}
|
||||
|
||||
// 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, 0755); 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, 0755); 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], 0644); err != nil {
|
||||
return &ExtractionResult{OutputDirectory: outputDir}
|
||||
}
|
||||
|
||||
result := executeExternal(actualExt, carvedFile, outputDir)
|
||||
result.OutputDirectory = outputDir
|
||||
result.ExtractorName = actualExt.Command
|
||||
return result
|
||||
|
||||
default:
|
||||
return &ExtractionResult{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
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}}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user