diff --git a/Makefile b/Makefile index f5f2e0d..09b3ef2 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/build-all.sh b/build-all.sh index 6d05e47..7770ab9 100755 --- a/build-all.sh +++ b/build-all.sh @@ -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' @@ -29,6 +30,7 @@ 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}" @@ -91,6 +93,16 @@ wails_cmd() { 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 @@ -132,6 +144,7 @@ check_deps() { 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; } resolve_wails + ensure_upx log "Go: $(go version)" log "Node: $(node --version)" @@ -197,12 +210,31 @@ build_target() { local host="$3" 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" = "linux" ] && ! linux_deps_ok; then @@ -222,17 +254,9 @@ build_target() { log "Building ${platform}…" if [ -n "$cc" ]; then - if [ -n "$tags" ]; then - CC="$cc" CGO_ENABLED=1 wails_cmd build -platform "$platform" -tags "$tags" -ldflags "$ldflags" - else - CC="$cc" CGO_ENABLED=1 wails_cmd build -platform "$platform" -ldflags "$ldflags" - fi + CC="$cc" CGO_ENABLED=1 wails_cmd build "${build_args[@]}" else - if [ -n "$tags" ]; then - CGO_ENABLED=1 wails_cmd build -platform "$platform" -tags "$tags" -ldflags "$ldflags" - else - CGO_ENABLED=1 wails_cmd build -platform "$platform" -ldflags "$ldflags" - fi + CGO_ENABLED=1 wails_cmd build "${build_args[@]}" fi ((BUILT_TARGETS += 1)) } diff --git a/build.ps1 b/build.ps1 index 5363e06..323fbe2 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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\' } diff --git a/build.sh b/build.sh index fd2c7e3..d40079b 100755 --- a/build.sh +++ b/build.sh @@ -29,6 +29,7 @@ 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}" @@ -91,6 +92,20 @@ wails_cmd() { 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 @@ -122,6 +137,14 @@ build_tags() { nexus_build_tags } +host_os() { + case "$(uname -s)" in + Darwin) echo "darwin" ;; + Linux) echo "linux" ;; + *) echo "unknown" ;; + esac +} + build_version() { nexus_build_version } @@ -137,6 +160,7 @@ check_deps() { command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; } check_linux_deps resolve_wails + ensure_upx log "All prerequisites found" log "Go: $(go version)" @@ -176,11 +200,24 @@ build() { version="$(build_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 -tags "$tags" -ldflags "$(build_ldflags "${version}")" + wails_cmd build "${build_args[@]}" -tags "$tags" -ldflags "$(build_ldflags "${version}")" else - wails_cmd build -ldflags "$(build_ldflags "${version}")" + wails_cmd build "${build_args[@]}" -ldflags "$(build_ldflags "${version}")" fi # Replace Wails-generated icon with custom app icon on macOS. diff --git a/frontend/index.html b/frontend/index.html index b8ec7fb..01aee08 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -318,6 +318,9 @@ + + + diff --git a/frontend/scripts/build.js b/frontend/scripts/build.js index 5911418..709c322 100644 --- a/frontend/scripts/build.js +++ b/frontend/scripts/build.js @@ -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.'); diff --git a/frontend/scripts/novnc-entry.js b/frontend/scripts/novnc-entry.js index 09ccb69..1958a13 100644 --- a/frontend/scripts/novnc-entry.js +++ b/frontend/scripts/novnc-entry.js @@ -1,4 +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 }; diff --git a/frontend/scripts/test-regressions.js b/frontend/scripts/test-regressions.js index 9e7994a..26c5864 100644 --- a/frontend/scripts/test-regressions.js +++ b/frontend/scripts/test-regressions.js @@ -23,6 +23,27 @@ function assertNotContains(file, needle, message) { } } +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)", @@ -163,5 +184,105 @@ assertContains( "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/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"); diff --git a/frontend/src/css/leviathan.css b/frontend/src/css/leviathan.css index e6ac328..494d0d2 100644 --- a/frontend/src/css/leviathan.css +++ b/frontend/src/css/leviathan.css @@ -5,25 +5,61 @@ 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-top: var(--space-2); - color: var(--text-tertiary); + 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: 28px minmax(0, 1fr); + 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); - font-size: var(--text-xs); + background: rgba(255, 167, 38, 0.08); + font-family: var(--font-mono); + font-size: 10px; font-weight: var(--weight-semibold); - text-align: center; +} + +.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 { @@ -35,38 +71,68 @@ flex-direction: column; min-width: 0; overflow: hidden; - background: var(--bg-base); + background: #181818; } .leviathan-panel-header { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: var(--space-4); - padding: var(--space-4); + 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; - font-size: var(--text-xl); + overflow: hidden; color: var(--text-primary); -} - -.leviathan-title-wrap p { - margin: var(--space-1) 0 0; - color: var(--text-secondary); - font-size: var(--text-sm); + 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, @@ -77,29 +143,136 @@ 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-4); + 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: repeat(auto-fit, minmax(220px, 1fr)); - gap: var(--space-3); - margin-bottom: var(--space-3); + 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 textarea { - min-height: 120px; +.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 { @@ -107,64 +280,188 @@ height: 18px; min-height: 18px; padding: 0; + accent-color: var(--accent); } .leviathan-file-picker { - display: flex; + display: grid; + grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-2); - margin-bottom: var(--space-3); min-width: 0; + padding-top: var(--space-2); + border-top: 1px solid var(--border-subtle); } .leviathan-file-path { min-width: 0; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; 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(160px, 1fr)); - gap: var(--space-2); - margin: var(--space-3) 0; + 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 { - border: 1px solid var(--border-subtle); - background: var(--bg-surface); - border-radius: var(--radius-md); - padding: var(--space-2); 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: var(--text-xs); + 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 { - border: 1px solid var(--border-subtle); - background: #05070c; - border-radius: var(--radius-md); + min-height: 100%; overflow: auto; - padding: var(--space-2); + padding: var(--space-3); + background: #05070c; } .leviathan-spectrogram-image { @@ -176,94 +473,272 @@ .leviathan-empty, .leviathan-json { - border: 1px solid var(--border-subtle); - background: var(--bg-surface); - border-radius: var(--radius-md); + min-height: 100%; color: var(--text-secondary); - padding: var(--space-4); + 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: collapse; + 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); + 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-bottom: 1px solid var(--border-subtle); - padding: var(--space-2) 0; + 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; - gap: 2px; - margin-top: var(--space-3); + align-content: start; + gap: 0; + min-height: 100%; + padding: var(--space-1); + overflow: auto; + background: #101010; font-family: var(--font-mono); - font-size: var(--text-xs); + font-size: 10px; } .leviathan-hex-row { display: grid; - grid-template-columns: 92px minmax(320px, 1fr) minmax(120px, 220px); + grid-template-columns: 82px max-content max-content; align-items: center; - gap: var(--space-3); + 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(16, 28px); - gap: 3px; + grid-template-columns: repeat(var(--leviathan-hex-width), 22px); + gap: 2px; } .leviathan-byte { - width: 28px; - min-height: 24px; + 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: var(--text-xs); + 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-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; } -@media (max-width: 900px) { +.leviathan-modal-fields { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: var(--space-3); +} + +@media (max-width: 1120px) { + .leviathan-workbench { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + } + + .leviathan-control-plane { + max-height: 260px; + } + + .leviathan-params { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } +} + +@media (max-width: 720px) { .leviathan-panel-header { + align-items: stretch; flex-direction: column; } + .leviathan-header-actions { + justify-content: flex-start; + } + .leviathan-hex-row { grid-template-columns: 1fr; gap: var(--space-1); + padding: var(--space-2); + } + + .leviathan-byte-cells { + grid-template-columns: repeat(var(--leviathan-hex-width), 22px); } } diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index fca03a2..bebff0a 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -73,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} * @private */ @@ -85,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); @@ -98,7 +101,9 @@ resolve(); } }; - check(); + Promise.resolve(window.NexusTerminal?.wailsServiceReady) + .catch(() => null) + .finally(check); }); } @@ -109,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) { @@ -122,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) { @@ -137,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); @@ -218,7 +223,7 @@ if (entry) { const { cols, rows } = entry.term; try { - window.go.api.Service.ResizeSession( + window.NexusTerminal.wailsService.ResizeSession( payload.sessionID, cols, rows, @@ -316,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 @@ -380,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. } @@ -816,7 +819,7 @@ try { const sessionInfo = - await window.go.api.Service.ConnectSession(profileId); + await window.NexusTerminal.wailsService.ConnectSession(profileId); this.state.addSession(sessionInfo); this.tabs.addTab(sessionInfo); @@ -859,7 +862,7 @@ */ async connectLocal() { try { - const sessionInfo = await window.go.api.Service.ConnectLocal(); + const sessionInfo = await window.NexusTerminal.wailsService.ConnectLocal(); this.state.addSession(sessionInfo); this.tabs.addTab(sessionInfo); @@ -986,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); @@ -1229,9 +1232,9 @@ this._latencySessionID = sessionID; const doPing = async () => { - if (!window.go || !window.go.api || !window.go.api.Service) return; + if (!window.NexusTerminal?.wailsService) return; try { - const ms = await window.go.api.Service.PingSession(sessionID); + const ms = await window.NexusTerminal.wailsService.PingSession(sessionID); const el = document.getElementById("status-latency"); if (el && this._latencySessionID === sessionID) { el.textContent = ms + " ms"; @@ -1266,12 +1269,12 @@ } 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( diff --git a/frontend/src/js/commands.js b/frontend/src/js/commands.js index 9b328e6..3eb0407 100644 --- a/frontend/src/js/commands.js +++ b/frontend/src/js/commands.js @@ -172,7 +172,7 @@ 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"); } catch (err) { @@ -189,7 +189,7 @@ 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"); } catch (err) { diff --git a/frontend/src/js/github.js b/frontend/src/js/github.js index 59072b0..af0878d 100644 --- a/frontend/src/js/github.js +++ b/frontend/src/js/github.js @@ -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"); } diff --git a/frontend/src/js/leviathan.js b/frontend/src/js/leviathan.js index 7932581..92bbfe1 100644 --- a/frontend/src/js/leviathan.js +++ b/frontend/src/js/leviathan.js @@ -58,7 +58,6 @@ "patch-plan": { source: "set 0x00 90 90" }, "patch-apply": { source: "set 0x00 90 90" }, transform: { pipeline: "hex.encode", start: 0, length: 1024 }, - binsnoop: { maxBytes: 67108864 }, "spectrogram-view": { fftSize: 2048, height: 1024, maxWidth: 1400, aspectRatio: 1, minHz: 0, maxHz: 0, brightness: 1, contrast: 1 }, hex: { start: 0, bytes: 2048, width: 16 }, }; @@ -68,6 +67,8 @@ this.app = app; this.state = null; this.lastResults = new Map(); + this.autoRunners = new Map(); + this.hexSearches = new Map(); } renderSidebar() { @@ -115,7 +116,7 @@ async loadFile() { try { - const payload = await window.go.api.Service.LeviathanOpenFile(); + const payload = await window.NexusTerminal.wailsService.LeviathanOpenFile(); this.applyState(payload); this.refreshOpenHexTabs(); } catch (err) { @@ -126,9 +127,9 @@ async saveFile() { try { - const path = await window.go.api.Service.LeviathanSaveWorking(); + const path = await window.NexusTerminal.wailsService.LeviathanSaveWorking(); if (path) this.app.notifications.success("Saved working copy"); - const state = await window.go.api.Service.LeviathanState(); + const state = await window.NexusTerminal.wailsService.LeviathanState(); this.applyState(state); } catch (err) { this.app.notifications.error("Save failed: " + err); @@ -153,6 +154,7 @@ state: "connected", tabName: tool.name, toolId: tool.id, + leviathanParams: options.params || null, }; this.app.state.addSession(sessionInfo); this.app.tabs.addTab(sessionInfo); @@ -170,9 +172,11 @@ const header = createElement("div", "leviathan-panel-header"); const titleWrap = createElement("div", "leviathan-title-wrap"); - titleWrap.appendChild(createElement("span", "leviathan-eyebrow", { textContent: this._stateLabel() })); - titleWrap.appendChild(createElement("h2", "", { textContent: tool.name })); - titleWrap.appendChild(createElement("p", "", { textContent: tool.desc })); + titleWrap.appendChild(createElement("span", "leviathan-tool-mark", { textContent: tool.ico })); + const heading = createElement("div", "leviathan-heading"); + heading.appendChild(createElement("h2", "", { textContent: tool.name })); + heading.appendChild(createElement("span", "leviathan-eyebrow", { textContent: this._stateLabel() })); + titleWrap.appendChild(heading); header.appendChild(titleWrap); const headerActions = createElement("div", "leviathan-header-actions"); @@ -193,6 +197,8 @@ if (tool.id === "hex") { this.renderHexEditor(body, sessionId); + } else if (tool.id === "search") { + this.renderSearchTool(body, sessionId); } else if (tool.id === "spectrogram-encode") { this.renderSpectrogramEncode(body, sessionId); } else if (tool.id === "spectrogram-view") { @@ -203,37 +209,113 @@ } renderHexEditor(body, sessionId) { + const { controls, output } = this._createWorkbench(body, "leviathan-hex-workbench", { topBar: true }); const toolbar = createElement("div", "leviathan-tool-toolbar"); - const start = this._input("start", "0"); - const bytes = this._input("bytes", "2048"); - const refresh = createElement("button", "btn btn-sm btn-primary", { textContent: "Refresh" }); - toolbar.append(start.row, bytes.row, refresh); - body.appendChild(toolbar); + const initial = this._sessionParams(sessionId); + const start = this._input("start", String(initial.start ?? 0)); + const bytes = this._input("bytes", String(initial.bytes ?? initial.length ?? 2048)); + const width = this._selectInput("row", [ + ["16", "16 bytes"], + ["32", "32 bytes"], + ], String(initial.width || 16)); + const search = createElement("button", "btn btn-sm", { type: "button", textContent: "Search" }); + const prev = createElement("button", "btn btn-sm", { type: "button", textContent: "Prev" }); + const next = createElement("button", "btn btn-sm", { type: "button", textContent: "Next" }); + toolbar.append(start.row, bytes.row, width.row, search, prev, next); const status = createElement("div", "leviathan-status", { textContent: this._stateLabel() }); const grid = createElement("div", "leviathan-hex-grid"); - body.append(status, grid); + controls.append(toolbar, status); + output.appendChild(grid); const load = async () => { try { status.textContent = "Loading hex..."; - const result = await window.go.api.Service.LeviathanRunTool("hex", { + const result = await window.NexusTerminal.wailsService.LeviathanRunTool("hex", { start: Number(start.input.value || 0), bytes: Number(bytes.input.value || 2048), - width: 16, + width: Number(width.input.value || 16), }); this.lastResults.set(sessionId, result); - this._renderHexRows(grid, result.rows || [], status); + this._renderHexRows(grid, result.rows || [], status, Number(width.input.value || 16), this._currentHexSearchHit(sessionId)); status.textContent = this._stateLabel(); } catch (err) { status.textContent = String(err); } }; - refresh.addEventListener("click", load); + [start.input, bytes.input, width.input].forEach((input) => { + input.addEventListener("change", load); + }); + search.addEventListener("click", () => this._showHexSearchModal(sessionId, start.input, width.input, load)); + prev.addEventListener("click", () => this._gotoHexSearchHit(sessionId, -1, start.input, width.input, load)); + next.addEventListener("click", () => this._gotoHexSearchHit(sessionId, 1, start.input, width.input, load)); load(); } + renderSearchTool(body, sessionId) { + const { controls, output } = this._createWorkbench(body, "leviathan-compact-workbench", { topBar: true }); + const params = createElement("div", "leviathan-params"); + const mode = this._selectInput("mode", [ + ["text", "String"], + ["hex", "Hex"], + ["regex", "Regex"], + ], "text"); + const query = this._input("query", "MZ"); + const start = this._input("start", "0"); + const length = this._input("length", "-1"); + const max = this._input("max", "1000"); + params.append(mode.row, query.row, start.row, length.row, max.row); + + const toolbar = createElement("div", "leviathan-tool-toolbar"); + const search = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Search" }); + const exportBtn = createElement("button", "btn btn-sm", { type: "button", textContent: "Export JSON" }); + toolbar.append(search, exportBtn); + controls.append(params, toolbar); + + const summary = createElement("div", "leviathan-summary"); + const results = createElement("div", "leviathan-results"); + output.append(summary, results); + results.appendChild(createElement("div", "leviathan-empty", { textContent: "Search results will appear here." })); + + const runSearch = async () => { + try { + results.innerHTML = ""; + results.appendChild(createElement("div", "leviathan-empty", { textContent: "Searching..." })); + const payload = { + mode: mode.input.value, + query: query.input.value, + start: Number(start.input.value || 0), + length: Number(length.input.value || -1), + max: Number(max.input.value || 1000), + }; + const out = await window.NexusTerminal.wailsService.LeviathanRunTool("search", payload); + this.lastResults.set(sessionId, out); + this._renderResult(summary, results, out, "search"); + } catch (err) { + results.innerHTML = ""; + this._renderToolError(results, "search", err); + } + }; + search.addEventListener("click", runSearch); + query.input.addEventListener("keydown", (event) => { + if (event.key === "Enter") runSearch(); + }); + exportBtn.addEventListener("click", async () => { + const out = this.lastResults.get(sessionId); + if (!out) return; + try { + await window.NexusTerminal.wailsService.LeviathanExportJSON("search.json", out); + this.app.notifications.success("Exported JSON"); + } catch (err) { + this.app.notifications.error("Export failed: " + err); + } + }); + this.autoRunners.set(sessionId, runSearch); + requestAnimationFrame(runSearch); + } + renderSpectrogramEncode(body, sessionId) { + const { controls, output } = this._createWorkbench(body); const params = createElement("div", "leviathan-params"); const fields = [ this._paramInput(params, "durationSec", "Duration (s)", 5, { type: "number", min: "0.25", max: "60", step: "0.25" }), @@ -248,18 +330,16 @@ this._paramInput(params, "preserveAspectRatio", "Preserve aspect", true, { type: "checkbox" }), this._paramInput(params, "invert", "Invert image", true, { type: "checkbox" }), ]; - body.appendChild(params); const imagePicker = createElement("div", "leviathan-file-picker"); const chooseImage = createElement("button", "btn btn-sm", { type: "button", textContent: "Select Image" }); const imagePath = createElement("span", "leviathan-file-path", { textContent: "No insertion image selected" }); imagePicker.append(chooseImage, imagePath); - body.appendChild(imagePicker); let selectedImagePath = ""; chooseImage.addEventListener("click", async () => { try { - const path = await window.go.api.Service.SelectFile("Select Image to Insert", ""); + const path = await window.NexusTerminal.wailsService.SelectFile("Select Image to Insert", ""); if (!path) return; selectedImagePath = path; imagePath.textContent = path; @@ -272,11 +352,11 @@ const encode = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Encode Loaded Image" }); const insert = createElement("button", "btn btn-sm", { type: "button", textContent: "Insert Into Loaded Audio" }); toolbar.append(encode, insert); - body.appendChild(toolbar); + controls.append(params, imagePicker, toolbar); const summary = createElement("div", "leviathan-summary"); const results = createElement("div", "leviathan-results"); - body.append(summary, results); + output.append(summary, results); results.appendChild(createElement("div", "leviathan-empty", { textContent: "Load an image to encode, or load WAV/MP3 audio and select an image to insert." })); const run = async (toolId) => { @@ -289,7 +369,7 @@ if (!selectedImagePath) throw new Error("Select an image to insert first."); payload.imagePath = selectedImagePath; } - const out = await window.go.api.Service.LeviathanRunTool(toolId, payload); + const out = await window.NexusTerminal.wailsService.LeviathanRunTool(toolId, payload); this.lastResults.set(sessionId, out); summary.innerHTML = ""; results.innerHTML = ""; @@ -298,7 +378,7 @@ if (out?.path) this.app.notifications.success("Saved " + out.path); } catch (err) { results.innerHTML = ""; - results.appendChild(createElement("pre", "leviathan-json", { textContent: String(err) })); + this._renderToolError(results, toolId, err); } }; encode.addEventListener("click", () => run("spectrogram-encode")); @@ -306,6 +386,7 @@ } renderSpectrogramView(body, sessionId) { + const { controls, output } = this._createWorkbench(body); const params = createElement("div", "leviathan-params"); const fields = [ this._paramInput(params, "fftSize", "FFT size", 2048, { type: "number", min: "256", max: "8192", step: "256" }), @@ -317,26 +398,25 @@ this._paramInput(params, "brightness", "Brightness", 1, { type: "number", min: "0.1", max: "4", step: "0.1" }), this._paramInput(params, "contrast", "Contrast", 1, { type: "number", min: "0.1", max: "4", step: "0.1" }), ]; - body.appendChild(params); const toolbar = createElement("div", "leviathan-tool-toolbar"); const run = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Render Spectrogram" }); const exportBtn = createElement("button", "btn btn-sm", { type: "button", textContent: "Export JSON" }); toolbar.append(run, exportBtn); - body.appendChild(toolbar); + controls.append(params, toolbar); const summary = createElement("div", "leviathan-summary"); const results = createElement("div", "leviathan-results"); - body.append(summary, results); + output.append(summary, results); results.appendChild(createElement("div", "leviathan-empty", { textContent: "Load a WAV or MP3 file, then render its spectrogram." })); - run.addEventListener("click", async () => { + const render = async () => { try { results.innerHTML = ""; results.appendChild(createElement("div", "leviathan-empty", { textContent: "Rendering spectrogram..." })); const payload = {}; for (const field of fields) payload[field.key] = this._fieldValue(field); - const out = await window.go.api.Service.LeviathanRunTool("spectrogram-view", payload); + const out = await window.NexusTerminal.wailsService.LeviathanRunTool("spectrogram-view", payload); this.lastResults.set(sessionId, out); summary.innerHTML = ""; results.innerHTML = ""; @@ -354,38 +434,53 @@ } } catch (err) { results.innerHTML = ""; - results.appendChild(createElement("pre", "leviathan-json", { textContent: String(err) })); + this._renderToolError(results, "spectrogram-view", err); } - }); + }; + run.addEventListener("click", render); exportBtn.addEventListener("click", async () => { const out = this.lastResults.get(sessionId); if (!out) return; try { - await window.go.api.Service.LeviathanExportJSON("spectrogram-view.json", out); + await window.NexusTerminal.wailsService.LeviathanExportJSON("spectrogram-view.json", out); this.app.notifications.success("Exported JSON"); } catch (err) { this.app.notifications.error("Export failed: " + err); } }); + this.autoRunners.set(sessionId, render); + requestAnimationFrame(render); } renderToolRunner(body, sessionId, tool) { const toolbar = createElement("div", "leviathan-tool-toolbar"); const params = createElement("div", "leviathan-params"); const fields = this._renderParams(tool.id, params); - const run = createElement("button", "btn btn-sm btn-primary", { textContent: "Run" }); + const autoRun = this._shouldAutoRunTool(tool.id, fields); + const topBar = fields.length === 0 || this._shouldUseTopBar(fields); + const { controls, output } = this._createWorkbench( + body, + topBar ? "leviathan-compact-workbench" : "", + { topBar }, + ); + const run = createElement("button", "btn btn-sm btn-primary", { textContent: this._toolActionLabel(tool.id) }); const exportBtn = createElement("button", "btn btn-sm", { textContent: "Export JSON" }); - toolbar.append(run, exportBtn); - body.append(params, toolbar); + if (!autoRun || fields.length) toolbar.append(run); + toolbar.append(exportBtn); + if (fields.length) controls.append(params); + controls.append(toolbar); const summary = createElement("div", "leviathan-summary"); const results = createElement("div", "leviathan-results"); - body.append(summary, results); + output.append(summary, results); - run.addEventListener("click", async () => { + let running = false; + const runTool = async () => { + if (running) return; + running = true; try { if (tool.id === "diff" && !this.state?.compare) { - const compare = await window.go.api.Service.LeviathanOpenCompare(); + const compare = await window.NexusTerminal.wailsService.LeviathanOpenCompare(); this.applyState(compare); } results.innerHTML = ""; @@ -394,30 +489,49 @@ for (const field of fields) { payload[field.key] = this._fieldValue(field); } - const out = await window.go.api.Service.LeviathanRunTool(tool.id, payload); + const out = await window.NexusTerminal.wailsService.LeviathanRunTool(tool.id, payload); this.lastResults.set(sessionId, out); - this._renderResult(summary, results, out); + this._renderResult(summary, results, out, tool.id); if (out && out.state) this.applyState(out.state); } catch (err) { results.innerHTML = ""; - results.appendChild(createElement("pre", "leviathan-json", { textContent: String(err) })); + this._renderToolError(results, tool.id, err); + } finally { + running = false; } - }); + }; + if (!autoRun || fields.length) run.addEventListener("click", runTool); exportBtn.addEventListener("click", async () => { const out = this.lastResults.get(sessionId); if (!out) return; try { - await window.go.api.Service.LeviathanExportJSON(`${tool.id}.json`, out); + await window.NexusTerminal.wailsService.LeviathanExportJSON(`${tool.id}.json`, out); this.app.notifications.success("Exported JSON"); } catch (err) { this.app.notifications.error("Export failed: " + err); } }); - results.appendChild( - createElement("div", "leviathan-empty", { - textContent: "Run the tool to populate this tab.", - }), - ); + if (autoRun) { + this.autoRunners.set(sessionId, runTool); + requestAnimationFrame(runTool); + } else { + this.autoRunners.delete(sessionId); + results.appendChild( + createElement("div", "leviathan-empty", { + textContent: "Ready.", + }), + ); + } + } + + _createWorkbench(body, extraClass = "", options = {}) { + body.classList.add("leviathan-workbench"); + body.classList.toggle("leviathan-top-workbench", Boolean(options.topBar)); + if (extraClass) body.classList.add(extraClass); + const controls = createElement("section", "leviathan-control-plane"); + const output = createElement("section", "leviathan-output-plane"); + body.append(controls, output); + return { controls, output }; } applyState(payload) { @@ -427,6 +541,15 @@ destroyPanel(sessionId) { this.lastResults.delete(sessionId); + this.autoRunners.delete(sessionId); + } + + handleTabFocus(sessionId) { + const session = this.app.state.getSession(sessionId); + if (session?.protocol !== "leviathan") return; + this._highlightActiveTool(session.toolId); + const runner = this.autoRunners.get(sessionId); + if (runner) runner(); } refreshOpenHexTabs() { @@ -437,8 +560,9 @@ } } - _renderHexRows(grid, rows, status) { + _renderHexRows(grid, rows, status, rowWidth = 16, activeHit = null) { grid.innerHTML = ""; + grid.style.setProperty("--leviathan-hex-width", String(rowWidth)); if (!rows.length) { grid.appendChild(createElement("div", "leviathan-empty", { textContent: "Load a file to inspect bytes." })); return; @@ -449,16 +573,20 @@ const bytes = createElement("span", "leviathan-byte-cells"); const cells = row.bytes || String(row.hex || "").split(/\s+/).filter(Boolean); cells.forEach((value, idx) => { + const cellOffset = (row.offset || 0) + idx; const cell = createElement("input", "leviathan-byte", { value, maxlength: "2", - "aria-label": `Byte ${this._hexOffset((row.offset || 0) + idx)}`, + "aria-label": `Byte ${this._hexOffset(cellOffset)}`, }); + if (activeHit && cellOffset >= activeHit.offset && cellOffset < activeHit.offset + activeHit.length) { + cell.classList.add("is-search-hit"); + } cell.addEventListener("change", async () => { try { - await window.go.api.Service.LeviathanSetByte((row.offset || 0) + idx, cell.value); + await window.NexusTerminal.wailsService.LeviathanSetByte(cellOffset, cell.value); cell.value = cell.value.trim().padStart(2, "0").slice(-2).toLowerCase(); - status.textContent = "Edited " + this._hexOffset((row.offset || 0) + idx); + status.textContent = "Edited " + this._hexOffset(cellOffset); } catch (err) { status.textContent = String(err); cell.value = value; @@ -500,10 +628,54 @@ }); } - _renderResult(summary, results, out) { + _shouldUseTopBar(fields) { + return fields.length <= 3 && !fields.some((field) => field.input.tagName === "TEXTAREA"); + } + + _shouldAutoRunTool(toolId, fields) { + const autoRunTools = new Set([ + "overview", + "hashes", + "entropy", + "strings", + "iocs", + "search", + "multi-search", + "parse", + "disasm", + "pdf", + "squashfs", + "carve", + "keys", + "timestamps", + "stego", + "metadata", + "binsnoop", + "rules", + "patch-plan", + "transform", + "spectrogram-view", + ]); + return autoRunTools.has(toolId); + } + + _toolActionLabel(toolId) { + if (toolId === "case") return "Export Case"; + if (toolId === "diff") return this.state?.compare ? "Compare" : "Load Compare"; + return "Run"; + } + + _renderResult(summary, results, out, toolId = "") { summary.innerHTML = ""; results.innerHTML = ""; this._renderSummaryPills(summary, this._flatSummary(out)); + if (toolId === "overview") return this._renderOverview(results, out); + if (toolId === "hashes") return this._renderHashes(results, out); + if (toolId === "entropy") return this._renderEntropy(summary, results, out); + if (toolId === "search") return this._renderSearchResults(summary, results, out); + if (toolId === "strings") return this._renderStrings(results, out.items || []); + if (toolId === "binsnoop") return this._renderBinsnoop(summary, results, out); + if (toolId === "carve") return this._renderCarve(summary, results, out); if (out?.tree) { this._renderTree(results, out.tree); return; @@ -516,15 +688,186 @@ results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) })); } - _renderTable(container, rows) { - rows = rows.slice(0, 1000); + _renderOverview(container, out) { + const dashboard = createElement("div", "leviathan-dashboard"); + dashboard.appendChild(this._metricCard("File", out?.name || this.state?.name || "No file")); + dashboard.appendChild(this._metricCard("Size", formatBytes(out?.size || 0))); + dashboard.appendChild(this._metricCard("Entropy mean", this._formatNumber(out?.entropy?.mean, 2))); + dashboard.appendChild(this._metricCard("Strings", out?.strings?.total ?? 0)); + dashboard.appendChild(this._metricCard("Carve hits", out?.carve?.total ?? 0)); + dashboard.appendChild(this._metricCard("Valid carve", out?.carve?.valid ?? 0)); + container.appendChild(dashboard); + + const sections = createElement("div", "leviathan-sections"); + sections.appendChild(this._kvSection("Hashes", out?.hashes || {}, ["crc32", "md5", "sha1", "sha256", "sha512"])); + sections.appendChild(this._kvSection("IOC totals", out?.iocs || {}, Object.keys(out?.iocs || {}).sort())); + sections.appendChild(this._kvSection("Scan limits", out?.limits || {}, Object.keys(out?.limits || {}), true)); + container.appendChild(sections); + } + + _renderHashes(container, out) { + const rows = ["crc32", "md5", "sha1", "sha256", "sha512"] + .filter((key) => out?.[key]) + .map((key) => ({ algorithm: key.toUpperCase(), digest: out[key] })); + this._renderTable(container, rows, ["algorithm", "digest"]); + } + + _renderEntropy(summary, container, out) { + summary.innerHTML = ""; + this._renderSummaryPills(summary, { + samples: Array.isArray(out?.samples) ? out.samples.length : 0, + mean: this._formatNumber(out?.mean, 3), + min: this._formatNumber(out?.min, 3), + max: this._formatNumber(out?.max, 3), + high: out?.highCount ?? 0, + }); + + const frame = createElement("div", "leviathan-entropy-frame"); + const canvas = createElement("canvas", "leviathan-entropy-canvas", { + width: "1200", + height: "260", + }); + frame.appendChild(canvas); + container.appendChild(frame); + + const samples = (out?.samples || []).map((v) => Number(v) || 0); + const offsets = (out?.offsets || []).map((v) => Number(v) || 0); + const step = Number(out?.step || out?.windowSize || 4096); + let dragStart = null; + let dragEnd = null; + + const pointerIndex = (event) => { + const rect = canvas.getBoundingClientRect(); + const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / Math.max(rect.width, 1))); + return Math.min(samples.length - 1, Math.max(0, Math.round(ratio * (samples.length - 1)))); + }; + const draw = () => this._drawEntropyCanvas(canvas, samples, dragStart, dragEnd); + + canvas.addEventListener("mousedown", (event) => { + if (!samples.length) return; + dragStart = pointerIndex(event); + dragEnd = dragStart; + draw(); + }); + canvas.addEventListener("mousemove", (event) => { + if (dragStart == null) return; + dragEnd = pointerIndex(event); + draw(); + }); + canvas.addEventListener("mouseup", () => { + if (dragStart == null || dragEnd == null || !offsets.length) { + dragStart = null; + dragEnd = null; + draw(); + return; + } + const lo = Math.min(dragStart, dragEnd); + const hi = Math.max(dragStart, dragEnd); + const start = offsets[lo] || 0; + const end = (offsets[hi] || start) + step; + dragStart = null; + dragEnd = null; + draw(); + if (end > start) this.openHexRange(start, end - start); + }); + canvas.addEventListener("mouseleave", () => { + if (dragStart == null) return; + dragStart = null; + dragEnd = null; + draw(); + }); + requestAnimationFrame(draw); + } + + _renderSearchResults(summary, container, out) { + const hits = out?.hits || []; + summary.innerHTML = ""; + this._renderSummaryPills(summary, { hits: hits.length }); + this._renderTable(container, hits, ["offset", "length", "sample"]); + } + + _renderStrings(container, rows) { + this._renderTable(container, rows, ["offset", "length", "encoding", "value"]); + } + + _renderCarve(summary, container, out) { + const rows = out?.items || []; + summary.innerHTML = ""; + this._renderSummaryPills(summary, { + hits: rows.length, + exportable: rows.filter((row) => this._rowRange(row).length > 0).length, + }); + this._renderTable(container, rows, ["offset", "length", "kind", "extension", "confidence", "status"], { + exportRows: true, + exportName: (row) => this._foundFileName(row, "carve"), + }); + } + + _renderBinsnoop(summary, container, out) { + const results = out?.results || {}; + const rows = results.file_map || results.fileMap || []; + summary.innerHTML = ""; + this._renderSummaryPills(summary, { + hits: rows.length, + scanned: formatBytes(results.scanned_bytes || results.document_bytes || this.state?.size || 0), + mode: results.whole_file ? "whole file" : "range", + window: results.windowed ? formatBytes(results.window_bytes || 0) : "full", + }); + this._renderTable(container, rows, ["offset", "size", "name", "description", "confidence"], { + exportRows: true, + exportName: (row) => this._foundFileName(row, "binsnoop"), + }); + } + + _drawEntropyCanvas(canvas, samples, dragStart, dragEnd) { + const ctx = canvas.getContext("2d"); + const width = canvas.width; + const height = canvas.height; + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = "#101010"; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.lineWidth = 1; + for (let i = 1; i < 8; i++) { + const y = height - (i / 8) * height; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + if (dragStart != null && dragEnd != null) { + const lo = Math.min(dragStart, dragEnd); + const hi = Math.max(dragStart, dragEnd); + const x1 = (lo / Math.max(samples.length - 1, 1)) * width; + const x2 = (hi / Math.max(samples.length - 1, 1)) * width; + ctx.fillStyle = "rgba(78, 205, 196, 0.18)"; + ctx.fillRect(x1, 0, Math.max(2, x2 - x1), height); + } + if (!samples.length) return; + ctx.strokeStyle = "#4ecdc4"; + ctx.lineWidth = 2; + ctx.beginPath(); + samples.forEach((sample, index) => { + const x = (index / Math.max(samples.length - 1, 1)) * width; + const y = height - (Math.max(0, Math.min(8, sample)) / 8) * height; + if (index === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + } + + _renderTable(container, rows, preferredColumns = null, options = {}) { + const sourceRows = rows || []; + const maxRows = options.maxRows || (options.exportRows ? sourceRows.length : 1000); + rows = sourceRows.slice(0, maxRows); if (!rows.length) { container.appendChild(createElement("div", "leviathan-empty", { textContent: "No rows." })); return; } - const preferred = ["offset", "length", "kind", "name", "category", "value", "sample", "summary", "iso", "type"]; + const preferred = preferredColumns || ["offset", "length", "size", "kind", "name", "category", "value", "sample", "summary", "description", "iso", "type"]; const all = Array.from(new Set(rows.flatMap((row) => Object.keys(row || {})))); const cols = [...preferred.filter((c) => all.includes(c)), ...all.filter((c) => !preferred.includes(c))].slice(0, 8); + if (options.exportRows) cols.push("export"); const table = createElement("table", "leviathan-table"); const thead = createElement("thead"); const headRow = createElement("tr"); @@ -533,11 +876,104 @@ const tbody = createElement("tbody"); rows.forEach((row) => { const tr = createElement("tr"); - cols.forEach((c) => tr.appendChild(createElement("td", "", { textContent: this._formatCell(row[c]) }))); + cols.forEach((c) => { + const td = createElement("td"); + if (c === "export") { + const range = this._rowRange(row); + if (range.length > 0) { + const btn = createElement("button", "btn btn-xs leviathan-export-found", { + type: "button", + textContent: "Export", + }); + btn.addEventListener("click", () => this._exportFoundRange(row, options.exportName)); + td.appendChild(btn); + } else { + td.appendChild(createElement("span", "leviathan-muted", { textContent: "No length" })); + } + } else if (c === "offset" && row[c] != null) { + const offset = Number(row[c]); + const length = Number(row.length || row.size || 256); + const btn = createElement("button", "leviathan-offset-link", { + type: "button", + textContent: this._hexOffset(offset), + }); + btn.addEventListener("click", () => this.openHexRange(offset, Math.max(256, length || 256))); + td.appendChild(btn); + } else { + td.textContent = this._formatCell(row[c]); + } + tr.appendChild(td); + }); tbody.appendChild(tr); }); table.append(thead, tbody); container.appendChild(table); + if (sourceRows.length > rows.length) { + container.appendChild(createElement("div", "leviathan-table-note", { + textContent: `Showing ${rows.length} of ${sourceRows.length} rows.`, + })); + } + } + + _rowRange(row) { + const offset = Number(row?.offset); + if (!Number.isFinite(offset) || offset < 0) return { offset: 0, length: 0 }; + let length = Number(row.length ?? row.size ?? 0); + if ((!Number.isFinite(length) || length <= 0) && Number.isFinite(Number(row.end))) { + length = Number(row.end) - offset; + } + if ((!Number.isFinite(length) || length <= 0) && this.state?.size > offset) { + length = Number(this.state.size) - offset; + } + return { + offset, + length: Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0, + }; + } + + _foundFileName(row, prefix) { + const range = this._rowRange(row); + const label = String(row.kind || row.name || row.id || prefix || "found") + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "found"; + const ext = String(row.extension || row.ext || "bin") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "") + .slice(0, 12) || "bin"; + return `${prefix || "found"}-${label}-${this._hexOffset(range.offset)}.${ext}`; + } + + async _exportFoundRange(row, nameBuilder) { + const range = this._rowRange(row); + if (range.length <= 0) { + this.app.notifications.error("Export failed: finding has no readable range"); + return; + } + const name = typeof nameBuilder === "function" ? nameBuilder(row) : this._foundFileName(row, "found"); + try { + const path = await window.NexusTerminal.wailsService.LeviathanExportRange(name, range.offset, range.length); + if (path) this.app.notifications.success("Exported " + path); + } catch (err) { + this.app.notifications.error("Export failed: " + err); + } + } + + _renderToolError(container, toolId, err) { + const message = String(err || "The tool could not complete."); + const isSquashMagic = toolId === "squashfs" && /magic|superblock|squash/i.test(message); + const state = createElement("div", isSquashMagic ? "leviathan-error-state leviathan-error-state-warning" : "leviathan-error-state"); + const icon = createElement("div", "leviathan-error-icon", { "aria-hidden": "true" }); + icon.appendChild(createElement("span", "", { textContent: "!" })); + const title = isSquashMagic ? "Not a SquashFS image" : "Tool could not run"; + const body = isSquashMagic + ? "The loaded file does not start with a valid SquashFS superblock. Try another file, or inspect this one in the Hex Editor." + : message; + state.appendChild(icon); + state.appendChild(createElement("strong", "", { textContent: title })); + state.appendChild(createElement("p", "", { textContent: body })); + container.appendChild(state); } _renderTree(container, root) { @@ -556,7 +992,7 @@ } _stateAction(method, sessionId) { - return window.go.api.Service[method]() + return window.NexusTerminal.wailsService[method]() .then((state) => { this.applyState(state); const session = this.app.state.getSession(sessionId); @@ -576,6 +1012,16 @@ return { row, input }; } + _selectInput(label, options, value) { + const row = createElement("label", "leviathan-param-inline"); + row.appendChild(createElement("span", "", { textContent: label })); + const input = createElement("select", "form-input"); + options.forEach(([optionValue, text]) => input.add(new Option(text, optionValue))); + input.value = value; + row.appendChild(input); + return { row, input }; + } + _paramInput(container, key, label, value, attrs = {}) { const row = createElement("label", "leviathan-param"); row.appendChild(createElement("span", "", { textContent: label })); @@ -606,6 +1052,148 @@ : raw; } + openHexRange(start, length = 2048) { + const safeStart = Math.max(0, Math.floor(Number(start) || 0)); + const safeLength = Math.max(256, Math.min(8192, Math.floor(Number(length) || 2048))); + this.openTool("hex", { + params: { start: safeStart, bytes: safeLength, width: 16 }, + }); + } + + _sessionParams(sessionId) { + return this.app.state.getSession(sessionId)?.leviathanParams || {}; + } + + _currentHexSearchHit(sessionId) { + const state = this.hexSearches.get(sessionId); + if (!state || state.index < 0 || !state.hits?.length) return null; + const hit = state.hits[state.index]; + return { + offset: Number(hit.offset || 0), + length: Number(hit.length || 1), + }; + } + + _gotoHexSearchHit(sessionId, direction, startInput, widthInput, load) { + const state = this.hexSearches.get(sessionId); + if (!state || !state.hits?.length) { + this._showHexSearchModal(sessionId, startInput, widthInput, load); + return; + } + state.index = (state.index + direction + state.hits.length) % state.hits.length; + const hit = state.hits[state.index]; + const rowWidth = Number(widthInput.value || 16); + const offset = Number(hit.offset || 0); + startInput.value = String(Math.max(0, offset - (offset % rowWidth))); + load(); + } + + _showHexSearchModal(sessionId, startInput, widthInput, load) { + const backdrop = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-sm leviathan-search-modal"); + const header = createElement("div", "modal-header"); + header.appendChild(createElement("h3", "modal-title", { textContent: "Hex Search" })); + const closeBtn = createElement("button", "btn btn-icon btn-xs modal-close", { + type: "button", + textContent: "x", + }); + header.appendChild(closeBtn); + + const body = createElement("div", "modal-body"); + const fields = createElement("div", "leviathan-modal-fields"); + const mode = this._selectInput("mode", [ + ["text", "String"], + ["hex", "Hex"], + ["regex", "Regex"], + ], "text"); + const query = this._input("query", ""); + fields.append(mode.row, query.row); + const status = createElement("div", "leviathan-status", { textContent: "" }); + body.append(fields, status); + + const footer = createElement("div", "modal-footer"); + const cancel = createElement("button", "btn btn-sm", { type: "button", textContent: "Cancel" }); + const run = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Search" }); + footer.append(cancel, run); + modal.append(header, body, footer); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + const close = () => { + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(() => backdrop.remove(), 200); + }; + const search = async () => { + try { + status.textContent = "Searching..."; + const out = await window.NexusTerminal.wailsService.LeviathanRunTool("search", { + mode: mode.input.value, + query: query.input.value, + start: 0, + length: -1, + max: 5000, + }); + const hits = (out.hits || []).map((hit) => ({ + offset: Number(hit.offset || 0), + length: Number(hit.length || 1), + sample: hit.sample || "", + })); + this.hexSearches.set(sessionId, { + mode: mode.input.value, + query: query.input.value, + hits, + index: -1, + }); + close(); + if (hits.length) this._gotoHexSearchHit(sessionId, 1, startInput, widthInput, load); + } catch (err) { + status.textContent = String(err); + } + }; + + closeBtn.addEventListener("click", close); + cancel.addEventListener("click", close); + run.addEventListener("click", search); + query.input.addEventListener("keydown", (event) => { + if (event.key === "Enter") search(); + }); + requestAnimationFrame(() => { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); + query.input.focus(); + }); + } + + _metricCard(label, value) { + const item = createElement("div", "leviathan-metric"); + item.appendChild(createElement("span", "", { textContent: label })); + item.appendChild(createElement("strong", "", { textContent: this._formatCell(value) })); + return item; + } + + _kvSection(title, values, keys, bytes = false) { + const section = createElement("section", "leviathan-section"); + section.appendChild(createElement("h3", "", { textContent: title })); + const list = createElement("div", "leviathan-kv-list"); + keys.forEach((key) => { + if (values?.[key] == null) return; + const row = createElement("div", "leviathan-kv-row"); + row.appendChild(createElement("span", "", { textContent: key })); + row.appendChild(createElement("strong", "", { + textContent: bytes ? formatBytes(Number(values[key]) || 0) : this._formatCell(values[key]), + })); + list.appendChild(row); + }); + section.appendChild(list); + return section; + } + + _formatNumber(value, digits = 2) { + const n = Number(value); + return Number.isFinite(n) ? n.toFixed(digits) : "0"; + } + _renderSummaryPills(container, values) { Object.entries(values || {}) .slice(0, 10) diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index d30e512..9fe40a1 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -437,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; @@ -1215,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"; @@ -1254,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( @@ -1265,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", @@ -1295,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)"; @@ -1319,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)"; } @@ -1763,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; }) @@ -1831,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); @@ -1908,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."; @@ -1922,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"; @@ -2328,11 +2328,11 @@ 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"); } @@ -2353,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"); @@ -2377,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`, ); diff --git a/frontend/src/js/raw.js b/frontend/src/js/raw.js index a31571e..ca99db8 100644 --- a/frontend/src/js/raw.js +++ b/frontend/src/js/raw.js @@ -214,7 +214,7 @@ return `[${ts}] [${dir}] ${e.data}`; }); const content = lines.join("\n"); - await window.go.api.Service.SaveSessionCapture(sessionId, content); + await window.NexusTerminal.wailsService.SaveSessionCapture(sessionId, content); this.app.notifications.success("Capture saved"); } catch (err) { this.app.notifications.error("Save failed: " + err); @@ -257,7 +257,7 @@ format === "hex" || format === "escaped" ? btoa(dataToSend) : window.NexusTerminal.utf8ToBase64(dataToSend); - await window.go.api.Service.WriteSession(sessionId, b64); + await window.NexusTerminal.wailsService.WriteSession(sessionId, b64); input.value = ""; // Also show in viewer as sent data diff --git a/frontend/src/js/recordings.js b/frontend/src/js/recordings.js index a87720c..3d3789f 100644 --- a/frontend/src/js/recordings.js +++ b/frontend/src/js/recordings.js @@ -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'); @@ -71,8 +71,8 @@ class RecordingManager { async openBrowser() { try { const [recordings, storageInfo] = await Promise.all([ - window.go.api.Service.ListRecordings(), - window.go.api.Service.GetRecordingStorageInfo(), + window.NexusTerminal.wailsService.ListRecordings(), + window.NexusTerminal.wailsService.GetRecordingStorageInfo(), ]); this._showBrowserModal(recordings || [], storageInfo || {}); } catch (err) { @@ -86,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); @@ -99,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); @@ -225,11 +225,11 @@ class RecordingManager { status.textContent = ''; btn.disabled = true; try { - const result = await window.go.api.Service.CleanupRecordings(ageDays, maxBytes); + const result = await window.NexusTerminal.wailsService.CleanupRecordings(ageDays, maxBytes); status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`; - const fresh = await window.go.api.Service.ListRecordings(); + const fresh = await window.NexusTerminal.wailsService.ListRecordings(); close(); - this._showBrowserModal(fresh || [], await window.go.api.Service.GetRecordingStorageInfo()); + this._showBrowserModal(fresh || [], await window.NexusTerminal.wailsService.GetRecordingStorageInfo()); } catch (err) { status.textContent = 'Cleanup failed: ' + err; } finally { @@ -556,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) { @@ -628,7 +628,7 @@ class ExportImportUI { try { confirmBtn.disabled = true; confirmBtn.textContent = 'Importing...'; - const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy); + const count = await window.NexusTerminal.wailsService.ImportProfilesEncrypted(password, strategy); this.app.notifications.success(`Imported ${count} profile(s)`); await this.app.loadProfiles(); close(); diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js index e89c649..4f3effb 100644 --- a/frontend/src/js/settings.js +++ b/frontend/src/js/settings.js @@ -48,13 +48,13 @@ 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.go.api.Service.GetDefaultShell(); + this._defaultShell = await window.NexusTerminal.wailsService.GetDefaultShell(); } catch (_) { this._defaultShell = ""; } @@ -88,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. @@ -312,7 +312,7 @@ }); browseBtn.addEventListener("click", async () => { try { - const path = await window.go.api.Service.BrowseForShell(); + const path = await window.NexusTerminal.wailsService.BrowseForShell(); if (path) { shellInput.value = path; } @@ -429,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"); @@ -448,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); @@ -465,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); @@ -522,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"); @@ -911,8 +911,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}`; @@ -931,7 +931,7 @@ 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); @@ -979,7 +979,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", ); diff --git a/frontend/src/js/sftp.js b/frontend/src/js/sftp.js index 80be945..4be4f3e 100644 --- a/frontend/src/js/sftp.js +++ b/frontend/src/js/sftp.js @@ -281,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; @@ -603,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( @@ -616,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) { @@ -633,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) { @@ -646,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) { @@ -656,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", ); @@ -671,7 +671,7 @@ var failed = 0; for (var i = 0; i < files.length; i++) { try { - await window.go.api.Service.SFTPDownload(sessionId, files[i].path); + await window.NexusTerminal.wailsService.SFTPDownload(sessionId, files[i].path); } catch (err) { failed++; this.app.notifications.error( @@ -705,7 +705,7 @@ var failed = 0; for (var i = 0; i < entries.length; i++) { try { - await window.go.api.Service.SFTPRemove(sessionId, entries[i].path); + await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entries[i].path); } catch (err) { failed++; this.app.notifications.error( diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js index 44076fb..c66d7a1 100644 --- a/frontend/src/js/sidebar.js +++ b/frontend/src/js/sidebar.js @@ -441,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(); @@ -457,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}"`); @@ -478,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"); @@ -687,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); @@ -708,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); diff --git a/frontend/src/js/snippets.js b/frontend/src/js/snippets.js index 837c9c1..6cd8c4e 100644 --- a/frontend/src/js/snippets.js +++ b/frontend/src/js/snippets.js @@ -92,7 +92,7 @@ _sendCommand(sessionId, command) { try { const b64 = window.NexusTerminal.utf8ToBase64(command + "\n"); - window.go.api.Service.WriteSession(sessionId, b64); + window.NexusTerminal.wailsService.WriteSession(sessionId, b64); this.app.notifications.info("Snippet sent"); } catch (err) { this.app.notifications.error("Failed to send snippet: " + err); @@ -171,7 +171,7 @@ /** @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); @@ -334,7 +334,7 @@ }); 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 = @@ -418,10 +418,10 @@ 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; } diff --git a/frontend/src/js/splitpane.js b/frontend/src/js/splitpane.js index 00cfe45..1824b1c 100644 --- a/frontend/src/js/splitpane.js +++ b/frontend/src/js/splitpane.js @@ -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 diff --git a/frontend/src/js/sshkeys.js b/frontend/src/js/sshkeys.js index 695bc13..bbda627 100644 --- a/frontend/src/js/sshkeys.js +++ b/frontend/src/js/sshkeys.js @@ -81,7 +81,7 @@ list.innerHTML = '
Loading keys…
'; try { - const keys = await window.go.api.Service.ListSSHKeyPairs(); + const keys = await window.NexusTerminal.wailsService.ListSSHKeyPairs(); if (keys.length === 0) { list.innerHTML = '
No SSH keys found in ~/.ssh/
'; @@ -133,7 +133,7 @@ async _copyPublicKey(key) { try { - const pubKey = await window.go.api.Service.GetSSHPublicKey(key.name); + const pubKey = await window.NexusTerminal.wailsService.GetSSHPublicKey(key.name); if (window.runtime && window.runtime.ClipboardSetText) { window.runtime.ClipboardSetText(pubKey); } else { @@ -158,7 +158,7 @@ async _deleteKey(key) { try { - await window.go.api.Service.DeleteSSHKey(key.name); + await window.NexusTerminal.wailsService.DeleteSSHKey(key.name); this.app.notifications.success(`Key "${key.name}" deleted`); this._loadKeys(); } catch (err) { @@ -237,7 +237,7 @@ } try { - await window.go.api.Service.GenerateSSHKey( + await window.NexusTerminal.wailsService.GenerateSSHKey( name, keyType, bits, diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js index e27bb3f..5f83324 100644 --- a/frontend/src/js/tabs.js +++ b/frontend/src/js/tabs.js @@ -119,6 +119,7 @@ if (session) { this.app.sidebar.highlightActive(session.profileId); } + this.app.leviathan?.handleTabFocus?.(sessionId); // Fit and refresh the terminal for the newly active session. // Double-RAF ensures the panel has layout dimensions before fitting. @@ -169,7 +170,7 @@ } try { - await window.go.api.Service.DisconnectSession(sessionId); + await window.NexusTerminal.wailsService.DisconnectSession(sessionId); } catch (_) { // Session may already be closed — carry on } diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index 2cad1eb..f4b04c1 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -155,7 +155,7 @@ const data = seq.repeat(lines); const b64 = utf8ToBase64(data); try { - window.go.api.Service.WriteSession(sessionId, b64); + window.NexusTerminal.wailsService.WriteSession(sessionId, b64); } catch (_) { /* non-critical */ } @@ -251,7 +251,7 @@ term.onData((data) => { try { const b64 = utf8ToBase64(data); - window.go.api.Service.WriteSession(sessionId, b64); + window.NexusTerminal.wailsService.WriteSession(sessionId, b64); // Broadcast to other connected sessions if enabled if (this.app.state.broadcastMode) { @@ -263,7 +263,7 @@ 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 */ } @@ -331,7 +331,7 @@ // 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); } @@ -895,7 +895,7 @@ const totalBytes = typeof file.size === "number" ? file.size : 0; let uploadId = ""; try { - uploadId = await window.go.api.Service.SFTPUploadStart( + uploadId = await window.NexusTerminal.wailsService.SFTPUploadStart( sessionId, remoteDir, file.name, @@ -906,14 +906,14 @@ const end = Math.min(offset + UPLOAD_CHUNK_BYTES, totalBytes); const buffer = await this._readFileSlice(file, offset, end); const base64Data = this._arrayBufferToBase64(buffer); - await window.go.api.Service.SFTPUploadChunk(uploadId, base64Data); + await window.NexusTerminal.wailsService.SFTPUploadChunk(uploadId, base64Data); } - await window.go.api.Service.SFTPUploadFinish(uploadId); + await window.NexusTerminal.wailsService.SFTPUploadFinish(uploadId); } catch (err) { if (uploadId) { try { - await window.go.api.Service.SFTPUploadCancel(uploadId); + await window.NexusTerminal.wailsService.SFTPUploadCancel(uploadId); } catch (_) { /* noop */ } @@ -1167,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, ); @@ -1446,7 +1446,7 @@ const renderForwards = async () => { try { const forwards = - (await window.go.api.Service.ListPortForwards(sessionId)) || []; + (await window.NexusTerminal.wailsService.ListPortForwards(sessionId)) || []; listEl.innerHTML = ""; if (forwards.length === 0) { listEl.innerHTML = @@ -1472,7 +1472,7 @@ }); stopBtn.addEventListener("click", async () => { try { - await window.go.api.Service.StopPortForward(sessionId, fwd.id); + await window.NexusTerminal.wailsService.StopPortForward(sessionId, fwd.id); self.app.notifications.info("Forward stopped"); renderForwards(); } catch (err) { @@ -1517,7 +1517,7 @@ } try { - await window.go.api.Service.StartPortForward(sessionId, { + await window.NexusTerminal.wailsService.StartPortForward(sessionId, { id: "", direction: direction, bindHost: bindHost, diff --git a/frontend/src/js/wails-service.js b/frontend/src/js/wails-service.js new file mode 100644 index 0000000..a44b215 --- /dev/null +++ b/frontend/src/js/wails-service.js @@ -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()); +})(); diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index ff71dce..a80bed2 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -96,6 +96,8 @@ export function IsSecretStoreUnlocked():Promise; export function LeviathanExportJSON(arg1:string,arg2:Record):Promise; +export function LeviathanExportRange(arg1:string,arg2:number,arg3:number):Promise; + export function LeviathanLoadFile(arg1:string,arg2:boolean):Promise>; export function LeviathanOpenCompare():Promise>; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index e09b58e..336718d 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -186,6 +186,10 @@ 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); } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index d95c4eb..d2ed1c3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -200,6 +200,24 @@ func TestWriteLeviathanDocumentFileWritesPatchedBytes(t *testing.T) { } } +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 { diff --git a/internal/api/leviathan.go b/internal/api/leviathan.go index e1e7ba8..0facc2a 100644 --- a/internal/api/leviathan.go +++ b/internal/api/leviathan.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -236,6 +237,44 @@ func (s *Service) LeviathanExportJSON(name string, value map[string]any) (string return path, os.WriteFile(path, b, 0600) } +// LeviathanExportRange prompts for a destination and streams a document range to disk. +func (s *Service) LeviathanExportRange(name string, start, length int64) (string, error) { + engine := s.leviathanEngine() + engine.mu.Lock() + if engine.doc == nil { + engine.mu.Unlock() + return "", errors.New("open a file first") + } + doc := engine.doc + engine.mu.Unlock() + + if s.ctx == nil { + return "", fmt.Errorf("application context not set") + } + if start < 0 { + return "", errors.New("export start must be non-negative") + } + if length <= 0 { + return "", errors.New("this finding does not have a known exportable length") + } + size := doc.Size() + if start >= size { + return "", errors.New("export start is outside the document") + } + if length > size-start { + length = size - start + } + defaultName := fmt.Sprintf("leviathan-%08x.bin", uint64(start)) + path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ + Title: "Export Found File", + DefaultFilename: safeExportName(name, defaultName), + }) + if err != nil || path == "" { + return "", err + } + return path, writeLeviathanDocumentRangeFile(path, doc, start, length) +} + func (l *leviathanState) stateLocked() leviathanDocState { state := leviathanDocState{} if l.doc != nil { @@ -323,12 +362,8 @@ func (l *leviathanState) runToolLocked(tool string, params map[string]any) (map[ entries, err := fs.Walk() return map[string]any{"superblock": fs.Superblock(), "compression": fs.CompressionName(), "entries": entries}, err case "binsnoop": - buf, err := l.rangeBytes(0, min64(l.doc.Size(), int64Param(params, "maxBytes", 64<<20))) - if err != nil { - return nil, err - } - scanner := binsnoop.New() - return map[string]any{"results": scanner.AnalyzeBytes(buf, l.doc.Name(), false), "signatures": binsnoop.ListSignatures()}, nil + results, err := leviathanBinsnoopDocument(l.doc, intParam(params, "max", 10000)) + return map[string]any{"results": results, "signatures": binsnoop.ListSignatures()}, err case "rules": rules, err := analysis.ParseRules(stringParam(params, "source", "")) if err != nil { @@ -565,12 +600,16 @@ func (s *Service) leviathanSpectrogramInsert(doc *document.Document, params map[ } func writeLeviathanDocumentFile(path string, doc *document.Document) error { + return writeLeviathanDocumentRangeFile(path, doc, 0, doc.Size()) +} + +func writeLeviathanDocumentRangeFile(path string, doc *document.Document, start, length int64) error { f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { return err } defer f.Close() - if err := doc.ForEachChunk(0, doc.Size(), func(_ int64, chunk []byte) error { + if err := doc.ForEachChunk(start, length, func(_ int64, chunk []byte) error { _, err := f.Write(chunk) return err }); err != nil { @@ -856,6 +895,78 @@ func boolParam(m map[string]any, key string, def bool) bool { return def } +func leviathanBinsnoopDocument(d *document.Document, maxResults int) (map[string]any, error) { + if maxResults <= 0 { + maxResults = 10000 + } + const windowSize int64 = 32 << 20 + const overlapSize int64 = 1 << 20 + + scanner := binsnoop.New() + size := d.Size() + seen := make(map[string]bool) + fileMap := make([]binsnoop.SignatureResult, 0, 128) + truncated := false + + maxInt := int64(^uint(0) >> 1) + for off := int64(0); off < size; { + readLen := windowSize + overlapSize + if off+readLen > size { + readLen = size - off + } + buf, err := d.Range(off, readLen) + if err != nil { + return nil, err + } + for _, hit := range scanner.Scan(buf) { + abs := off + int64(hit.Offset) + if abs < 0 || abs > maxInt || abs >= size { + continue + } + if hit.Size < 0 || int64(hit.Size) > size-abs { + continue + } + hit.Offset = int(abs) + key := fmt.Sprintf("%d:%s", hit.Offset, hit.Name) + if seen[key] { + continue + } + seen[key] = true + fileMap = append(fileMap, hit) + if len(fileMap) >= maxResults { + truncated = true + break + } + } + if truncated || off+windowSize >= size { + break + } + off += windowSize + } + + sort.Slice(fileMap, func(i, j int) bool { + if fileMap[i].Offset == fileMap[j].Offset { + return fileMap[i].Confidence > fileMap[j].Confidence + } + return fileMap[i].Offset < fileMap[j].Offset + }) + + return map[string]any{ + "file_path": d.Name(), + "file_map": fileMap, + "scanned_bytes": size, + "window_bytes": windowSize, + "overlap_bytes": overlapSize, + "whole_file": true, + "windowed": true, + "truncated": truncated, + "max_results": maxResults, + "extractions": map[string]any{}, + "result_count": len(fileMap), + "document_bytes": size, + }, nil +} + func leviathanSpectrogramEncodeOptions(params map[string]any) spectrogram.EncodeOptions { return spectrogram.EncodeOptions{ DurationSec: floatParam(params, "durationSec", 5), diff --git a/internal/app/app.go b/internal/app/app.go index 507399c..7f136c4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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. diff --git a/release.sh b/release.sh index 52b52dc..a70e702 100755 --- a/release.sh +++ b/release.sh @@ -65,6 +65,7 @@ release_targets() { check_deps() { command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; } + export PATH="$PATH:$(go env GOPATH)/bin" command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; } command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; } command -v zip >/dev/null 2>&1 || { err "zip is not installed"; exit 1; } @@ -83,6 +84,8 @@ check_deps() { export PATH="$PATH:$(go env GOPATH)/bin" fi fi + + ensure_upx } install_deps() { @@ -105,6 +108,30 @@ build_ldflags() { nexus_build_ldflags "$1" } +ensure_upx() { + if ! nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then + return + fi + local requires_upx=0 + local target + while IFS= read -r target; do + [ -n "$target" ] || continue + local target_os="${target%%/*}" + if nexus_use_upx_for_target "$target_os"; then + requires_upx=1 + break + fi + done < <(release_targets) + if [ "$requires_upx" -eq 0 ]; then + return + fi + if command -v upx >/dev/null 2>&1; then + return + fi + err "NEXUS_COMPRESS=1 requires UPX. Install it first, for example: brew install upx" + exit 1 +} + pick_cc() { local target_os="$1" local target_arch="$2" @@ -175,10 +202,24 @@ build_target() { fi log "Building ${platform}..." + nexus_configure_release_cgo local ldflags ldflags="$(build_ldflags "$version")" local tags tags="$(build_tags)" + local build_args=("-platform" "$platform" "-trimpath") + if nexus_use_upx_for_target "$target_os"; then + 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" "-o" "$output_path") if [ -n "$cc" ]; then local cc_cmd="$cc" if [ -n "$zig_target" ]; then @@ -188,22 +229,12 @@ build_target() { cc_cmd="zig cc -target ${zig_target}-windows-gnu" fi fi - if [ -n "$tags" ]; then - if ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags" -o "$output_path"; then - warn "Build failed for ${platform}" - return 1 - fi - elif ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then + if ! CC="$cc_cmd" CGO_ENABLED=1 wails build "${build_args[@]}"; then warn "Build failed for ${platform}" return 1 fi else - if [ -n "$tags" ]; then - if ! CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags" -o "$output_path"; then - warn "Build failed for ${platform}" - return 1 - fi - elif ! CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then + if ! CGO_ENABLED=1 wails build "${build_args[@]}"; then warn "Build failed for ${platform}" return 1 fi diff --git a/scripts/build-common.sh b/scripts/build-common.sh index 9485c6f..3816001 100644 --- a/scripts/build-common.sh +++ b/scripts/build-common.sh @@ -6,6 +6,34 @@ nexus_wails_version() { awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' "$go_mod" } +nexus_bool_enabled() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +nexus_use_upx_for_target() { + local target_os="$1" + nexus_bool_enabled "${NEXUS_COMPRESS:-0}" || return 1 + if [ "$target_os" = "darwin" ] && ! nexus_bool_enabled "${NEXUS_FORCE_MACOS_UPX:-0}"; then + return 1 + fi + return 0 +} + +nexus_skips_upx_for_target() { + local target_os="$1" + nexus_bool_enabled "${NEXUS_COMPRESS:-0}" || return 1 + [ "$target_os" = "darwin" ] || return 1 + ! nexus_bool_enabled "${NEXUS_FORCE_MACOS_UPX:-0}" +} + +nexus_configure_release_cgo() { + export CGO_CFLAGS="${CGO_CFLAGS:--O2 -g0}" + export CGO_CXXFLAGS="${CGO_CXXFLAGS:--O2 -g0}" +} + nexus_build_tags() { if command -v pkg-config >/dev/null 2>&1 \ && pkg-config --exists webkit2gtk-4.1 \ @@ -24,7 +52,7 @@ nexus_build_version() { nexus_build_ldflags() { local version="$1" - local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}" + local flags="-s -w -buildid= -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