Final pre-launch version: Update logic

This commit is contained in:
Andy Dixon
2026-04-28 09:35:53 +01:00
parent 5699d17fb6
commit c656d2ff44
13 changed files with 392 additions and 21 deletions
+9 -2
View File
@@ -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"
+31 -8
View File
@@ -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/"
+29 -1
View File
@@ -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\'
}
+20 -1
View File
@@ -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/"
}
+3
View File
@@ -269,6 +269,9 @@
<span id="status-protocol"></span>
<span id="status-session-count">0 sessions</span>
<span class="status-spacer"></span>
<button class="status-update is-hidden" id="status-update" type="button">
Update available
</button>
<span id="status-vault">🔒 Locked</span>
</footer>
</div>
+21
View File
@@ -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;
+52
View File
@@ -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 ─────────────────────────────────────────────
+20 -1
View File
@@ -848,7 +848,7 @@
body.innerHTML = `
<div class="about-section">
<h3 class="about-title">Nexus Terminal</h3>
<p class="about-version">Version 1.0.0</p>
<p class="about-version" data-role="app-version">Version …</p>
<p class="about-desc">Developed by Andy Dixon - www.dixon.cx/nexus</p>
<div class="about-links">
<p>AES-256-GCM encrypted credential vault</p>
@@ -864,6 +864,22 @@
</div>
`;
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 =
`<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
+13 -1
View File
@@ -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) {
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(context.Background())
info, err = checker.Check(ctx)
}
if err != nil {
return nil, err
}
+24
View File
@@ -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)
}
+100
View File
@@ -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 {
+40
View File
@@ -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")
}
}
+29 -6
View File
@@ -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).