From c656d2ff44065c10f575593a3953733dfee7bf47 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Tue, 28 Apr 2026 09:35:53 +0100 Subject: [PATCH] Final pre-launch version: Update logic --- Makefile | 11 +++- build-all.sh | 39 +++++++++--- build.ps1 | 30 +++++++++- build.sh | 21 ++++++- frontend/index.html | 3 + frontend/src/css/layout.css | 21 +++++++ frontend/src/js/app.js | 52 ++++++++++++++++ frontend/src/js/settings.js | 21 ++++++- internal/api/api.go | 16 ++++- internal/app/app.go | 24 ++++++++ internal/updater/updater.go | 100 +++++++++++++++++++++++++++++++ internal/updater/updater_test.go | 40 +++++++++++++ main.go | 35 +++++++++-- 13 files changed, 392 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index 5f319f1..f5f2e0d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ -.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package help +.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package release help # Resolve the Wails binary. Prefer wails on PATH; fall back to GOPATH/bin. WAILS := $(or $(shell which wails 2>/dev/null),$(shell go env GOPATH)/bin/wails) +VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo dev)-$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +LD_UPDATE_URL := $(if $(NEXUS_UPDATE_URL),-X github.com/andydixon/nexus/internal/api.appUpdateURL=$(NEXUS_UPDATE_URL),) # Default target all: build @@ -20,7 +22,7 @@ dev: deps # Production build build: deps - $(WAILS) build -ldflags "-s -w" + $(WAILS) build -ldflags "-s -w -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)" # Run tests test: @@ -47,6 +49,10 @@ lint: package: build @echo "Binary built at build/bin/" +# Build and package release artefacts for all platforms +release: + ./release.sh + # Help help: @echo "Nexus Terminal — Build Targets" @@ -59,4 +65,5 @@ help: @echo " make clean — Clean build artefacts" @echo " make lint — Run linter (requires golangci-lint)" @echo " make package — Build and package for distribution" + @echo " make release — Build multi-platform release artefacts" @echo " make help — Show this help" diff --git a/build-all.sh b/build-all.sh index 5a639b9..e52d7b0 100755 --- a/build-all.sh +++ b/build-all.sh @@ -16,6 +16,23 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; } warn() { echo -e "${YELLOW}[nexus]${NC} $1"; } err() { echo -e "${RED}[nexus]${NC} $1" >&2; } +build_version() { + local tag + local commit + tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")" + commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")" + echo "${tag}-${commit}" +} + +build_ldflags() { + local version="$1" + local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}" + if [ -n "${NEXUS_UPDATE_URL:-}" ]; then + flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}" + fi + echo "${flags}" +} + check_deps() { command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; } command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; } @@ -80,6 +97,9 @@ build_target() { local target_os="$1" local target_arch="$2" local host="$3" + local version="$4" + local ldflags + ldflags="$(build_ldflags "${version}")" local platform="${target_os}/${target_arch}" local cc="" @@ -94,9 +114,9 @@ build_target() { log "Building ${platform}…" if [ -n "$cc" ]; then - CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "-s -w" + CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" else - CGO_ENABLED=1 wails build -platform "$platform" -ldflags "-s -w" + CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" fi } @@ -104,6 +124,9 @@ main() { check_deps install_deps + local version + version="$(build_version)" + local host host="$(host_os)" if [ "$host" = "unknown" ]; then @@ -112,15 +135,15 @@ main() { fi # Build host targets first - build_target "$host" "amd64" "$host" - build_target "$host" "arm64" "$host" + build_target "$host" "amd64" "$host" "$version" + build_target "$host" "arm64" "$host" "$version" # Cross-build Linux and Windows targets where toolchains are available if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then - build_target "linux" "amd64" "$host" - build_target "linux" "arm64" "$host" - build_target "windows" "amd64" "$host" - build_target "windows" "arm64" "$host" + build_target "linux" "amd64" "$host" "$version" + build_target "linux" "arm64" "$host" "$version" + build_target "windows" "amd64" "$host" "$version" + build_target "windows" "arm64" "$host" "$version" fi log "Builds complete. Output: build/bin/" diff --git a/build.ps1 b/build.ps1 index 509493e..5363e06 100644 --- a/build.ps1 +++ b/build.ps1 @@ -48,6 +48,32 @@ function Install-Deps { Pop-Location } +function Get-BuildVersion { + try { + $tag = & git describe --tags --abbrev=0 2>$null + if (-not $tag) { $tag = 'dev' } + } catch { + $tag = 'dev' + } + + try { + $commit = & git rev-parse --short HEAD 2>$null + if (-not $commit) { $commit = 'unknown' } + } catch { + $commit = 'unknown' + } + + return "$tag-$commit" +} + +function Get-Ldflags([string]$Version) { + $flags = "-s -w -X github.com/andydixon/nexus/internal/api.appVersion=$Version" + if ($env:NEXUS_UPDATE_URL) { + $flags += " -X github.com/andydixon/nexus/internal/api.appUpdateURL=$($env:NEXUS_UPDATE_URL)" + } + return $flags +} + function Dev { $wailsPath = Check-Deps Install-Deps @@ -58,10 +84,12 @@ function Dev { function Build { $wailsPath = Check-Deps Install-Deps + $version = Get-BuildVersion + $ldflags = Get-Ldflags $version $env:CGO_ENABLED = '1' Write-Info 'Building production binary (CGO_ENABLED=1)...' - & $wailsPath build -ldflags "-s -w" + & $wailsPath build -ldflags $ldflags Write-Info 'Build complete: build\bin\' } diff --git a/build.sh b/build.sh index a8ab195..c394099 100755 --- a/build.sh +++ b/build.sh @@ -17,6 +17,23 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; } warn() { echo -e "${YELLOW}[nexus]${NC} $1"; } err() { echo -e "${RED}[nexus]${NC} $1" >&2; } +build_version() { + local tag + local commit + tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")" + commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")" + echo "${tag}-${commit}" +} + +build_ldflags() { + local version="$1" + local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}" + if [ -n "${NEXUS_UPDATE_URL:-}" ]; then + flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}" + fi + echo "${flags}" +} + # Check prerequisites check_deps() { command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; } @@ -62,7 +79,9 @@ build() { check_deps install_deps log "Building production binary…" - wails build -ldflags "-s -w" + local version + version="$(build_version)" + wails build -ldflags "$(build_ldflags "${version}")" log "Build complete: build/bin/" } diff --git a/frontend/index.html b/frontend/index.html index 3adf795..652c4dc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -269,6 +269,9 @@ 0 sessions + 🔒 Locked diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css index 504aa62..fabca0f 100644 --- a/frontend/src/css/layout.css +++ b/frontend/src/css/layout.css @@ -125,6 +125,27 @@ flex: 1; } +.status-update { + border: 1px solid var(--accent); + background: rgba(78, 205, 196, 0.12); + color: var(--accent); + font-size: var(--text-xs); + padding: 2px 8px; + border-radius: 999px; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.status-update:hover { + background: rgba(78, 205, 196, 0.2); + color: var(--text-primary); + border-color: var(--accent-strong, var(--accent)); +} + +.status-update.is-hidden { + display: none; +} + /* Status bar indicator dots */ .status-bar .status-dot { width: 6px; diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index f3ec615..fec4e76 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -40,6 +40,7 @@ this.exportImport = new ExportImportUI(this); this.sftp = new SFTPBrowser(this); this.snippets = new SnippetLibrary(this); + this.updateInfo = null; } /** @@ -61,6 +62,7 @@ await this.loadPreferences(); await this.checkSecretStore(); this.updateStatusBar(); + this.checkForUpdates(true); } /** @@ -254,6 +256,11 @@ ); }); + // App-wide commands from the native menu + window.runtime.EventsOn("app:open-about", () => { + this.settings.openTab("about"); + }); + // Port-forwarding events window.runtime.EventsOn("session:forward:started", (payload) => { const fwd = payload.forward || {}; @@ -503,6 +510,12 @@ this._bindClick("btn-empty-connect", () => this.profileEditor.openNew()); this._bindClick("btn-empty-quick", () => this.quickConnect()); + const updateBtn = document.getElementById("status-update"); + if (updateBtn) { + updateBtn.addEventListener("mousedown", (e) => e.preventDefault()); + updateBtn.addEventListener("click", () => this.settings.openTab("about")); + } + const searchInput = document.getElementById("profile-search"); if (searchInput) { searchInput.addEventListener("input", (e) => @@ -851,6 +864,7 @@ const connEl = document.getElementById("status-connection"); const countEl = document.getElementById("status-session-count"); const vaultEl = document.getElementById("status-vault"); + const updateEl = document.getElementById("status-update"); if (!connEl) return; const sessions = this.state.getAllSessions(); @@ -874,6 +888,44 @@ ? "🔓 Unlocked" : "🔒 Locked"; } + + if (updateEl) { + if (this.updateInfo && this.updateInfo.available) { + const version = this.updateInfo.latestVersion + ? ` (${this.updateInfo.latestVersion})` + : ""; + updateEl.textContent = `Update available${version}`; + updateEl.classList.remove("is-hidden"); + } else { + updateEl.textContent = "Update available"; + updateEl.classList.add("is-hidden"); + } + } + } + + async checkForUpdates(silent) { + if (!window.go || !window.go.api || !window.go.api.Service) { + return; + } + + try { + const info = await window.go.api.Service.CheckForUpdates(); + this.setUpdateInfo(info); + if (!silent && info && info.available) { + this.notifications.info( + `Update available: ${info.latestVersion || "new version"}`, + ); + } + } catch (err) { + if (!silent) { + this.notifications.error("Update check failed: " + err); + } + } + } + + setUpdateInfo(info) { + this.updateInfo = info || null; + this.updateStatusBar(); } // ── Internal helpers ───────────────────────────────────────────── diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js index ba8e846..fc328a6 100644 --- a/frontend/src/js/settings.js +++ b/frontend/src/js/settings.js @@ -848,7 +848,7 @@ body.innerHTML = `

Nexus Terminal

-

Version 1.0.0

+

Version …

Developed by Andy Dixon - www.dixon.cx/nexus

`; + const versionEl = body.querySelector("[data-role='app-version']"); + if (versionEl) { + if (window.go && window.go.api && window.go.api.Service) { + window.go.api.Service.GetAppInfo() + .then((info) => { + const version = (info && info.version) || "dev"; + versionEl.textContent = `Version ${version}`; + }) + .catch(() => { + versionEl.textContent = "Version unavailable"; + }); + } else { + versionEl.textContent = "Version unavailable"; + } + } + const updateBtn = body.querySelector("#settings-check-update"); if (updateBtn) { updateBtn.addEventListener("click", async () => { @@ -872,6 +888,9 @@ try { const info = await window.go.api.Service.CheckForUpdates(); const resultEl = body.querySelector("#settings-update-result"); + if (this.app && typeof this.app.setUpdateInfo === "function") { + this.app.setUpdateInfo(info); + } if (info.available) { resultEl.innerHTML = `Update available: ${info.latestVersion}` + diff --git a/internal/api/api.go b/internal/api/api.go index 3e8ea7f..ebf5c5f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -36,6 +36,9 @@ import ( // appVersion is set at build time via ldflags. var appVersion = "dev" +// appUpdateURL is set at build time via ldflags. +var appUpdateURL = "" + // Service is the main API surface exposed to the frontend via Wails bindings. type Service struct { store *storage.Store @@ -1298,8 +1301,17 @@ func (s *Service) ImportSSHConfig(configPath string, folder string) (int, error) // CheckForUpdates queries GitHub Releases for a newer version. func (s *Service) CheckForUpdates() (map[string]interface{}, error) { - checker := updater.NewChecker(appVersion) - info, err := checker.Check(context.Background()) + ctx := context.Background() + var info *updater.UpdateInfo + var err error + + if appUpdateURL != "" { + checker := updater.NewURLChecker(appUpdateURL, appVersion) + info, err = checker.Check(ctx) + } else { + checker := updater.NewChecker(appVersion) + info, err = checker.Check(ctx) + } if err != nil { return nil, err } diff --git a/internal/app/app.go b/internal/app/app.go index 071d475..507399c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -146,3 +146,27 @@ func (a *App) DomReady(ctx context.Context) { }) log.Println("DOM ready – backend:ready event emitted") } + +// EmitOpenAbout tells the frontend to open the About tab. +func (a *App) EmitOpenAbout() { + a.mu.Lock() + ctx := a.ctx + a.mu.Unlock() + if ctx == nil { + log.Println("unable to open About: application context not initialised") + return + } + runtime.EventsEmit(ctx, "app:open-about", nil) +} + +// RequestQuit asks the runtime to quit the application. +func (a *App) RequestQuit() { + a.mu.Lock() + ctx := a.ctx + a.mu.Unlock() + if ctx == nil { + log.Println("unable to quit: application context not initialised") + return + } + runtime.Quit(ctx) +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 72001a3..1cc673d 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "strings" "time" @@ -56,6 +57,23 @@ type Checker struct { client *http.Client } +// Manifest describes a self-hosted update manifest. +type Manifest struct { + LatestVersion string `json:"latestVersion"` + Version string `json:"version"` + ReleaseURL string `json:"releaseURL"` + ReleaseName string `json:"releaseName"` + ReleaseNotes string `json:"releaseNotes"` + PublishedAt string `json:"publishedAt"` +} + +// URLChecker performs version checks against a self-hosted update manifest. +type URLChecker struct { + url string + current string + client *http.Client +} + // NewChecker creates an update checker for the given current version. // The version string should be a semver tag (e.g. "v1.2.3" or "1.2.3"). func NewChecker(currentVersion string) *Checker { @@ -69,6 +87,17 @@ func NewChecker(currentVersion string) *Checker { } } +// NewURLChecker creates a checker for a self-hosted update manifest URL. +func NewURLChecker(url string, currentVersion string) *URLChecker { + return &URLChecker{ + url: url, + current: normaliseVersion(currentVersion), + client: &http.Client{ + Timeout: requestTimeout, + }, + } +} + // SetRepository overrides the GitHub repository to check. func (c *Checker) SetRepository(owner, repo string) { c.owner = owner @@ -134,6 +163,51 @@ func (c *Checker) Check(ctx context.Context) (*UpdateInfo, error) { }, nil } +// Check queries the update manifest URL and compares it to the current version. +func (c *URLChecker) Check(ctx context.Context) (*UpdateInfo, error) { + if c.url == "" { + return nil, fmt.Errorf("updater: update manifest URL is empty") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) + if err != nil { + return nil, fmt.Errorf("updater: failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json, text/plain") + req.Header.Set("User-Agent", "NexusTerminal-UpdateChecker") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("updater: request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("updater: update server returned %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("updater: failed to read response: %w", err) + } + + info, ok := parseManifest(body, c.current) + if ok { + return info, nil + } + + latest := normaliseVersion(strings.TrimSpace(string(body))) + if latest == "" { + return nil, fmt.Errorf("updater: update manifest is empty") + } + + return &UpdateInfo{ + Available: isNewer(latest, c.current), + CurrentVersion: c.current, + LatestVersion: latest, + }, nil +} + // normaliseVersion strips a leading "v" and trims whitespace. func normaliseVersion(v string) string { v = strings.TrimSpace(v) @@ -141,6 +215,32 @@ func normaliseVersion(v string) string { return v } +func parseManifest(body []byte, current string) (*UpdateInfo, bool) { + var manifest Manifest + if err := json.Unmarshal(body, &manifest); err != nil { + return nil, false + } + + latest := manifest.LatestVersion + if latest == "" { + latest = manifest.Version + } + latest = normaliseVersion(latest) + if latest == "" { + return nil, false + } + + return &UpdateInfo{ + Available: isNewer(latest, current), + CurrentVersion: current, + LatestVersion: latest, + ReleaseURL: manifest.ReleaseURL, + ReleaseName: manifest.ReleaseName, + ReleaseNotes: manifest.ReleaseNotes, + PublishedAt: manifest.PublishedAt, + }, true +} + // isNewer performs a simple string comparison of dot-separated version parts. // Returns true if latest > current (basic semver, no pre-release handling). func isNewer(latest, current string) bool { diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 7223d7e..bf55f7a 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -1,6 +1,9 @@ package updater import ( + "context" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -64,3 +67,40 @@ func TestTruncateNotes(t *testing.T) { t.Errorf("expected 501 runes, got %d", len([]rune(got))) } } + +func TestURLCheckerManifestJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"latestVersion":"1.2.3","releaseURL":"https://example.com/release"}`)) + })) + defer server.Close() + + checker := NewURLChecker(server.URL, "1.2.0") + info, err := checker.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.Available { + t.Fatalf("expected update to be available") + } + if info.LatestVersion != "1.2.3" { + t.Fatalf("unexpected latest version: %s", info.LatestVersion) + } +} + +func TestURLCheckerPlainText(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("2.0.0")) + })) + defer server.Close() + + checker := NewURLChecker(server.URL, "1.9.0") + info, err := checker.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.Available { + t.Fatalf("expected update to be available") + } +} diff --git a/main.go b/main.go index adf1be2..7a33b06 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "log" "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/menu" + "github.com/wailsapp/wails/v2/pkg/menu/keys" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/linux" @@ -16,6 +18,31 @@ import ( "github.com/andydixon/nexus/frontend" ) +func buildAppMenu(app *application.App) *menu.Menu { + mainMenu := menu.NewMenu() + + appMenu := menu.NewMenu() + appMenu.AddText("About Nexus Terminal", nil, func(_ *menu.CallbackData) { + app.EmitOpenAbout() + }) + appMenu.AddSeparator() + appMenu.AddText("Quit Nexus Terminal", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) { + app.RequestQuit() + }) + + helpMenu := menu.NewMenu() + helpMenu.AddText("About Nexus Terminal", nil, func(_ *menu.CallbackData) { + app.EmitOpenAbout() + }) + + mainMenu.Append(menu.SubMenu("Nexus Terminal", appMenu)) + mainMenu.Append(menu.EditMenu()) + mainMenu.Append(menu.WindowMenu()) + mainMenu.Append(menu.SubMenu("Help", helpMenu)) + + return mainMenu +} + func main() { nexus := application.NewApp() @@ -35,13 +62,9 @@ func main() { OnStartup: nexus.Startup, OnShutdown: nexus.Shutdown, OnDomReady: nexus.DomReady, + Menu: buildAppMenu(nexus), // macOS: enable the green fullscreen traffic-light button. - Mac: &mac.Options{ - About: &mac.AboutInfo{ - Title: "Nexus Terminal", - Message: "A premium desktop terminal client", - }, - }, + Mac: &mac.Options{}, // Windows: default decorations include maximise; fullscreen via F11. Windows: &windows.Options{}, // Linux: use the default window manager decorations (fullscreen via WM).