Final pre-launch version: Update logic
This commit is contained in:
@@ -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.
|
# 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)
|
||||||
|
LD_UPDATE_URL := $(if $(NEXUS_UPDATE_URL),-X github.com/andydixon/nexus/internal/api.appUpdateURL=$(NEXUS_UPDATE_URL),)
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: build
|
all: build
|
||||||
@@ -20,7 +22,7 @@ dev: deps
|
|||||||
|
|
||||||
# Production build
|
# Production build
|
||||||
build: deps
|
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
|
# Run tests
|
||||||
test:
|
test:
|
||||||
@@ -47,6 +49,10 @@ lint:
|
|||||||
package: build
|
package: build
|
||||||
@echo "Binary built at build/bin/"
|
@echo "Binary built at build/bin/"
|
||||||
|
|
||||||
|
# Build and package release artefacts for all platforms
|
||||||
|
release:
|
||||||
|
./release.sh
|
||||||
|
|
||||||
# Help
|
# Help
|
||||||
help:
|
help:
|
||||||
@echo "Nexus Terminal — Build Targets"
|
@echo "Nexus Terminal — Build Targets"
|
||||||
@@ -59,4 +65,5 @@ help:
|
|||||||
@echo " make clean — Clean build artefacts"
|
@echo " make clean — Clean build artefacts"
|
||||||
@echo " make lint — Run linter (requires golangci-lint)"
|
@echo " make lint — Run linter (requires golangci-lint)"
|
||||||
@echo " make package — Build and package for distribution"
|
@echo " make package — Build and package for distribution"
|
||||||
|
@echo " make release — Build multi-platform release artefacts"
|
||||||
@echo " make help — Show this help"
|
@echo " make help — Show this help"
|
||||||
|
|||||||
+31
-8
@@ -16,6 +16,23 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
|||||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
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() {
|
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; }
|
||||||
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; }
|
||||||
@@ -80,6 +97,9 @@ build_target() {
|
|||||||
local target_os="$1"
|
local target_os="$1"
|
||||||
local target_arch="$2"
|
local target_arch="$2"
|
||||||
local host="$3"
|
local host="$3"
|
||||||
|
local version="$4"
|
||||||
|
local ldflags
|
||||||
|
ldflags="$(build_ldflags "${version}")"
|
||||||
|
|
||||||
local platform="${target_os}/${target_arch}"
|
local platform="${target_os}/${target_arch}"
|
||||||
local cc=""
|
local cc=""
|
||||||
@@ -94,9 +114,9 @@ build_target() {
|
|||||||
|
|
||||||
log "Building ${platform}…"
|
log "Building ${platform}…"
|
||||||
if [ -n "$cc" ]; then
|
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
|
else
|
||||||
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "-s -w"
|
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +124,9 @@ main() {
|
|||||||
check_deps
|
check_deps
|
||||||
install_deps
|
install_deps
|
||||||
|
|
||||||
|
local version
|
||||||
|
version="$(build_version)"
|
||||||
|
|
||||||
local host
|
local host
|
||||||
host="$(host_os)"
|
host="$(host_os)"
|
||||||
if [ "$host" = "unknown" ]; then
|
if [ "$host" = "unknown" ]; then
|
||||||
@@ -112,15 +135,15 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Build host targets first
|
# Build host targets first
|
||||||
build_target "$host" "amd64" "$host"
|
build_target "$host" "amd64" "$host" "$version"
|
||||||
build_target "$host" "arm64" "$host"
|
build_target "$host" "arm64" "$host" "$version"
|
||||||
|
|
||||||
# Cross-build Linux and Windows targets where toolchains are available
|
# Cross-build Linux and Windows targets where toolchains are available
|
||||||
if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then
|
if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then
|
||||||
build_target "linux" "amd64" "$host"
|
build_target "linux" "amd64" "$host" "$version"
|
||||||
build_target "linux" "arm64" "$host"
|
build_target "linux" "arm64" "$host" "$version"
|
||||||
build_target "windows" "amd64" "$host"
|
build_target "windows" "amd64" "$host" "$version"
|
||||||
build_target "windows" "arm64" "$host"
|
build_target "windows" "arm64" "$host" "$version"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Builds complete. Output: build/bin/"
|
log "Builds complete. Output: build/bin/"
|
||||||
|
|||||||
@@ -48,6 +48,32 @@ function Install-Deps {
|
|||||||
Pop-Location
|
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 {
|
function Dev {
|
||||||
$wailsPath = Check-Deps
|
$wailsPath = Check-Deps
|
||||||
Install-Deps
|
Install-Deps
|
||||||
@@ -58,10 +84,12 @@ function Dev {
|
|||||||
function Build {
|
function Build {
|
||||||
$wailsPath = Check-Deps
|
$wailsPath = Check-Deps
|
||||||
Install-Deps
|
Install-Deps
|
||||||
|
$version = Get-BuildVersion
|
||||||
|
$ldflags = Get-Ldflags $version
|
||||||
|
|
||||||
$env:CGO_ENABLED = '1'
|
$env:CGO_ENABLED = '1'
|
||||||
Write-Info 'Building production binary (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\'
|
Write-Info 'Build complete: build\bin\'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
|||||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
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 prerequisites
|
||||||
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; }
|
||||||
@@ -62,7 +79,9 @@ build() {
|
|||||||
check_deps
|
check_deps
|
||||||
install_deps
|
install_deps
|
||||||
log "Building production binary…"
|
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/"
|
log "Build complete: build/bin/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,9 @@
|
|||||||
<span id="status-protocol"></span>
|
<span id="status-protocol"></span>
|
||||||
<span id="status-session-count">0 sessions</span>
|
<span id="status-session-count">0 sessions</span>
|
||||||
<span class="status-spacer"></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>
|
<span id="status-vault">🔒 Locked</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,6 +125,27 @@
|
|||||||
flex: 1;
|
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 indicator dots */
|
||||||
.status-bar .status-dot {
|
.status-bar .status-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
this.exportImport = new ExportImportUI(this);
|
this.exportImport = new ExportImportUI(this);
|
||||||
this.sftp = new SFTPBrowser(this);
|
this.sftp = new SFTPBrowser(this);
|
||||||
this.snippets = new SnippetLibrary(this);
|
this.snippets = new SnippetLibrary(this);
|
||||||
|
this.updateInfo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
await this.loadPreferences();
|
await this.loadPreferences();
|
||||||
await this.checkSecretStore();
|
await this.checkSecretStore();
|
||||||
this.updateStatusBar();
|
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
|
// Port-forwarding events
|
||||||
window.runtime.EventsOn("session:forward:started", (payload) => {
|
window.runtime.EventsOn("session:forward:started", (payload) => {
|
||||||
const fwd = payload.forward || {};
|
const fwd = payload.forward || {};
|
||||||
@@ -503,6 +510,12 @@
|
|||||||
this._bindClick("btn-empty-connect", () => this.profileEditor.openNew());
|
this._bindClick("btn-empty-connect", () => this.profileEditor.openNew());
|
||||||
this._bindClick("btn-empty-quick", () => this.quickConnect());
|
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");
|
const searchInput = document.getElementById("profile-search");
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener("input", (e) =>
|
searchInput.addEventListener("input", (e) =>
|
||||||
@@ -851,6 +864,7 @@
|
|||||||
const connEl = document.getElementById("status-connection");
|
const connEl = document.getElementById("status-connection");
|
||||||
const countEl = document.getElementById("status-session-count");
|
const countEl = document.getElementById("status-session-count");
|
||||||
const vaultEl = document.getElementById("status-vault");
|
const vaultEl = document.getElementById("status-vault");
|
||||||
|
const updateEl = document.getElementById("status-update");
|
||||||
if (!connEl) return;
|
if (!connEl) return;
|
||||||
|
|
||||||
const sessions = this.state.getAllSessions();
|
const sessions = this.state.getAllSessions();
|
||||||
@@ -874,6 +888,44 @@
|
|||||||
? "🔓 Unlocked"
|
? "🔓 Unlocked"
|
||||||
: "🔒 Locked";
|
: "🔒 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 ─────────────────────────────────────────────
|
// ── Internal helpers ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -848,7 +848,7 @@
|
|||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="about-section">
|
<div class="about-section">
|
||||||
<h3 class="about-title">Nexus Terminal</h3>
|
<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>
|
<p class="about-desc">Developed by Andy Dixon - www.dixon.cx/nexus</p>
|
||||||
<div class="about-links">
|
<div class="about-links">
|
||||||
<p>AES-256-GCM encrypted credential vault</p>
|
<p>AES-256-GCM encrypted credential vault</p>
|
||||||
@@ -864,6 +864,22 @@
|
|||||||
</div>
|
</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");
|
const updateBtn = body.querySelector("#settings-check-update");
|
||||||
if (updateBtn) {
|
if (updateBtn) {
|
||||||
updateBtn.addEventListener("click", async () => {
|
updateBtn.addEventListener("click", async () => {
|
||||||
@@ -872,6 +888,9 @@
|
|||||||
try {
|
try {
|
||||||
const info = await window.go.api.Service.CheckForUpdates();
|
const info = await window.go.api.Service.CheckForUpdates();
|
||||||
const resultEl = body.querySelector("#settings-update-result");
|
const resultEl = body.querySelector("#settings-update-result");
|
||||||
|
if (this.app && typeof this.app.setUpdateInfo === "function") {
|
||||||
|
this.app.setUpdateInfo(info);
|
||||||
|
}
|
||||||
if (info.available) {
|
if (info.available) {
|
||||||
resultEl.innerHTML =
|
resultEl.innerHTML =
|
||||||
`<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
|
`<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
|
||||||
|
|||||||
+13
-1
@@ -36,6 +36,9 @@ import (
|
|||||||
// appVersion is set at build time via ldflags.
|
// appVersion is set at build time via ldflags.
|
||||||
var appVersion = "dev"
|
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.
|
// Service is the main API surface exposed to the frontend via Wails bindings.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *storage.Store
|
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.
|
// CheckForUpdates queries GitHub Releases for a newer version.
|
||||||
func (s *Service) CheckForUpdates() (map[string]interface{}, error) {
|
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)
|
checker := updater.NewChecker(appVersion)
|
||||||
info, err := checker.Check(context.Background())
|
info, err = checker.Check(ctx)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,3 +146,27 @@ func (a *App) DomReady(ctx context.Context) {
|
|||||||
})
|
})
|
||||||
log.Println("DOM ready – backend:ready event emitted")
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -56,6 +57,23 @@ type Checker struct {
|
|||||||
client *http.Client
|
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.
|
// 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").
|
// The version string should be a semver tag (e.g. "v1.2.3" or "1.2.3").
|
||||||
func NewChecker(currentVersion string) *Checker {
|
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.
|
// SetRepository overrides the GitHub repository to check.
|
||||||
func (c *Checker) SetRepository(owner, repo string) {
|
func (c *Checker) SetRepository(owner, repo string) {
|
||||||
c.owner = owner
|
c.owner = owner
|
||||||
@@ -134,6 +163,51 @@ func (c *Checker) Check(ctx context.Context) (*UpdateInfo, error) {
|
|||||||
}, nil
|
}, 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.
|
// normaliseVersion strips a leading "v" and trims whitespace.
|
||||||
func normaliseVersion(v string) string {
|
func normaliseVersion(v string) string {
|
||||||
v = strings.TrimSpace(v)
|
v = strings.TrimSpace(v)
|
||||||
@@ -141,6 +215,32 @@ func normaliseVersion(v string) string {
|
|||||||
return v
|
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.
|
// isNewer performs a simple string comparison of dot-separated version parts.
|
||||||
// Returns true if latest > current (basic semver, no pre-release handling).
|
// Returns true if latest > current (basic semver, no pre-release handling).
|
||||||
func isNewer(latest, current string) bool {
|
func isNewer(latest, current string) bool {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package updater
|
package updater
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -64,3 +67,40 @@ func TestTruncateNotes(t *testing.T) {
|
|||||||
t.Errorf("expected 501 runes, got %d", len([]rune(got)))
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"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"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||||
@@ -16,6 +18,31 @@ import (
|
|||||||
"github.com/andydixon/nexus/frontend"
|
"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() {
|
func main() {
|
||||||
nexus := application.NewApp()
|
nexus := application.NewApp()
|
||||||
|
|
||||||
@@ -35,13 +62,9 @@ func main() {
|
|||||||
OnStartup: nexus.Startup,
|
OnStartup: nexus.Startup,
|
||||||
OnShutdown: nexus.Shutdown,
|
OnShutdown: nexus.Shutdown,
|
||||||
OnDomReady: nexus.DomReady,
|
OnDomReady: nexus.DomReady,
|
||||||
|
Menu: buildAppMenu(nexus),
|
||||||
// macOS: enable the green fullscreen traffic-light button.
|
// macOS: enable the green fullscreen traffic-light button.
|
||||||
Mac: &mac.Options{
|
Mac: &mac.Options{},
|
||||||
About: &mac.AboutInfo{
|
|
||||||
Title: "Nexus Terminal",
|
|
||||||
Message: "A premium desktop terminal client",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Windows: default decorations include maximise; fullscreen via F11.
|
// Windows: default decorations include maximise; fullscreen via F11.
|
||||||
Windows: &windows.Options{},
|
Windows: &windows.Options{},
|
||||||
// Linux: use the default window manager decorations (fullscreen via WM).
|
// Linux: use the default window manager decorations (fullscreen via WM).
|
||||||
|
|||||||
Reference in New Issue
Block a user