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
+14 -2
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) {
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
}
+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")
}
}