v3 fixes and styling
This commit is contained in:
@@ -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.
|
# 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)
|
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)
|
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),)
|
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
|
# Default target
|
||||||
all: build
|
all: build
|
||||||
@@ -12,6 +16,17 @@ all: build
|
|||||||
deps: frontend-deps
|
deps: frontend-deps
|
||||||
go mod download
|
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
|
# Install frontend dependencies
|
||||||
frontend-deps:
|
frontend-deps:
|
||||||
cd frontend && npm install
|
cd frontend && npm install
|
||||||
@@ -22,7 +37,11 @@ dev: deps
|
|||||||
|
|
||||||
# Production build
|
# Production build
|
||||||
build: deps
|
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
|
# Run tests
|
||||||
test:
|
test:
|
||||||
@@ -60,6 +79,7 @@ help:
|
|||||||
@echo " make deps — Install all dependencies"
|
@echo " make deps — Install all dependencies"
|
||||||
@echo " make dev — Run in development mode with hot reload"
|
@echo " make dev — Run in development mode with hot reload"
|
||||||
@echo " make build — Production build"
|
@echo " make build — Production build"
|
||||||
|
@echo " make build-compressed — Production build with UPX compression"
|
||||||
@echo " make test — Run tests"
|
@echo " make test — Run tests"
|
||||||
@echo " make test-cover — Run tests with coverage report"
|
@echo " make test-cover — Run tests with coverage report"
|
||||||
@echo " make clean — Clean build artefacts"
|
@echo " make clean — Clean build artefacts"
|
||||||
|
|||||||
+35
-11
@@ -6,6 +6,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
source "$SCRIPT_DIR/scripts/build-common.sh"
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
@@ -29,6 +30,7 @@ fi
|
|||||||
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
|
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
|
||||||
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
|
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
|
||||||
mkdir -p "$GOCACHE" "$GOPATH"
|
mkdir -p "$GOCACHE" "$GOPATH"
|
||||||
|
export PATH="$GOPATH/bin:$PATH"
|
||||||
|
|
||||||
version_at_least() {
|
version_at_least() {
|
||||||
local have="${1#go}"
|
local have="${1#go}"
|
||||||
@@ -91,6 +93,16 @@ wails_cmd() {
|
|||||||
fi
|
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() {
|
linux_deps_ok() {
|
||||||
command -v pkg-config >/dev/null 2>&1 || return 1
|
command -v pkg-config >/dev/null 2>&1 || return 1
|
||||||
pkg-config --exists gtk+-3.0 || 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 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 npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
|
||||||
resolve_wails
|
resolve_wails
|
||||||
|
ensure_upx
|
||||||
|
|
||||||
log "Go: $(go version)"
|
log "Go: $(go version)"
|
||||||
log "Node: $(node --version)"
|
log "Node: $(node --version)"
|
||||||
@@ -197,12 +210,31 @@ build_target() {
|
|||||||
local host="$3"
|
local host="$3"
|
||||||
local host_arch_value="$4"
|
local host_arch_value="$4"
|
||||||
local version="$5"
|
local version="$5"
|
||||||
|
local platform="${target_os}/${target_arch}"
|
||||||
|
nexus_configure_release_cgo
|
||||||
local ldflags
|
local ldflags
|
||||||
ldflags="$(build_ldflags "${version}")"
|
ldflags="$(build_ldflags "${version}")"
|
||||||
local tags
|
local tags
|
||||||
tags="$(build_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=""
|
local cc=""
|
||||||
|
|
||||||
if [ "$target_os" = "linux" ] && ! linux_deps_ok; then
|
if [ "$target_os" = "linux" ] && ! linux_deps_ok; then
|
||||||
@@ -222,17 +254,9 @@ build_target() {
|
|||||||
|
|
||||||
log "Building ${platform}…"
|
log "Building ${platform}…"
|
||||||
if [ -n "$cc" ]; then
|
if [ -n "$cc" ]; then
|
||||||
if [ -n "$tags" ]; then
|
CC="$cc" CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
|
||||||
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
|
|
||||||
else
|
else
|
||||||
if [ -n "$tags" ]; then
|
CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
|
||||||
CGO_ENABLED=1 wails_cmd build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
|
|
||||||
else
|
|
||||||
CGO_ENABLED=1 wails_cmd build -platform "$platform" -ldflags "$ldflags"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
((BUILT_TARGETS += 1))
|
((BUILT_TARGETS += 1))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-Warn($Message) { Write-Host "[nexus] $Message" -ForegroundColor Yellow }
|
||||||
function Write-Err($Message) { Write-Host "[nexus] $Message" -ForegroundColor Red }
|
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 {
|
function Get-WailsPath {
|
||||||
$wails = Get-Command wails -ErrorAction SilentlyContinue
|
$wails = Get-Command wails -ErrorAction SilentlyContinue
|
||||||
if ($wails) { return $wails.Source }
|
if ($wails) { return $wails.Source }
|
||||||
@@ -67,7 +71,7 @@ function Get-BuildVersion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Get-Ldflags([string]$Version) {
|
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) {
|
if ($env:NEXUS_UPDATE_URL) {
|
||||||
$flags += " -X github.com/andydixon/nexus/internal/api.appUpdateURL=$($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
|
$ldflags = Get-Ldflags $version
|
||||||
|
|
||||||
$env:CGO_ENABLED = '1'
|
$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)...'
|
Write-Info 'Building production binary (CGO_ENABLED=1)...'
|
||||||
& $wailsPath build -ldflags $ldflags
|
& $wailsPath @buildArgs
|
||||||
Write-Info 'Build complete: build\bin\'
|
Write-Info 'Build complete: build\bin\'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ fi
|
|||||||
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
|
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
|
||||||
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
|
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
|
||||||
mkdir -p "$GOCACHE" "$GOPATH"
|
mkdir -p "$GOCACHE" "$GOPATH"
|
||||||
|
export PATH="$GOPATH/bin:$PATH"
|
||||||
|
|
||||||
version_at_least() {
|
version_at_least() {
|
||||||
local have="${1#go}"
|
local have="${1#go}"
|
||||||
@@ -91,6 +92,20 @@ wails_cmd() {
|
|||||||
fi
|
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() {
|
check_linux_deps() {
|
||||||
if [ "$(uname -s)" != "Linux" ]; then
|
if [ "$(uname -s)" != "Linux" ]; then
|
||||||
return
|
return
|
||||||
@@ -122,6 +137,14 @@ build_tags() {
|
|||||||
nexus_build_tags
|
nexus_build_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
host_os() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) echo "darwin" ;;
|
||||||
|
Linux) echo "linux" ;;
|
||||||
|
*) echo "unknown" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
build_version() {
|
build_version() {
|
||||||
nexus_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; }
|
command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
|
||||||
check_linux_deps
|
check_linux_deps
|
||||||
resolve_wails
|
resolve_wails
|
||||||
|
ensure_upx
|
||||||
|
|
||||||
log "All prerequisites found"
|
log "All prerequisites found"
|
||||||
log "Go: $(go version)"
|
log "Go: $(go version)"
|
||||||
@@ -176,11 +200,24 @@ build() {
|
|||||||
version="$(build_version)"
|
version="$(build_version)"
|
||||||
local tags
|
local tags
|
||||||
tags="$(build_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
|
if [ -n "$tags" ]; then
|
||||||
log "Using Wails build tags: ${tags}"
|
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
|
else
|
||||||
wails_cmd build -ldflags "$(build_ldflags "${version}")"
|
wails_cmd build "${build_args[@]}" -ldflags "$(build_ldflags "${version}")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Replace Wails-generated icon with custom app icon on macOS.
|
# Replace Wails-generated icon with custom app icon on macOS.
|
||||||
|
|||||||
@@ -318,6 +318,9 @@
|
|||||||
<script src="/src/js/lib/xterm-addon-unicode11.min.js"></script>
|
<script src="/src/js/lib/xterm-addon-unicode11.min.js"></script>
|
||||||
<script src="/src/js/lib/xterm-addon-web-links.min.js"></script>
|
<script src="/src/js/lib/xterm-addon-web-links.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Wails service bridge; loaded before application code. -->
|
||||||
|
<script src="/src/js/wails-service.js"></script>
|
||||||
|
|
||||||
<!-- Application scripts (loaded in dependency order) -->
|
<!-- Application scripts (loaded in dependency order) -->
|
||||||
<script src="/src/js/utils.js"></script>
|
<script src="/src/js/utils.js"></script>
|
||||||
<script src="/src/js/state.js"></script>
|
<script src="/src/js/state.js"></script>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const requiredFiles = [
|
|||||||
'dist/src/js/lib/xterm-addon-fit.min.js',
|
'dist/src/js/lib/xterm-addon-fit.min.js',
|
||||||
'dist/src/js/lib/xterm-addon-search.min.js',
|
'dist/src/js/lib/xterm-addon-search.min.js',
|
||||||
'dist/src/js/lib/xterm-addon-web-links.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.
|
* 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
|
* Runs the asset copy script to pull xterm files into src/js/lib/ and
|
||||||
* src/css/lib/.
|
* src/css/lib/.
|
||||||
@@ -65,6 +121,10 @@ function copyAssets() {
|
|||||||
cwd: frontendDir,
|
cwd: frontendDir,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
});
|
});
|
||||||
|
execSync('node scripts/bundle-novnc.js', {
|
||||||
|
cwd: frontendDir,
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +132,7 @@ function copyAssets() {
|
|||||||
* dist/index.html
|
* dist/index.html
|
||||||
* dist/src/css/ (application + lib stylesheets)
|
* dist/src/css/ (application + lib stylesheets)
|
||||||
* dist/src/js/ (application + lib scripts)
|
* dist/src/js/ (application + lib scripts)
|
||||||
|
* dist/wailsjs/ (generated backend bindings, if present)
|
||||||
* dist/src/components/ (if present)
|
* dist/src/components/ (if present)
|
||||||
* dist/src/assets/ (if present)
|
* dist/src/assets/ (if present)
|
||||||
*/
|
*/
|
||||||
@@ -94,6 +155,13 @@ function buildDist() {
|
|||||||
copyDirSync(srcDir, path.join(distDir, 'src'));
|
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.');
|
console.log('[build] dist/ assembled.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +219,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildDist();
|
buildDist();
|
||||||
|
cleanupDistAssets();
|
||||||
|
|
||||||
if (!validate()) {
|
if (!validate()) {
|
||||||
console.error('[build] Validation failed — some required files are missing.');
|
console.error('[build] Validation failed — some required files are missing.');
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
// Entry point for esbuild bundling of noVNC.
|
// Entry point for esbuild bundling of noVNC.
|
||||||
// Assigns RFB to window.noVNC so it's available as a global in the frontend.
|
// Assigns RFB to window.noVNC so it's available as a global in the frontend.
|
||||||
import RFB from "@novnc/novnc";
|
import RFB from "@novnc/novnc";
|
||||||
|
import { initLogging } from "../node_modules/@novnc/novnc/core/util/logging.js";
|
||||||
|
|
||||||
|
initLogging("error");
|
||||||
window.noVNC = { RFB: RFB };
|
window.noVNC = { RFB: RFB };
|
||||||
|
|||||||
@@ -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(
|
assertContains(
|
||||||
"frontend/src/js/terminal.js",
|
"frontend/src/js/terminal.js",
|
||||||
"escapeHtml(title)",
|
"escapeHtml(title)",
|
||||||
@@ -163,5 +184,105 @@ assertContains(
|
|||||||
"github.com/hajimehoshi/go-mp3",
|
"github.com/hajimehoshi/go-mp3",
|
||||||
"Leviathan spectrogram visualiser must support MP3 decoding",
|
"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");
|
console.log("frontend regression checks passed");
|
||||||
|
|||||||
+532
-57
@@ -5,25 +5,61 @@
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-group {
|
.leviathan-group {
|
||||||
margin-top: var(--space-2);
|
margin: var(--space-3) var(--space-3) var(--space-1);
|
||||||
color: var(--text-tertiary);
|
padding: 0;
|
||||||
|
color: var(--sidebar-group-title);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-tool {
|
.leviathan-tool {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 28px minmax(0, 1fr);
|
grid-template-columns: 26px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
gap: var(--space-2);
|
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 {
|
.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);
|
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);
|
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 {
|
.badge-leviathan {
|
||||||
@@ -35,38 +71,68 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-base);
|
background: #181818;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-panel-header {
|
.leviathan-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
min-height: 48px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-title-wrap {
|
.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;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-title-wrap h2 {
|
.leviathan-title-wrap h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-xl);
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
.leviathan-title-wrap p {
|
line-height: var(--leading-tight);
|
||||||
margin: var(--space-1) 0 0;
|
text-overflow: ellipsis;
|
||||||
color: var(--text-secondary);
|
white-space: nowrap;
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-eyebrow,
|
.leviathan-eyebrow,
|
||||||
.leviathan-status {
|
.leviathan-status {
|
||||||
|
overflow: hidden;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-header-actions,
|
.leviathan-header-actions,
|
||||||
@@ -77,29 +143,136 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leviathan-header-actions .btn,
|
||||||
|
.leviathan-tool-toolbar .btn {
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.leviathan-panel-body {
|
.leviathan-panel-body {
|
||||||
min-height: 0;
|
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;
|
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 {
|
.leviathan-params {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
}
|
||||||
|
|
||||||
|
.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,
|
||||||
.leviathan-param-inline {
|
.leviathan-param-inline {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-param textarea {
|
.leviathan-param > span,
|
||||||
min-height: 120px;
|
.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 {
|
.leviathan-checkbox {
|
||||||
@@ -107,64 +280,188 @@
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-file-picker {
|
.leviathan-file-picker {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-file-path {
|
.leviathan-file-path {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-summary {
|
.leviathan-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
gap: var(--space-2);
|
gap: 1px;
|
||||||
margin: var(--space-3) 0;
|
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 {
|
.leviathan-pill {
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-2);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-pill span {
|
.leviathan-pill span {
|
||||||
display: block;
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
color: var(--text-tertiary);
|
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 {
|
.leviathan-pill strong {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
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;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-results {
|
.leviathan-results {
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
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 {
|
.leviathan-spectrogram-frame {
|
||||||
border: 1px solid var(--border-subtle);
|
min-height: 100%;
|
||||||
background: #05070c;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: var(--space-2);
|
padding: var(--space-3);
|
||||||
|
background: #05070c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-spectrogram-image {
|
.leviathan-spectrogram-image {
|
||||||
@@ -176,94 +473,272 @@
|
|||||||
|
|
||||||
.leviathan-empty,
|
.leviathan-empty,
|
||||||
.leviathan-json {
|
.leviathan-json {
|
||||||
border: 1px solid var(--border-subtle);
|
min-height: 100%;
|
||||||
background: var(--bg-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-secondary);
|
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 {
|
.leviathan-json {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
overflow: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-table {
|
.leviathan-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-table th,
|
.leviathan-table th,
|
||||||
.leviathan-table td {
|
.leviathan-table td {
|
||||||
|
max-width: 320px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2) var(--space-3);
|
||||||
|
overflow: hidden;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
vertical-align: top;
|
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 {
|
.leviathan-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
background: var(--bg-surface);
|
||||||
font-weight: var(--weight-semibold);
|
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 {
|
.leviathan-tree-node {
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-left: 1px solid var(--border-subtle);
|
||||||
padding: var(--space-2) 0;
|
padding: var(--space-1) 0 var(--space-1) var(--space-2);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leviathan-tree-node strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
.leviathan-tree-node span {
|
.leviathan-tree-node span {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-hex-grid {
|
.leviathan-hex-grid {
|
||||||
|
--leviathan-hex-width: 16;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
align-content: start;
|
||||||
margin-top: var(--space-3);
|
gap: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: var(--space-1);
|
||||||
|
overflow: auto;
|
||||||
|
background: #101010;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-hex-row {
|
.leviathan-hex-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 92px minmax(320px, 1fr) minmax(120px, 220px);
|
grid-template-columns: 82px max-content max-content;
|
||||||
align-items: center;
|
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 {
|
.leviathan-offset {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-byte-cells {
|
.leviathan-byte-cells {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(16, 28px);
|
grid-template-columns: repeat(var(--leviathan-hex-width), 22px);
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leviathan-byte {
|
.leviathan-byte {
|
||||||
width: 28px;
|
width: 22px;
|
||||||
min-height: 24px;
|
min-height: 20px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: #1d1d1d;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font-mono);
|
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 {
|
.leviathan-ascii {
|
||||||
|
overflow: hidden;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-overflow: clip;
|
||||||
white-space: pre;
|
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 {
|
.leviathan-panel-header {
|
||||||
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leviathan-header-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.leviathan-hex-row {
|
.leviathan-hex-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leviathan-byte-cells {
|
||||||
|
grid-template-columns: repeat(var(--leviathan-hex-width), 22px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-24
@@ -73,10 +73,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polls for `window.go` and `window.runtime` to become available.
|
* Polls for the Wails runtime and generated binding bridge.
|
||||||
* Wails injects these into the webview; on fast machines they are
|
* Application code uses wailsService as a small compatibility layer over
|
||||||
* present before DOMContentLoaded, but on slower starts there may
|
* the normal Wails backend global.
|
||||||
* be a brief delay.
|
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
@@ -85,7 +84,11 @@
|
|||||||
const maxWait = 5000;
|
const maxWait = 5000;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const check = () => {
|
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();
|
resolve();
|
||||||
} else if (Date.now() - start < maxWait) {
|
} else if (Date.now() - start < maxWait) {
|
||||||
setTimeout(check, 50);
|
setTimeout(check, 50);
|
||||||
@@ -98,7 +101,9 @@
|
|||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
check();
|
Promise.resolve(window.NexusTerminal?.wailsServiceReady)
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(check);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +114,7 @@
|
|||||||
*/
|
*/
|
||||||
async loadProfiles() {
|
async loadProfiles() {
|
||||||
try {
|
try {
|
||||||
const profiles = await window.go.api.Service.ListProfiles();
|
const profiles = await window.NexusTerminal.wailsService.ListProfiles();
|
||||||
this.state.setProfiles(profiles || []);
|
this.state.setProfiles(profiles || []);
|
||||||
this.sidebar.render();
|
this.sidebar.render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -122,7 +127,7 @@
|
|||||||
*/
|
*/
|
||||||
async loadPreferences() {
|
async loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const prefs = await window.go.api.Service.GetPreferences();
|
const prefs = await window.NexusTerminal.wailsService.GetPreferences();
|
||||||
this.state.setPreferences(prefs || {});
|
this.state.setPreferences(prefs || {});
|
||||||
this._applyPreferences();
|
this._applyPreferences();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -137,10 +142,10 @@
|
|||||||
async checkSecretStore() {
|
async checkSecretStore() {
|
||||||
try {
|
try {
|
||||||
const initialised =
|
const initialised =
|
||||||
await window.go.api.Service.IsSecretStoreInitialised();
|
await window.NexusTerminal.wailsService.IsSecretStoreInitialised();
|
||||||
this.state.setSecretStoreInitialised(!!initialised);
|
this.state.setSecretStoreInitialised(!!initialised);
|
||||||
if (initialised) {
|
if (initialised) {
|
||||||
const unlocked = await window.go.api.Service.IsSecretStoreUnlocked();
|
const unlocked = await window.NexusTerminal.wailsService.IsSecretStoreUnlocked();
|
||||||
this.state.setSecretStoreState(!!unlocked);
|
this.state.setSecretStoreState(!!unlocked);
|
||||||
} else {
|
} else {
|
||||||
this.state.setSecretStoreState(false);
|
this.state.setSecretStoreState(false);
|
||||||
@@ -218,7 +223,7 @@
|
|||||||
if (entry) {
|
if (entry) {
|
||||||
const { cols, rows } = entry.term;
|
const { cols, rows } = entry.term;
|
||||||
try {
|
try {
|
||||||
window.go.api.Service.ResizeSession(
|
window.NexusTerminal.wailsService.ResizeSession(
|
||||||
payload.sessionID,
|
payload.sessionID,
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
@@ -316,14 +321,12 @@
|
|||||||
this.recordings._updateRecordingIndicator(payload.sessionID, false);
|
this.recordings._updateRecordingIndicator(payload.sessionID, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Telnet negotiation debug messages
|
// Telnet negotiation debug messages are intentionally ignored in release UI.
|
||||||
window.runtime.EventsOn("session:debug", (payload) => {
|
window.runtime.EventsOn("session:debug", () => {});
|
||||||
console.debug("[session:debug]", payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backend ready event (emitted from DomReady)
|
// Backend ready event (emitted from DomReady)
|
||||||
window.runtime.EventsOn("backend:ready", () => {
|
window.runtime.EventsOn("backend:ready", () => {
|
||||||
console.log("Backend is ready");
|
// Lifecycle signal only.
|
||||||
});
|
});
|
||||||
|
|
||||||
// Host key verification requests
|
// Host key verification requests
|
||||||
@@ -380,7 +383,7 @@
|
|||||||
*/
|
*/
|
||||||
async closeSessionImmediate(sessionId) {
|
async closeSessionImmediate(sessionId) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.DisconnectSession(sessionId);
|
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore failures; we still want to clean up locally.
|
// Ignore failures; we still want to clean up locally.
|
||||||
}
|
}
|
||||||
@@ -816,7 +819,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionInfo =
|
const sessionInfo =
|
||||||
await window.go.api.Service.ConnectSession(profileId);
|
await window.NexusTerminal.wailsService.ConnectSession(profileId);
|
||||||
this.state.addSession(sessionInfo);
|
this.state.addSession(sessionInfo);
|
||||||
this.tabs.addTab(sessionInfo);
|
this.tabs.addTab(sessionInfo);
|
||||||
|
|
||||||
@@ -859,7 +862,7 @@
|
|||||||
*/
|
*/
|
||||||
async connectLocal() {
|
async connectLocal() {
|
||||||
try {
|
try {
|
||||||
const sessionInfo = await window.go.api.Service.ConnectLocal();
|
const sessionInfo = await window.NexusTerminal.wailsService.ConnectLocal();
|
||||||
this.state.addSession(sessionInfo);
|
this.state.addSession(sessionInfo);
|
||||||
this.tabs.addTab(sessionInfo);
|
this.tabs.addTab(sessionInfo);
|
||||||
|
|
||||||
@@ -986,7 +989,7 @@
|
|||||||
displayName: result.host,
|
displayName: result.host,
|
||||||
tags: [],
|
tags: [],
|
||||||
};
|
};
|
||||||
const sessionInfo = await window.go.api.Service.ConnectQuick(profile);
|
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
|
||||||
this.state.addSession(sessionInfo);
|
this.state.addSession(sessionInfo);
|
||||||
this.tabs.addTab(sessionInfo);
|
this.tabs.addTab(sessionInfo);
|
||||||
|
|
||||||
@@ -1229,9 +1232,9 @@
|
|||||||
this._latencySessionID = sessionID;
|
this._latencySessionID = sessionID;
|
||||||
|
|
||||||
const doPing = async () => {
|
const doPing = async () => {
|
||||||
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
if (!window.NexusTerminal?.wailsService) return;
|
||||||
try {
|
try {
|
||||||
const ms = await window.go.api.Service.PingSession(sessionID);
|
const ms = await window.NexusTerminal.wailsService.PingSession(sessionID);
|
||||||
const el = document.getElementById("status-latency");
|
const el = document.getElementById("status-latency");
|
||||||
if (el && this._latencySessionID === sessionID) {
|
if (el && this._latencySessionID === sessionID) {
|
||||||
el.textContent = ms + " ms";
|
el.textContent = ms + " ms";
|
||||||
@@ -1266,12 +1269,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkForUpdates(silent) {
|
async checkForUpdates(silent) {
|
||||||
if (!window.go || !window.go.api || !window.go.api.Service) {
|
if (!window.NexusTerminal?.wailsService) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await window.go.api.Service.CheckForUpdates();
|
const info = await window.NexusTerminal.wailsService.CheckForUpdates();
|
||||||
this.setUpdateInfo(info);
|
this.setUpdateInfo(info);
|
||||||
if (!silent && info && info.available) {
|
if (!silent && info && info.available) {
|
||||||
this.notifications.info(
|
this.notifications.info(
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
section: "Security",
|
section: "Security",
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.LockSecretStore();
|
await window.NexusTerminal.wailsService.LockSecretStore();
|
||||||
this.app.state.setSecretStoreState(false);
|
this.app.state.setSecretStoreState(false);
|
||||||
this.app.notifications.info("Secret store locked");
|
this.app.notifications.info("Secret store locked");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
try {
|
try {
|
||||||
const pw =
|
const pw =
|
||||||
await this.app.secretPrompt.promptMasterPassword(false);
|
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.state.setSecretStoreState(true);
|
||||||
this.app.notifications.success("Secret store unlocked");
|
this.app.notifications.success("Secret store unlocked");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -425,7 +425,7 @@
|
|||||||
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.DownloadGitHubRepoArchive(
|
await window.NexusTerminal.wailsService.DownloadGitHubRepoArchive(
|
||||||
this.sessionID,
|
this.sessionID,
|
||||||
repo.fullName,
|
repo.fullName,
|
||||||
ref,
|
ref,
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
if (!this._statusEl) return;
|
if (!this._statusEl) return;
|
||||||
this._statusEl.textContent = "Refreshing…";
|
this._statusEl.textContent = "Refreshing…";
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.RefreshGitHubRepos(this.sessionID);
|
await window.NexusTerminal.wailsService.RefreshGitHubRepos(this.sessionID);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showError(err.message || "Refresh failed");
|
this.showError(err.message || "Refresh failed");
|
||||||
}
|
}
|
||||||
|
|||||||
+646
-58
@@ -58,7 +58,6 @@
|
|||||||
"patch-plan": { source: "set 0x00 90 90" },
|
"patch-plan": { source: "set 0x00 90 90" },
|
||||||
"patch-apply": { source: "set 0x00 90 90" },
|
"patch-apply": { source: "set 0x00 90 90" },
|
||||||
transform: { pipeline: "hex.encode", start: 0, length: 1024 },
|
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 },
|
"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 },
|
hex: { start: 0, bytes: 2048, width: 16 },
|
||||||
};
|
};
|
||||||
@@ -68,6 +67,8 @@
|
|||||||
this.app = app;
|
this.app = app;
|
||||||
this.state = null;
|
this.state = null;
|
||||||
this.lastResults = new Map();
|
this.lastResults = new Map();
|
||||||
|
this.autoRunners = new Map();
|
||||||
|
this.hexSearches = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSidebar() {
|
renderSidebar() {
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
|
|
||||||
async loadFile() {
|
async loadFile() {
|
||||||
try {
|
try {
|
||||||
const payload = await window.go.api.Service.LeviathanOpenFile();
|
const payload = await window.NexusTerminal.wailsService.LeviathanOpenFile();
|
||||||
this.applyState(payload);
|
this.applyState(payload);
|
||||||
this.refreshOpenHexTabs();
|
this.refreshOpenHexTabs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -126,9 +127,9 @@
|
|||||||
|
|
||||||
async saveFile() {
|
async saveFile() {
|
||||||
try {
|
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");
|
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);
|
this.applyState(state);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error("Save failed: " + err);
|
this.app.notifications.error("Save failed: " + err);
|
||||||
@@ -153,6 +154,7 @@
|
|||||||
state: "connected",
|
state: "connected",
|
||||||
tabName: tool.name,
|
tabName: tool.name,
|
||||||
toolId: tool.id,
|
toolId: tool.id,
|
||||||
|
leviathanParams: options.params || null,
|
||||||
};
|
};
|
||||||
this.app.state.addSession(sessionInfo);
|
this.app.state.addSession(sessionInfo);
|
||||||
this.app.tabs.addTab(sessionInfo);
|
this.app.tabs.addTab(sessionInfo);
|
||||||
@@ -170,9 +172,11 @@
|
|||||||
|
|
||||||
const header = createElement("div", "leviathan-panel-header");
|
const header = createElement("div", "leviathan-panel-header");
|
||||||
const titleWrap = createElement("div", "leviathan-title-wrap");
|
const titleWrap = createElement("div", "leviathan-title-wrap");
|
||||||
titleWrap.appendChild(createElement("span", "leviathan-eyebrow", { textContent: this._stateLabel() }));
|
titleWrap.appendChild(createElement("span", "leviathan-tool-mark", { textContent: tool.ico }));
|
||||||
titleWrap.appendChild(createElement("h2", "", { textContent: tool.name }));
|
const heading = createElement("div", "leviathan-heading");
|
||||||
titleWrap.appendChild(createElement("p", "", { textContent: tool.desc }));
|
heading.appendChild(createElement("h2", "", { textContent: tool.name }));
|
||||||
|
heading.appendChild(createElement("span", "leviathan-eyebrow", { textContent: this._stateLabel() }));
|
||||||
|
titleWrap.appendChild(heading);
|
||||||
header.appendChild(titleWrap);
|
header.appendChild(titleWrap);
|
||||||
|
|
||||||
const headerActions = createElement("div", "leviathan-header-actions");
|
const headerActions = createElement("div", "leviathan-header-actions");
|
||||||
@@ -193,6 +197,8 @@
|
|||||||
|
|
||||||
if (tool.id === "hex") {
|
if (tool.id === "hex") {
|
||||||
this.renderHexEditor(body, sessionId);
|
this.renderHexEditor(body, sessionId);
|
||||||
|
} else if (tool.id === "search") {
|
||||||
|
this.renderSearchTool(body, sessionId);
|
||||||
} else if (tool.id === "spectrogram-encode") {
|
} else if (tool.id === "spectrogram-encode") {
|
||||||
this.renderSpectrogramEncode(body, sessionId);
|
this.renderSpectrogramEncode(body, sessionId);
|
||||||
} else if (tool.id === "spectrogram-view") {
|
} else if (tool.id === "spectrogram-view") {
|
||||||
@@ -203,37 +209,113 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderHexEditor(body, sessionId) {
|
renderHexEditor(body, sessionId) {
|
||||||
|
const { controls, output } = this._createWorkbench(body, "leviathan-hex-workbench", { topBar: true });
|
||||||
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
||||||
const start = this._input("start", "0");
|
const initial = this._sessionParams(sessionId);
|
||||||
const bytes = this._input("bytes", "2048");
|
const start = this._input("start", String(initial.start ?? 0));
|
||||||
const refresh = createElement("button", "btn btn-sm btn-primary", { textContent: "Refresh" });
|
const bytes = this._input("bytes", String(initial.bytes ?? initial.length ?? 2048));
|
||||||
toolbar.append(start.row, bytes.row, refresh);
|
const width = this._selectInput("row", [
|
||||||
body.appendChild(toolbar);
|
["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 status = createElement("div", "leviathan-status", { textContent: this._stateLabel() });
|
||||||
const grid = createElement("div", "leviathan-hex-grid");
|
const grid = createElement("div", "leviathan-hex-grid");
|
||||||
body.append(status, grid);
|
controls.append(toolbar, status);
|
||||||
|
output.appendChild(grid);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
status.textContent = "Loading hex...";
|
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),
|
start: Number(start.input.value || 0),
|
||||||
bytes: Number(bytes.input.value || 2048),
|
bytes: Number(bytes.input.value || 2048),
|
||||||
width: 16,
|
width: Number(width.input.value || 16),
|
||||||
});
|
});
|
||||||
this.lastResults.set(sessionId, result);
|
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();
|
status.textContent = this._stateLabel();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = String(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();
|
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) {
|
renderSpectrogramEncode(body, sessionId) {
|
||||||
|
const { controls, output } = this._createWorkbench(body);
|
||||||
const params = createElement("div", "leviathan-params");
|
const params = createElement("div", "leviathan-params");
|
||||||
const fields = [
|
const fields = [
|
||||||
this._paramInput(params, "durationSec", "Duration (s)", 5, { type: "number", min: "0.25", max: "60", step: "0.25" }),
|
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, "preserveAspectRatio", "Preserve aspect", true, { type: "checkbox" }),
|
||||||
this._paramInput(params, "invert", "Invert image", true, { type: "checkbox" }),
|
this._paramInput(params, "invert", "Invert image", true, { type: "checkbox" }),
|
||||||
];
|
];
|
||||||
body.appendChild(params);
|
|
||||||
|
|
||||||
const imagePicker = createElement("div", "leviathan-file-picker");
|
const imagePicker = createElement("div", "leviathan-file-picker");
|
||||||
const chooseImage = createElement("button", "btn btn-sm", { type: "button", textContent: "Select Image" });
|
const chooseImage = createElement("button", "btn btn-sm", { type: "button", textContent: "Select Image" });
|
||||||
const imagePath = createElement("span", "leviathan-file-path", { textContent: "No insertion image selected" });
|
const imagePath = createElement("span", "leviathan-file-path", { textContent: "No insertion image selected" });
|
||||||
imagePicker.append(chooseImage, imagePath);
|
imagePicker.append(chooseImage, imagePath);
|
||||||
body.appendChild(imagePicker);
|
|
||||||
|
|
||||||
let selectedImagePath = "";
|
let selectedImagePath = "";
|
||||||
chooseImage.addEventListener("click", async () => {
|
chooseImage.addEventListener("click", async () => {
|
||||||
try {
|
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;
|
if (!path) return;
|
||||||
selectedImagePath = path;
|
selectedImagePath = path;
|
||||||
imagePath.textContent = path;
|
imagePath.textContent = path;
|
||||||
@@ -272,11 +352,11 @@
|
|||||||
const encode = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Encode Loaded Image" });
|
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" });
|
const insert = createElement("button", "btn btn-sm", { type: "button", textContent: "Insert Into Loaded Audio" });
|
||||||
toolbar.append(encode, insert);
|
toolbar.append(encode, insert);
|
||||||
body.appendChild(toolbar);
|
controls.append(params, imagePicker, toolbar);
|
||||||
|
|
||||||
const summary = createElement("div", "leviathan-summary");
|
const summary = createElement("div", "leviathan-summary");
|
||||||
const results = createElement("div", "leviathan-results");
|
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." }));
|
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) => {
|
const run = async (toolId) => {
|
||||||
@@ -289,7 +369,7 @@
|
|||||||
if (!selectedImagePath) throw new Error("Select an image to insert first.");
|
if (!selectedImagePath) throw new Error("Select an image to insert first.");
|
||||||
payload.imagePath = selectedImagePath;
|
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);
|
this.lastResults.set(sessionId, out);
|
||||||
summary.innerHTML = "";
|
summary.innerHTML = "";
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
@@ -298,7 +378,7 @@
|
|||||||
if (out?.path) this.app.notifications.success("Saved " + out.path);
|
if (out?.path) this.app.notifications.success("Saved " + out.path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
results.appendChild(createElement("pre", "leviathan-json", { textContent: String(err) }));
|
this._renderToolError(results, toolId, err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
encode.addEventListener("click", () => run("spectrogram-encode"));
|
encode.addEventListener("click", () => run("spectrogram-encode"));
|
||||||
@@ -306,6 +386,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderSpectrogramView(body, sessionId) {
|
renderSpectrogramView(body, sessionId) {
|
||||||
|
const { controls, output } = this._createWorkbench(body);
|
||||||
const params = createElement("div", "leviathan-params");
|
const params = createElement("div", "leviathan-params");
|
||||||
const fields = [
|
const fields = [
|
||||||
this._paramInput(params, "fftSize", "FFT size", 2048, { type: "number", min: "256", max: "8192", step: "256" }),
|
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, "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" }),
|
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 toolbar = createElement("div", "leviathan-tool-toolbar");
|
||||||
const run = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Render Spectrogram" });
|
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" });
|
const exportBtn = createElement("button", "btn btn-sm", { type: "button", textContent: "Export JSON" });
|
||||||
toolbar.append(run, exportBtn);
|
toolbar.append(run, exportBtn);
|
||||||
body.appendChild(toolbar);
|
controls.append(params, toolbar);
|
||||||
|
|
||||||
const summary = createElement("div", "leviathan-summary");
|
const summary = createElement("div", "leviathan-summary");
|
||||||
const results = createElement("div", "leviathan-results");
|
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." }));
|
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 {
|
try {
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Rendering spectrogram..." }));
|
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Rendering spectrogram..." }));
|
||||||
const payload = {};
|
const payload = {};
|
||||||
for (const field of fields) payload[field.key] = this._fieldValue(field);
|
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);
|
this.lastResults.set(sessionId, out);
|
||||||
summary.innerHTML = "";
|
summary.innerHTML = "";
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
@@ -354,38 +434,53 @@
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
results.innerHTML = "";
|
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 () => {
|
exportBtn.addEventListener("click", async () => {
|
||||||
const out = this.lastResults.get(sessionId);
|
const out = this.lastResults.get(sessionId);
|
||||||
if (!out) return;
|
if (!out) return;
|
||||||
try {
|
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");
|
this.app.notifications.success("Exported JSON");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error("Export failed: " + err);
|
this.app.notifications.error("Export failed: " + err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.autoRunners.set(sessionId, render);
|
||||||
|
requestAnimationFrame(render);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderToolRunner(body, sessionId, tool) {
|
renderToolRunner(body, sessionId, tool) {
|
||||||
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
||||||
const params = createElement("div", "leviathan-params");
|
const params = createElement("div", "leviathan-params");
|
||||||
const fields = this._renderParams(tool.id, 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" });
|
const exportBtn = createElement("button", "btn btn-sm", { textContent: "Export JSON" });
|
||||||
toolbar.append(run, exportBtn);
|
if (!autoRun || fields.length) toolbar.append(run);
|
||||||
body.append(params, toolbar);
|
toolbar.append(exportBtn);
|
||||||
|
if (fields.length) controls.append(params);
|
||||||
|
controls.append(toolbar);
|
||||||
|
|
||||||
const summary = createElement("div", "leviathan-summary");
|
const summary = createElement("div", "leviathan-summary");
|
||||||
const results = createElement("div", "leviathan-results");
|
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 {
|
try {
|
||||||
if (tool.id === "diff" && !this.state?.compare) {
|
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);
|
this.applyState(compare);
|
||||||
}
|
}
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
@@ -394,30 +489,49 @@
|
|||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
payload[field.key] = this._fieldValue(field);
|
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.lastResults.set(sessionId, out);
|
||||||
this._renderResult(summary, results, out);
|
this._renderResult(summary, results, out, tool.id);
|
||||||
if (out && out.state) this.applyState(out.state);
|
if (out && out.state) this.applyState(out.state);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
results.innerHTML = "";
|
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 () => {
|
exportBtn.addEventListener("click", async () => {
|
||||||
const out = this.lastResults.get(sessionId);
|
const out = this.lastResults.get(sessionId);
|
||||||
if (!out) return;
|
if (!out) return;
|
||||||
try {
|
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");
|
this.app.notifications.success("Exported JSON");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error("Export failed: " + err);
|
this.app.notifications.error("Export failed: " + err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
results.appendChild(
|
if (autoRun) {
|
||||||
createElement("div", "leviathan-empty", {
|
this.autoRunners.set(sessionId, runTool);
|
||||||
textContent: "Run the tool to populate this tab.",
|
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) {
|
applyState(payload) {
|
||||||
@@ -427,6 +541,15 @@
|
|||||||
|
|
||||||
destroyPanel(sessionId) {
|
destroyPanel(sessionId) {
|
||||||
this.lastResults.delete(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() {
|
refreshOpenHexTabs() {
|
||||||
@@ -437,8 +560,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderHexRows(grid, rows, status) {
|
_renderHexRows(grid, rows, status, rowWidth = 16, activeHit = null) {
|
||||||
grid.innerHTML = "";
|
grid.innerHTML = "";
|
||||||
|
grid.style.setProperty("--leviathan-hex-width", String(rowWidth));
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
grid.appendChild(createElement("div", "leviathan-empty", { textContent: "Load a file to inspect bytes." }));
|
grid.appendChild(createElement("div", "leviathan-empty", { textContent: "Load a file to inspect bytes." }));
|
||||||
return;
|
return;
|
||||||
@@ -449,16 +573,20 @@
|
|||||||
const bytes = createElement("span", "leviathan-byte-cells");
|
const bytes = createElement("span", "leviathan-byte-cells");
|
||||||
const cells = row.bytes || String(row.hex || "").split(/\s+/).filter(Boolean);
|
const cells = row.bytes || String(row.hex || "").split(/\s+/).filter(Boolean);
|
||||||
cells.forEach((value, idx) => {
|
cells.forEach((value, idx) => {
|
||||||
|
const cellOffset = (row.offset || 0) + idx;
|
||||||
const cell = createElement("input", "leviathan-byte", {
|
const cell = createElement("input", "leviathan-byte", {
|
||||||
value,
|
value,
|
||||||
maxlength: "2",
|
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 () => {
|
cell.addEventListener("change", async () => {
|
||||||
try {
|
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();
|
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) {
|
} catch (err) {
|
||||||
status.textContent = String(err);
|
status.textContent = String(err);
|
||||||
cell.value = value;
|
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 = "";
|
summary.innerHTML = "";
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
this._renderSummaryPills(summary, this._flatSummary(out));
|
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) {
|
if (out?.tree) {
|
||||||
this._renderTree(results, out.tree);
|
this._renderTree(results, out.tree);
|
||||||
return;
|
return;
|
||||||
@@ -516,15 +688,186 @@
|
|||||||
results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) }));
|
results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderTable(container, rows) {
|
_renderOverview(container, out) {
|
||||||
rows = rows.slice(0, 1000);
|
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) {
|
if (!rows.length) {
|
||||||
container.appendChild(createElement("div", "leviathan-empty", { textContent: "No rows." }));
|
container.appendChild(createElement("div", "leviathan-empty", { textContent: "No rows." }));
|
||||||
return;
|
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 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);
|
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 table = createElement("table", "leviathan-table");
|
||||||
const thead = createElement("thead");
|
const thead = createElement("thead");
|
||||||
const headRow = createElement("tr");
|
const headRow = createElement("tr");
|
||||||
@@ -533,11 +876,104 @@
|
|||||||
const tbody = createElement("tbody");
|
const tbody = createElement("tbody");
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const tr = createElement("tr");
|
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);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
table.append(thead, tbody);
|
table.append(thead, tbody);
|
||||||
container.appendChild(table);
|
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) {
|
_renderTree(container, root) {
|
||||||
@@ -556,7 +992,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_stateAction(method, sessionId) {
|
_stateAction(method, sessionId) {
|
||||||
return window.go.api.Service[method]()
|
return window.NexusTerminal.wailsService[method]()
|
||||||
.then((state) => {
|
.then((state) => {
|
||||||
this.applyState(state);
|
this.applyState(state);
|
||||||
const session = this.app.state.getSession(sessionId);
|
const session = this.app.state.getSession(sessionId);
|
||||||
@@ -576,6 +1012,16 @@
|
|||||||
return { row, input };
|
return { row, input };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_selectInput(label, options, value) {
|
||||||
|
const row = createElement("label", "leviathan-param-inline");
|
||||||
|
row.appendChild(createElement("span", "", { textContent: label }));
|
||||||
|
const input = createElement("select", "form-input");
|
||||||
|
options.forEach(([optionValue, text]) => input.add(new Option(text, optionValue)));
|
||||||
|
input.value = value;
|
||||||
|
row.appendChild(input);
|
||||||
|
return { row, input };
|
||||||
|
}
|
||||||
|
|
||||||
_paramInput(container, key, label, value, attrs = {}) {
|
_paramInput(container, key, label, value, attrs = {}) {
|
||||||
const row = createElement("label", "leviathan-param");
|
const row = createElement("label", "leviathan-param");
|
||||||
row.appendChild(createElement("span", "", { textContent: label }));
|
row.appendChild(createElement("span", "", { textContent: label }));
|
||||||
@@ -606,6 +1052,148 @@
|
|||||||
: raw;
|
: 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) {
|
_renderSummaryPills(container, values) {
|
||||||
Object.entries(values || {})
|
Object.entries(values || {})
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
|
|||||||
+18
-18
@@ -437,10 +437,10 @@
|
|||||||
|
|
||||||
_loadSSHKeyOptions() {
|
_loadSSHKeyOptions() {
|
||||||
if (this._sshKeyOptionsLoading || this._sshKeyOptionsLoaded) return;
|
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;
|
this._sshKeyOptionsLoading = true;
|
||||||
window.go.api.Service.ListSSHKeys()
|
window.NexusTerminal.wailsService.ListSSHKeys()
|
||||||
.then((keys) => {
|
.then((keys) => {
|
||||||
this._sshKeyOptions = Array.isArray(keys) ? keys : [];
|
this._sshKeyOptions = Array.isArray(keys) ? keys : [];
|
||||||
this._sshKeyOptionsLoaded = true;
|
this._sshKeyOptionsLoaded = true;
|
||||||
@@ -1215,7 +1215,7 @@
|
|||||||
const profileId = profile.id;
|
const profileId = profile.id;
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
try {
|
try {
|
||||||
const code = await window.go.api.Service.GetTOTPCode(profileId);
|
const code = await window.NexusTerminal.wailsService.GetTOTPCode(profileId);
|
||||||
testBtn.textContent = code;
|
testBtn.textContent = code;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
testBtn.textContent = "Test";
|
testBtn.textContent = "Test";
|
||||||
@@ -1254,7 +1254,7 @@
|
|||||||
if (!secret) {
|
if (!secret) {
|
||||||
// Delete existing secret
|
// Delete existing secret
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.DeleteTOTPSecret(profileId);
|
await window.NexusTerminal.wailsService.DeleteTOTPSecret(profileId);
|
||||||
NexusTerminal.Notifications.show("TOTP secret removed.", "info");
|
NexusTerminal.Notifications.show("TOTP secret removed.", "info");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
NexusTerminal.Notifications.show(
|
NexusTerminal.Notifications.show(
|
||||||
@@ -1265,7 +1265,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SetTOTPSecret(profileId, secret);
|
await window.NexusTerminal.wailsService.SetTOTPSecret(profileId, secret);
|
||||||
NexusTerminal.Notifications.show(
|
NexusTerminal.Notifications.show(
|
||||||
"TOTP secret saved securely.",
|
"TOTP secret saved securely.",
|
||||||
"success",
|
"success",
|
||||||
@@ -1295,7 +1295,7 @@
|
|||||||
const profileId = profile.id;
|
const profileId = profile.id;
|
||||||
if (!profileId) return;
|
if (!profileId) return;
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.DeleteTOTPSecret(profileId);
|
await window.NexusTerminal.wailsService.DeleteTOTPSecret(profileId);
|
||||||
NexusTerminal.Notifications.show("TOTP secret removed.", "info");
|
NexusTerminal.Notifications.show("TOTP secret removed.", "info");
|
||||||
secretInput.placeholder =
|
secretInput.placeholder =
|
||||||
"Base32-encoded secret (e.g. from authenticator app setup)";
|
"Base32-encoded secret (e.g. from authenticator app setup)";
|
||||||
@@ -1319,7 +1319,7 @@
|
|||||||
if (profile.id) {
|
if (profile.id) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const has = await window.go.api.Service.HasTOTPSecret(profile.id);
|
const has = await window.NexusTerminal.wailsService.HasTOTPSecret(profile.id);
|
||||||
if (has) {
|
if (has) {
|
||||||
secretInput.placeholder = "••••••••• (secret saved)";
|
secretInput.placeholder = "••••••••• (secret saved)";
|
||||||
}
|
}
|
||||||
@@ -1763,8 +1763,8 @@
|
|||||||
});
|
});
|
||||||
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||||
browseBtn.addEventListener("click", () => {
|
browseBtn.addEventListener("click", () => {
|
||||||
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
if (!window.NexusTerminal?.wailsService) return;
|
||||||
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
|
window.NexusTerminal.wailsService.SelectFile("Select SSH Private Key", "*")
|
||||||
.then((path) => {
|
.then((path) => {
|
||||||
if (path) input.value = path;
|
if (path) input.value = path;
|
||||||
})
|
})
|
||||||
@@ -1831,8 +1831,8 @@
|
|||||||
|
|
||||||
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||||
browseBtn.addEventListener("click", () => {
|
browseBtn.addEventListener("click", () => {
|
||||||
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
if (!window.NexusTerminal?.wailsService) return;
|
||||||
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
|
window.NexusTerminal.wailsService.SelectFile("Select SSH Private Key", "*")
|
||||||
.then((path) => {
|
.then((path) => {
|
||||||
if (path) keyInput.value = path;
|
if (path) keyInput.value = path;
|
||||||
this._validateKeyPath(keyInput, error);
|
this._validateKeyPath(keyInput, error);
|
||||||
@@ -1908,10 +1908,10 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.go || !window.go.api || !window.go.api.Service) {
|
if (!window.NexusTerminal?.wailsService) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.go.api.Service.FileExists(value)
|
window.NexusTerminal.wailsService.FileExists(value)
|
||||||
.then((exists) => {
|
.then((exists) => {
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
errorEl.textContent = "Key file not found.";
|
errorEl.textContent = "Key file not found.";
|
||||||
@@ -1922,7 +1922,7 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return window.go.api.Service.FileIsPrivateKey(value).then((isKey) => {
|
return window.NexusTerminal.wailsService.FileIsPrivateKey(value).then((isKey) => {
|
||||||
if (!isKey) {
|
if (!isKey) {
|
||||||
errorEl.textContent = "File is not a recognised private key.";
|
errorEl.textContent = "File is not a recognised private key.";
|
||||||
errorEl.style.display = "block";
|
errorEl.style.display = "block";
|
||||||
@@ -2328,11 +2328,11 @@
|
|||||||
try {
|
try {
|
||||||
let saved;
|
let saved;
|
||||||
if (this._isNew) {
|
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.state.addProfile(saved);
|
||||||
this.app.notifications.success("Profile created");
|
this.app.notifications.success("Profile created");
|
||||||
} else {
|
} 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.state.updateProfile(saved);
|
||||||
this.app.notifications.success("Profile updated");
|
this.app.notifications.success("Profile updated");
|
||||||
}
|
}
|
||||||
@@ -2353,7 +2353,7 @@
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
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.state.removeProfile(this._profile.id);
|
||||||
this.app.sidebar.render();
|
this.app.sidebar.render();
|
||||||
this.app.notifications.success("Profile deleted");
|
this.app.notifications.success("Profile deleted");
|
||||||
@@ -2377,7 +2377,7 @@
|
|||||||
this.app.notifications.info(`Testing connection to ${host}:${port}…`);
|
this.app.notifications.info(`Testing connection to ${host}:${port}…`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.go.api.Service.TestConnection(host, port);
|
const result = await window.NexusTerminal.wailsService.TestConnection(host, port);
|
||||||
this.app.notifications.success(
|
this.app.notifications.success(
|
||||||
`Reachable — ${result.latencyMs}ms latency`,
|
`Reachable — ${result.latencyMs}ms latency`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -214,7 +214,7 @@
|
|||||||
return `[${ts}] [${dir}] ${e.data}`;
|
return `[${ts}] [${dir}] ${e.data}`;
|
||||||
});
|
});
|
||||||
const content = lines.join("\n");
|
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");
|
this.app.notifications.success("Capture saved");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error("Save failed: " + err);
|
this.app.notifications.error("Save failed: " + err);
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
format === "hex" || format === "escaped"
|
format === "hex" || format === "escaped"
|
||||||
? btoa(dataToSend)
|
? btoa(dataToSend)
|
||||||
: window.NexusTerminal.utf8ToBase64(dataToSend);
|
: window.NexusTerminal.utf8ToBase64(dataToSend);
|
||||||
await window.go.api.Service.WriteSession(sessionId, b64);
|
await window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
|
|
||||||
// Also show in viewer as sent data
|
// Also show in viewer as sent data
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class RecordingManager {
|
|||||||
const cols = entry ? entry.term.cols : 80;
|
const cols = entry ? entry.term.cols : 80;
|
||||||
const rows = entry ? entry.term.rows : 24;
|
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.activeSessions.add(sessionId);
|
||||||
this._updateRecordingIndicator(sessionId, true);
|
this._updateRecordingIndicator(sessionId, true);
|
||||||
this.app.notifications.success('Recording started');
|
this.app.notifications.success('Recording started');
|
||||||
@@ -56,7 +56,7 @@ class RecordingManager {
|
|||||||
*/
|
*/
|
||||||
async stop(sessionId) {
|
async stop(sessionId) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.StopRecording(sessionId);
|
await window.NexusTerminal.wailsService.StopRecording(sessionId);
|
||||||
this.activeSessions.delete(sessionId);
|
this.activeSessions.delete(sessionId);
|
||||||
this._updateRecordingIndicator(sessionId, false);
|
this._updateRecordingIndicator(sessionId, false);
|
||||||
this.app.notifications.success('Recording saved');
|
this.app.notifications.success('Recording saved');
|
||||||
@@ -71,8 +71,8 @@ class RecordingManager {
|
|||||||
async openBrowser() {
|
async openBrowser() {
|
||||||
try {
|
try {
|
||||||
const [recordings, storageInfo] = await Promise.all([
|
const [recordings, storageInfo] = await Promise.all([
|
||||||
window.go.api.Service.ListRecordings(),
|
window.NexusTerminal.wailsService.ListRecordings(),
|
||||||
window.go.api.Service.GetRecordingStorageInfo(),
|
window.NexusTerminal.wailsService.GetRecordingStorageInfo(),
|
||||||
]);
|
]);
|
||||||
this._showBrowserModal(recordings || [], storageInfo || {});
|
this._showBrowserModal(recordings || [], storageInfo || {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -86,7 +86,7 @@ class RecordingManager {
|
|||||||
*/
|
*/
|
||||||
async playback(id) {
|
async playback(id) {
|
||||||
try {
|
try {
|
||||||
const data = await window.go.api.Service.GetRecording(id);
|
const data = await window.NexusTerminal.wailsService.GetRecording(id);
|
||||||
this._showPlaybackModal(data);
|
this._showPlaybackModal(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('Failed to load recording: ' + err);
|
this.app.notifications.error('Failed to load recording: ' + err);
|
||||||
@@ -99,7 +99,7 @@ class RecordingManager {
|
|||||||
*/
|
*/
|
||||||
async deleteRecording(id) {
|
async deleteRecording(id) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.DeleteRecording(id);
|
await window.NexusTerminal.wailsService.DeleteRecording(id);
|
||||||
this.app.notifications.success('Recording deleted');
|
this.app.notifications.success('Recording deleted');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('Failed to delete recording: ' + err);
|
this.app.notifications.error('Failed to delete recording: ' + err);
|
||||||
@@ -225,11 +225,11 @@ class RecordingManager {
|
|||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
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)}.`;
|
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();
|
close();
|
||||||
this._showBrowserModal(fresh || [], await window.go.api.Service.GetRecordingStorageInfo());
|
this._showBrowserModal(fresh || [], await window.NexusTerminal.wailsService.GetRecordingStorageInfo());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = 'Cleanup failed: ' + err;
|
status.textContent = 'Cleanup failed: ' + err;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -556,7 +556,7 @@ class ExportImportUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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}`);
|
this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`);
|
||||||
close();
|
close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -628,7 +628,7 @@ class ExportImportUI {
|
|||||||
try {
|
try {
|
||||||
confirmBtn.disabled = true;
|
confirmBtn.disabled = true;
|
||||||
confirmBtn.textContent = 'Importing...';
|
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)`);
|
this.app.notifications.success(`Imported ${count} profile(s)`);
|
||||||
await this.app.loadProfiles();
|
await this.app.loadProfiles();
|
||||||
close();
|
close();
|
||||||
|
|||||||
+12
-12
@@ -48,13 +48,13 @@
|
|||||||
this._activeTab = tabKey || this._activeTab || "general";
|
this._activeTab = tabKey || this._activeTab || "general";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._knownHosts = (await window.go.api.Service.ListKnownHosts()) || [];
|
this._knownHosts = (await window.NexusTerminal.wailsService.ListKnownHosts()) || [];
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
this._knownHosts = [];
|
this._knownHosts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._defaultShell = await window.go.api.Service.GetDefaultShell();
|
this._defaultShell = await window.NexusTerminal.wailsService.GetDefaultShell();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
this._defaultShell = "";
|
this._defaultShell = "";
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
this._collectValues();
|
this._collectValues();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SavePreferences(this._prefs);
|
await window.NexusTerminal.wailsService.SavePreferences(this._prefs);
|
||||||
this.app.state.setPreferences(this._prefs);
|
this.app.state.setPreferences(this._prefs);
|
||||||
|
|
||||||
// Apply terminal colour theme change immediately.
|
// Apply terminal colour theme change immediately.
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
});
|
});
|
||||||
browseBtn.addEventListener("click", async () => {
|
browseBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const path = await window.go.api.Service.BrowseForShell();
|
const path = await window.NexusTerminal.wailsService.BrowseForShell();
|
||||||
if (path) {
|
if (path) {
|
||||||
shellInput.value = path;
|
shellInput.value = path;
|
||||||
}
|
}
|
||||||
@@ -429,7 +429,7 @@
|
|||||||
initBtn.addEventListener("click", async () => {
|
initBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const pw = await this.app.secretPrompt.promptMasterPassword(true);
|
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.setSecretStoreInitialised(true);
|
||||||
this.app.state.setSecretStoreState(true);
|
this.app.state.setSecretStoreState(true);
|
||||||
this.app.notifications.success("Secret store created");
|
this.app.notifications.success("Secret store created");
|
||||||
@@ -448,7 +448,7 @@
|
|||||||
unlockBtn.addEventListener("click", async () => {
|
unlockBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
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.state.setSecretStoreState(true);
|
||||||
this.app.notifications.success("Secret store unlocked");
|
this.app.notifications.success("Secret store unlocked");
|
||||||
this._renderTabContent(body);
|
this._renderTabContent(body);
|
||||||
@@ -465,7 +465,7 @@
|
|||||||
});
|
});
|
||||||
lockBtn.addEventListener("click", async () => {
|
lockBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.LockSecretStore();
|
await window.NexusTerminal.wailsService.LockSecretStore();
|
||||||
this.app.state.setSecretStoreState(false);
|
this.app.state.setSecretStoreState(false);
|
||||||
this.app.notifications.info("Secret store locked");
|
this.app.notifications.info("Secret store locked");
|
||||||
this._renderTabContent(body);
|
this._renderTabContent(body);
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
});
|
});
|
||||||
deleteBtn.addEventListener("click", async () => {
|
deleteBtn.addEventListener("click", async () => {
|
||||||
try {
|
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._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
|
||||||
this._renderTabContent(body);
|
this._renderTabContent(body);
|
||||||
this.app.notifications.success("Known host removed");
|
this.app.notifications.success("Known host removed");
|
||||||
@@ -911,8 +911,8 @@
|
|||||||
|
|
||||||
const versionEl = body.querySelector("[data-role='app-version']");
|
const versionEl = body.querySelector("[data-role='app-version']");
|
||||||
if (versionEl) {
|
if (versionEl) {
|
||||||
if (window.go && window.go.api && window.go.api.Service) {
|
if (window.NexusTerminal?.wailsService) {
|
||||||
window.go.api.Service.GetAppInfo()
|
window.NexusTerminal.wailsService.GetAppInfo()
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
const version = (info && info.version) || "dev";
|
const version = (info && info.version) || "dev";
|
||||||
versionEl.textContent = `Version ${version}`;
|
versionEl.textContent = `Version ${version}`;
|
||||||
@@ -931,7 +931,7 @@
|
|||||||
updateBtn.disabled = true;
|
updateBtn.disabled = true;
|
||||||
updateBtn.textContent = "Checking…";
|
updateBtn.textContent = "Checking…";
|
||||||
try {
|
try {
|
||||||
const info = await window.go.api.Service.CheckForUpdates();
|
const info = await window.NexusTerminal.wailsService.CheckForUpdates();
|
||||||
const resultEl = body.querySelector("#settings-update-result");
|
const resultEl = body.querySelector("#settings-update-result");
|
||||||
if (this.app && typeof this.app.setUpdateInfo === "function") {
|
if (this.app && typeof this.app.setUpdateInfo === "function") {
|
||||||
this.app.setUpdateInfo(info);
|
this.app.setUpdateInfo(info);
|
||||||
@@ -979,7 +979,7 @@
|
|||||||
importBtn.disabled = true;
|
importBtn.disabled = true;
|
||||||
importBtn.textContent = "Importing…";
|
importBtn.textContent = "Importing…";
|
||||||
try {
|
try {
|
||||||
const count = await window.go.api.Service.ImportSSHConfig(
|
const count = await window.NexusTerminal.wailsService.ImportSSHConfig(
|
||||||
"",
|
"",
|
||||||
"SSH Config",
|
"SSH Config",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -281,9 +281,9 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (path === null) {
|
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.cwd = path;
|
||||||
state.files = this._sortFiles(files || []);
|
state.files = this._sortFiles(files || []);
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
@@ -603,7 +603,7 @@
|
|||||||
|
|
||||||
async _download(sessionId, filePath) {
|
async _download(sessionId, filePath) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPDownload(sessionId, filePath);
|
await window.NexusTerminal.wailsService.SFTPDownload(sessionId, filePath);
|
||||||
this.app.notifications.success("Download started");
|
this.app.notifications.success("Download started");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error(
|
this.app.notifications.error(
|
||||||
@@ -616,7 +616,7 @@
|
|||||||
var state = this.panels.get(sessionId);
|
var state = this.panels.get(sessionId);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPUpload(sessionId, state.cwd);
|
await window.NexusTerminal.wailsService.SFTPUpload(sessionId, state.cwd);
|
||||||
this.app.notifications.success("Upload complete");
|
this.app.notifications.success("Upload complete");
|
||||||
await this._refresh(sessionId);
|
await this._refresh(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -633,7 +633,7 @@
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
var fullPath = pathJoin(state.cwd, name);
|
var fullPath = pathJoin(state.cwd, name);
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPMkdir(sessionId, fullPath);
|
await window.NexusTerminal.wailsService.SFTPMkdir(sessionId, fullPath);
|
||||||
this.app.notifications.success("Directory created");
|
this.app.notifications.success("Directory created");
|
||||||
await this._refresh(sessionId);
|
await this._refresh(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -646,7 +646,7 @@
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
var newPath = pathJoin(parentPath(oldPath), newName);
|
var newPath = pathJoin(parentPath(oldPath), newName);
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath);
|
await window.NexusTerminal.wailsService.SFTPRename(sessionId, oldPath, newPath);
|
||||||
this.app.notifications.success("Renamed");
|
this.app.notifications.success("Renamed");
|
||||||
await this._refresh(sessionId);
|
await this._refresh(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -656,7 +656,7 @@
|
|||||||
|
|
||||||
async _delete(sessionId, entry) {
|
async _delete(sessionId, entry) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPRemove(sessionId, entry.path);
|
await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entry.path);
|
||||||
this.app.notifications.success(
|
this.app.notifications.success(
|
||||||
(entry.isDir ? "Directory" : "File") + " deleted",
|
(entry.isDir ? "Directory" : "File") + " deleted",
|
||||||
);
|
);
|
||||||
@@ -671,7 +671,7 @@
|
|||||||
var failed = 0;
|
var failed = 0;
|
||||||
for (var i = 0; i < files.length; i++) {
|
for (var i = 0; i < files.length; i++) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPDownload(sessionId, files[i].path);
|
await window.NexusTerminal.wailsService.SFTPDownload(sessionId, files[i].path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed++;
|
failed++;
|
||||||
this.app.notifications.error(
|
this.app.notifications.error(
|
||||||
@@ -705,7 +705,7 @@
|
|||||||
var failed = 0;
|
var failed = 0;
|
||||||
for (var i = 0; i < entries.length; i++) {
|
for (var i = 0; i < entries.length; i++) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPRemove(sessionId, entries[i].path);
|
await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entries[i].path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed++;
|
failed++;
|
||||||
this.app.notifications.error(
|
this.app.notifications.error(
|
||||||
|
|||||||
@@ -441,7 +441,7 @@
|
|||||||
async _toggleFavourite(profile) {
|
async _toggleFavourite(profile) {
|
||||||
try {
|
try {
|
||||||
const newVal = !profile.favourite;
|
const newVal = !profile.favourite;
|
||||||
await window.go.api.Service.SetFavourite(profile.id, newVal);
|
await window.NexusTerminal.wailsService.SetFavourite(profile.id, newVal);
|
||||||
profile.favourite = newVal;
|
profile.favourite = newVal;
|
||||||
this.app.state.updateProfile(profile);
|
this.app.state.updateProfile(profile);
|
||||||
this.render();
|
this.render();
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
*/
|
*/
|
||||||
async _duplicateProfile(profile) {
|
async _duplicateProfile(profile) {
|
||||||
try {
|
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.app.state.addProfile(dup);
|
||||||
this.render();
|
this.render();
|
||||||
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
|
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
|
||||||
@@ -478,7 +478,7 @@
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.DeleteProfile(profile.id);
|
await window.NexusTerminal.wailsService.DeleteProfile(profile.id);
|
||||||
this.app.state.removeProfile(profile.id);
|
this.app.state.removeProfile(profile.id);
|
||||||
this.render();
|
this.render();
|
||||||
this.app.notifications.success("Profile deleted");
|
this.app.notifications.success("Profile deleted");
|
||||||
@@ -687,7 +687,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const saved = await window.go.api.Service.UpdateProfile(updated);
|
const saved = await window.NexusTerminal.wailsService.UpdateProfile(updated);
|
||||||
this.app.state.updateProfile(saved);
|
this.app.state.updateProfile(saved);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error("Failed to update tags: " + err);
|
this.app.notifications.error("Failed to update tags: " + err);
|
||||||
@@ -708,7 +708,7 @@
|
|||||||
const updated = JSON.parse(JSON.stringify(p));
|
const updated = JSON.parse(JSON.stringify(p));
|
||||||
updated.folder = folder;
|
updated.folder = folder;
|
||||||
try {
|
try {
|
||||||
const saved = await window.go.api.Service.UpdateProfile(updated);
|
const saved = await window.NexusTerminal.wailsService.UpdateProfile(updated);
|
||||||
this.app.state.updateProfile(saved);
|
this.app.state.updateProfile(saved);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error("Failed to move group: " + err);
|
this.app.notifications.error("Failed to move group: " + err);
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
_sendCommand(sessionId, command) {
|
_sendCommand(sessionId, command) {
|
||||||
try {
|
try {
|
||||||
const b64 = window.NexusTerminal.utf8ToBase64(command + "\n");
|
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");
|
this.app.notifications.info("Snippet sent");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error("Failed to send snippet: " + err);
|
this.app.notifications.error("Failed to send snippet: " + err);
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
/** @private */
|
/** @private */
|
||||||
async _loadSnippets() {
|
async _loadSnippets() {
|
||||||
try {
|
try {
|
||||||
this.snippets = (await window.go.api.Service.ListSnippets()) || [];
|
this.snippets = (await window.NexusTerminal.wailsService.ListSnippets()) || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.snippets = [];
|
this.snippets = [];
|
||||||
console.error("Failed to load snippets:", err);
|
console.error("Failed to load snippets:", err);
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
});
|
});
|
||||||
deleteBtn.addEventListener("click", async () => {
|
deleteBtn.addEventListener("click", async () => {
|
||||||
try {
|
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.snippets = this.snippets.filter((s) => s.id !== snippet.id);
|
||||||
this._renderList(modal, closeFn);
|
this._renderList(modal, closeFn);
|
||||||
detailPanel.innerHTML =
|
detailPanel.innerHTML =
|
||||||
@@ -418,10 +418,10 @@
|
|||||||
|
|
||||||
let saved;
|
let saved;
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
saved = await window.go.api.Service.CreateSnippet(data);
|
saved = await window.NexusTerminal.wailsService.CreateSnippet(data);
|
||||||
this.snippets.push(saved);
|
this.snippets.push(saved);
|
||||||
} else {
|
} 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);
|
const idx = this.snippets.findIndex((s) => s.id === saved.id);
|
||||||
if (idx !== -1) this.snippets[idx] = saved;
|
if (idx !== -1) this.snippets[idx] = saved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ class SplitPaneManager {
|
|||||||
if (placeholder) placeholder.remove();
|
if (placeholder) placeholder.remove();
|
||||||
|
|
||||||
// Connect via backend
|
// 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);
|
self.app.state.addSession(sessionInfo);
|
||||||
|
|
||||||
// Create a terminal container inside this pane
|
// Create a terminal container inside this pane
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
|
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keys = await window.go.api.Service.ListSSHKeyPairs();
|
const keys = await window.NexusTerminal.wailsService.ListSSHKeyPairs();
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
|
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
|
|
||||||
async _copyPublicKey(key) {
|
async _copyPublicKey(key) {
|
||||||
try {
|
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) {
|
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||||
window.runtime.ClipboardSetText(pubKey);
|
window.runtime.ClipboardSetText(pubKey);
|
||||||
} else {
|
} else {
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
|
|
||||||
async _deleteKey(key) {
|
async _deleteKey(key) {
|
||||||
try {
|
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.app.notifications.success(`Key "${key.name}" deleted`);
|
||||||
this._loadKeys();
|
this._loadKeys();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.GenerateSSHKey(
|
await window.NexusTerminal.wailsService.GenerateSSHKey(
|
||||||
name,
|
name,
|
||||||
keyType,
|
keyType,
|
||||||
bits,
|
bits,
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
if (session) {
|
if (session) {
|
||||||
this.app.sidebar.highlightActive(session.profileId);
|
this.app.sidebar.highlightActive(session.profileId);
|
||||||
}
|
}
|
||||||
|
this.app.leviathan?.handleTabFocus?.(sessionId);
|
||||||
|
|
||||||
// Fit and refresh the terminal for the newly active session.
|
// Fit and refresh the terminal for the newly active session.
|
||||||
// Double-RAF ensures the panel has layout dimensions before fitting.
|
// Double-RAF ensures the panel has layout dimensions before fitting.
|
||||||
@@ -169,7 +170,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.DisconnectSession(sessionId);
|
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Session may already be closed — carry on
|
// Session may already be closed — carry on
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -155,7 +155,7 @@
|
|||||||
const data = seq.repeat(lines);
|
const data = seq.repeat(lines);
|
||||||
const b64 = utf8ToBase64(data);
|
const b64 = utf8ToBase64(data);
|
||||||
try {
|
try {
|
||||||
window.go.api.Service.WriteSession(sessionId, b64);
|
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* non-critical */
|
/* non-critical */
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
try {
|
try {
|
||||||
const b64 = utf8ToBase64(data);
|
const b64 = utf8ToBase64(data);
|
||||||
window.go.api.Service.WriteSession(sessionId, b64);
|
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||||
|
|
||||||
// Broadcast to other connected sessions if enabled
|
// Broadcast to other connected sessions if enabled
|
||||||
if (this.app.state.broadcastMode) {
|
if (this.app.state.broadcastMode) {
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
const otherSession = this.app.state.getSession(otherId);
|
const otherSession = this.app.state.getSession(otherId);
|
||||||
if (otherSession && otherSession.state === "connected") {
|
if (otherSession && otherSession.state === "connected") {
|
||||||
try {
|
try {
|
||||||
window.go.api.Service.WriteSession(otherId, b64);
|
window.NexusTerminal.wailsService.WriteSession(otherId, b64);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* non-critical */
|
/* non-critical */
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
// Notify backend when terminal dimensions change
|
// Notify backend when terminal dimensions change
|
||||||
term.onResize(({ cols, rows }) => {
|
term.onResize(({ cols, rows }) => {
|
||||||
try {
|
try {
|
||||||
window.go.api.Service.ResizeSession(sessionId, cols, rows);
|
window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ResizeSession error:", err);
|
console.error("ResizeSession error:", err);
|
||||||
}
|
}
|
||||||
@@ -895,7 +895,7 @@
|
|||||||
const totalBytes = typeof file.size === "number" ? file.size : 0;
|
const totalBytes = typeof file.size === "number" ? file.size : 0;
|
||||||
let uploadId = "";
|
let uploadId = "";
|
||||||
try {
|
try {
|
||||||
uploadId = await window.go.api.Service.SFTPUploadStart(
|
uploadId = await window.NexusTerminal.wailsService.SFTPUploadStart(
|
||||||
sessionId,
|
sessionId,
|
||||||
remoteDir,
|
remoteDir,
|
||||||
file.name,
|
file.name,
|
||||||
@@ -906,14 +906,14 @@
|
|||||||
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, totalBytes);
|
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, totalBytes);
|
||||||
const buffer = await this._readFileSlice(file, offset, end);
|
const buffer = await this._readFileSlice(file, offset, end);
|
||||||
const base64Data = this._arrayBufferToBase64(buffer);
|
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) {
|
} catch (err) {
|
||||||
if (uploadId) {
|
if (uploadId) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPUploadCancel(uploadId);
|
await window.NexusTerminal.wailsService.SFTPUploadCancel(uploadId);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
@@ -1167,12 +1167,12 @@
|
|||||||
renderLoading();
|
renderLoading();
|
||||||
try {
|
try {
|
||||||
const targetPath =
|
const targetPath =
|
||||||
path || (await window.go.api.Service.SFTPPwd(sessionId));
|
path || (await window.NexusTerminal.wailsService.SFTPPwd(sessionId));
|
||||||
const files = await window.go.api.Service.SFTPList(
|
const files = await window.NexusTerminal.wailsService.SFTPList(
|
||||||
sessionId,
|
sessionId,
|
||||||
targetPath,
|
targetPath,
|
||||||
);
|
);
|
||||||
const stat = await window.go.api.Service.SFTPStat(
|
const stat = await window.NexusTerminal.wailsService.SFTPStat(
|
||||||
sessionId,
|
sessionId,
|
||||||
targetPath,
|
targetPath,
|
||||||
);
|
);
|
||||||
@@ -1446,7 +1446,7 @@
|
|||||||
const renderForwards = async () => {
|
const renderForwards = async () => {
|
||||||
try {
|
try {
|
||||||
const forwards =
|
const forwards =
|
||||||
(await window.go.api.Service.ListPortForwards(sessionId)) || [];
|
(await window.NexusTerminal.wailsService.ListPortForwards(sessionId)) || [];
|
||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
if (forwards.length === 0) {
|
if (forwards.length === 0) {
|
||||||
listEl.innerHTML =
|
listEl.innerHTML =
|
||||||
@@ -1472,7 +1472,7 @@
|
|||||||
});
|
});
|
||||||
stopBtn.addEventListener("click", async () => {
|
stopBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.StopPortForward(sessionId, fwd.id);
|
await window.NexusTerminal.wailsService.StopPortForward(sessionId, fwd.id);
|
||||||
self.app.notifications.info("Forward stopped");
|
self.app.notifications.info("Forward stopped");
|
||||||
renderForwards();
|
renderForwards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1517,7 +1517,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.StartPortForward(sessionId, {
|
await window.NexusTerminal.wailsService.StartPortForward(sessionId, {
|
||||||
id: "",
|
id: "",
|
||||||
direction: direction,
|
direction: direction,
|
||||||
bindHost: bindHost,
|
bindHost: bindHost,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
(function () {
|
||||||
|
const root = (window.NexusTerminal = window.NexusTerminal || {});
|
||||||
|
|
||||||
|
const directService = () => window.go?.api?.Service || null;
|
||||||
|
const unavailable = (name) =>
|
||||||
|
Promise.reject(new Error(`Wails service method is not available: ${String(name)}`));
|
||||||
|
|
||||||
|
root.wailsService = new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, prop) {
|
||||||
|
if (prop === "then") return undefined;
|
||||||
|
const service = directService();
|
||||||
|
const value = service?.[prop];
|
||||||
|
if (typeof value === "function") return value.bind(service);
|
||||||
|
if (value !== undefined) return value;
|
||||||
|
return () => unavailable(prop);
|
||||||
|
},
|
||||||
|
has(_target, prop) {
|
||||||
|
return prop in (directService() || {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
root.wailsServiceReady = Promise.resolve(root.wailsService);
|
||||||
|
root.hasWailsService = () =>
|
||||||
|
Boolean(root.wailsService && window.runtime && directService());
|
||||||
|
})();
|
||||||
Vendored
+2
@@ -96,6 +96,8 @@ export function IsSecretStoreUnlocked():Promise<boolean>;
|
|||||||
|
|
||||||
export function LeviathanExportJSON(arg1:string,arg2:Record<string, any>):Promise<string>;
|
export function LeviathanExportJSON(arg1:string,arg2:Record<string, any>):Promise<string>;
|
||||||
|
|
||||||
|
export function LeviathanExportRange(arg1:string,arg2:number,arg3:number):Promise<string>;
|
||||||
|
|
||||||
export function LeviathanLoadFile(arg1:string,arg2:boolean):Promise<Record<string, any>>;
|
export function LeviathanLoadFile(arg1:string,arg2:boolean):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function LeviathanOpenCompare():Promise<Record<string, any>>;
|
export function LeviathanOpenCompare():Promise<Record<string, any>>;
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ export function LeviathanExportJSON(arg1, arg2) {
|
|||||||
return window['go']['api']['Service']['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) {
|
export function LeviathanLoadFile(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['LeviathanLoadFile'](arg1, arg2);
|
return window['go']['api']['Service']['LeviathanLoadFile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestReadLeviathanSmallFileRejectsOversizedFile(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "big.img")
|
path := filepath.Join(t.TempDir(), "big.img")
|
||||||
if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil {
|
if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil {
|
||||||
|
|||||||
+118
-7
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -236,6 +237,44 @@ func (s *Service) LeviathanExportJSON(name string, value map[string]any) (string
|
|||||||
return path, os.WriteFile(path, b, 0600)
|
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 {
|
func (l *leviathanState) stateLocked() leviathanDocState {
|
||||||
state := leviathanDocState{}
|
state := leviathanDocState{}
|
||||||
if l.doc != nil {
|
if l.doc != nil {
|
||||||
@@ -323,12 +362,8 @@ func (l *leviathanState) runToolLocked(tool string, params map[string]any) (map[
|
|||||||
entries, err := fs.Walk()
|
entries, err := fs.Walk()
|
||||||
return map[string]any{"superblock": fs.Superblock(), "compression": fs.CompressionName(), "entries": entries}, err
|
return map[string]any{"superblock": fs.Superblock(), "compression": fs.CompressionName(), "entries": entries}, err
|
||||||
case "binsnoop":
|
case "binsnoop":
|
||||||
buf, err := l.rangeBytes(0, min64(l.doc.Size(), int64Param(params, "maxBytes", 64<<20)))
|
results, err := leviathanBinsnoopDocument(l.doc, intParam(params, "max", 10000))
|
||||||
if err != nil {
|
return map[string]any{"results": results, "signatures": binsnoop.ListSignatures()}, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
scanner := binsnoop.New()
|
|
||||||
return map[string]any{"results": scanner.AnalyzeBytes(buf, l.doc.Name(), false), "signatures": binsnoop.ListSignatures()}, nil
|
|
||||||
case "rules":
|
case "rules":
|
||||||
rules, err := analysis.ParseRules(stringParam(params, "source", ""))
|
rules, err := analysis.ParseRules(stringParam(params, "source", ""))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -565,12 +600,16 @@ func (s *Service) leviathanSpectrogramInsert(doc *document.Document, params map[
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeLeviathanDocumentFile(path string, doc *document.Document) error {
|
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)
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
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)
|
_, err := f.Write(chunk)
|
||||||
return err
|
return err
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -856,6 +895,78 @@ func boolParam(m map[string]any, key string, def bool) bool {
|
|||||||
return def
|
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 {
|
func leviathanSpectrogramEncodeOptions(params map[string]any) spectrogram.EncodeOptions {
|
||||||
return spectrogram.EncodeOptions{
|
return spectrogram.EncodeOptions{
|
||||||
DurationSec: floatParam(params, "durationSec", 5),
|
DurationSec: floatParam(params, "durationSec", 5),
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
// Wire the API service with all backing stores now that they exist.
|
// 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 = *api.NewService(store, a.SecretStore, a.KeychainStore, a.SessionManager, a.AuditLog, a.Prefs)
|
||||||
a.Service.SetContext(ctx)
|
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
|
// 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 {
|
if a.SecretStore != nil {
|
||||||
a.SecretStore.Lock()
|
a.SecretStore.Lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Nexus Terminal shut down cleanly")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DomReady is called by Wails once the frontend DOM is fully loaded. It emits
|
// 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{}{
|
runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{
|
||||||
"message": "Backend is ready",
|
"message": "Backend is ready",
|
||||||
})
|
})
|
||||||
log.Println("DOM ready – backend:ready event emitted")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmitOpenAbout tells the frontend to open the About tab.
|
// EmitOpenAbout tells the frontend to open the About tab.
|
||||||
|
|||||||
+43
-12
@@ -65,6 +65,7 @@ release_targets() {
|
|||||||
|
|
||||||
check_deps() {
|
check_deps() {
|
||||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
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 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 npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
|
||||||
command -v zip >/dev/null 2>&1 || { err "zip is not installed"; exit 1; }
|
command -v 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"
|
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ensure_upx
|
||||||
}
|
}
|
||||||
|
|
||||||
install_deps() {
|
install_deps() {
|
||||||
@@ -105,6 +108,30 @@ build_ldflags() {
|
|||||||
nexus_build_ldflags "$1"
|
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() {
|
pick_cc() {
|
||||||
local target_os="$1"
|
local target_os="$1"
|
||||||
local target_arch="$2"
|
local target_arch="$2"
|
||||||
@@ -175,10 +202,24 @@ build_target() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "Building ${platform}..."
|
log "Building ${platform}..."
|
||||||
|
nexus_configure_release_cgo
|
||||||
local ldflags
|
local ldflags
|
||||||
ldflags="$(build_ldflags "$version")"
|
ldflags="$(build_ldflags "$version")"
|
||||||
local tags
|
local tags
|
||||||
tags="$(build_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
|
if [ -n "$cc" ]; then
|
||||||
local cc_cmd="$cc"
|
local cc_cmd="$cc"
|
||||||
if [ -n "$zig_target" ]; then
|
if [ -n "$zig_target" ]; then
|
||||||
@@ -188,22 +229,12 @@ build_target() {
|
|||||||
cc_cmd="zig cc -target ${zig_target}-windows-gnu"
|
cc_cmd="zig cc -target ${zig_target}-windows-gnu"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [ -n "$tags" ]; then
|
if ! CC="$cc_cmd" CGO_ENABLED=1 wails build "${build_args[@]}"; 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
|
|
||||||
warn "Build failed for ${platform}"
|
warn "Build failed for ${platform}"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
if [ -n "$tags" ]; then
|
if ! CGO_ENABLED=1 wails build "${build_args[@]}"; 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
|
|
||||||
warn "Build failed for ${platform}"
|
warn "Build failed for ${platform}"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
+29
-1
@@ -6,6 +6,34 @@ nexus_wails_version() {
|
|||||||
awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' "$go_mod"
|
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() {
|
nexus_build_tags() {
|
||||||
if command -v pkg-config >/dev/null 2>&1 \
|
if command -v pkg-config >/dev/null 2>&1 \
|
||||||
&& pkg-config --exists webkit2gtk-4.1 \
|
&& pkg-config --exists webkit2gtk-4.1 \
|
||||||
@@ -24,7 +52,7 @@ nexus_build_version() {
|
|||||||
|
|
||||||
nexus_build_ldflags() {
|
nexus_build_ldflags() {
|
||||||
local version="$1"
|
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
|
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
|
||||||
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
|
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user