feat: auto-updater — check Gitea packages for new versions, download + install
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m5s

Add internal/updater package with UpdateService that queries the Gitea
generic-package API for newer releases, downloads the installer with
SHA256 verification, and launches it to apply the update. Includes
semver comparison (CompareVersions) and comprehensive test coverage
with httptest-based mock servers.

Wire UpdateService into WraithApp (app.go accepts version param) and
register as a Wails service in main.go. Frontend StatusBar shows a
blue pill notification when an update is available; SettingsModal About
section displays the current version and a "Check for Updates" button
with idle/checking/found/up-to-date/error states.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 10:11:50 -04:00
parent d07ac7ce3b
commit f22fbe14fa
6 changed files with 672 additions and 8 deletions

View File

@ -174,8 +174,7 @@
<div class="space-y-2 text-xs">
<div class="flex justify-between py-1.5 border-b border-[#30363d]">
<span class="text-[var(--wraith-text-secondary)]">Version</span>
<span class="text-[var(--wraith-text-primary)]">0.1.0-dev</span>
<!-- TODO: Fetch from Wails binding AppService.GetVersion() -->
<span class="text-[var(--wraith-text-primary)]">{{ currentVersion }}</span>
</div>
<div class="flex justify-between py-1.5 border-b border-[#30363d]">
<span class="text-[var(--wraith-text-secondary)]">License</span>
@ -183,7 +182,7 @@
</div>
<div class="flex justify-between py-1.5 border-b border-[#30363d]">
<span class="text-[var(--wraith-text-secondary)]">Runtime</span>
<span class="text-[var(--wraith-text-primary)]">Wails v2</span>
<span class="text-[var(--wraith-text-primary)]">Wails v3</span>
</div>
<div class="flex justify-between py-1.5">
<span class="text-[var(--wraith-text-secondary)]">Frontend</span>
@ -191,6 +190,47 @@
</div>
</div>
<!-- Check for Updates -->
<div class="pt-2">
<button
class="w-full px-3 py-2 text-xs rounded border transition-colors cursor-pointer flex items-center justify-center gap-2"
:class="updateCheckState === 'found'
? 'border-[#3fb950] text-[#3fb950] hover:bg-[#3fb950]/10'
: 'border-[#30363d] text-[var(--wraith-text-primary)] hover:bg-[#30363d]'"
:disabled="updateCheckState === 'checking'"
@click="checkForUpdates"
>
<template v-if="updateCheckState === 'idle'">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
</svg>
Check for Updates
</template>
<template v-else-if="updateCheckState === 'checking'">
<svg class="w-3.5 h-3.5 animate-spin" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0ZM1.5 8a6.5 6.5 0 1 1 13 0 6.5 6.5 0 0 1-13 0Z" opacity=".25" />
<path d="M8 0a8 8 0 0 1 8 8h-1.5A6.5 6.5 0 0 0 8 1.5V0Z" />
</svg>
Checking...
</template>
<template v-else-if="updateCheckState === 'found'">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm.75 3.5v3.69l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V5.5a.75.75 0 0 1 1.5 0Z" />
</svg>
v{{ latestVersion }} available click to download
</template>
<template v-else-if="updateCheckState === 'up-to-date'">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
</svg>
You're up to date
</template>
<template v-else-if="updateCheckState === 'error'">
Could not check for updates
</template>
</button>
</div>
<div class="flex gap-2 pt-2">
<a
href="#"
@ -276,6 +316,12 @@ const settings = ref({
scrollbackBuffer: 5000,
});
// --- Update check state ---
type UpdateCheckState = "idle" | "checking" | "found" | "up-to-date" | "error";
const updateCheckState = ref<UpdateCheckState>("idle");
const currentVersion = ref("0.1.0-dev");
const latestVersion = ref("");
function open(): void {
visible.value = true;
activeSection.value = "general";
@ -301,6 +347,35 @@ function importVault(): void {
alert("Import vault — not yet implemented (requires Wails binding)");
}
async function checkForUpdates(): Promise<void> {
if (updateCheckState.value === "checking") return;
if (updateCheckState.value === "found") {
// Second click trigger download
// TODO: replace with Wails binding UpdateService.DownloadUpdate() + ApplyUpdate()
console.log("Download update:", latestVersion.value);
return;
}
updateCheckState.value = "checking";
try {
// TODO: replace with Wails binding UpdateService.CheckForUpdate()
// const info = await UpdateService.CheckForUpdate();
// currentVersion.value = info.currentVersion;
// if (info.available) {
// latestVersion.value = info.latestVersion;
// updateCheckState.value = "found";
// } else {
// updateCheckState.value = "up-to-date";
// }
// Placeholder until Wails bindings are generated:
updateCheckState.value = "up-to-date";
} catch {
updateCheckState.value = "error";
}
}
function openLink(target: string): void {
// TODO: Replace with Wails runtime.BrowserOpenURL(url)
const urls: Record<string, string> = {

View File

@ -18,8 +18,33 @@
</template>
</div>
<!-- Right: terminal info -->
<!-- Right: terminal info + update notification -->
<div class="flex items-center gap-3">
<!-- Update notification pill -->
<button
v-if="updateAvailable && updateInfo"
class="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors cursor-pointer"
:class="updateState === 'downloading'
? 'bg-[#1f6feb]/30 text-[#58a6ff]'
: 'bg-[#1f6feb]/20 text-[#58a6ff] hover:bg-[#1f6feb]/30'"
:disabled="updateState === 'downloading'"
@click="handleUpdate"
>
<template v-if="updateState === 'idle'">
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm.75 3.5v3.69l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V5.5a.75.75 0 0 1 1.5 0Z" />
</svg>
v{{ updateInfo.latestVersion }} available &mdash; Update
</template>
<template v-else-if="updateState === 'downloading'">
<svg class="w-3 h-3 animate-spin" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0ZM1.5 8a6.5 6.5 0 1 1 13 0 6.5 6.5 0 0 1-13 0Z" opacity=".25" />
<path d="M8 0a8 8 0 0 1 8 8h-1.5A6.5 6.5 0 0 0 8 1.5V0Z" />
</svg>
Downloading...
</template>
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Change terminal theme"
@ -34,7 +59,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, ref, onMounted } from "vue";
import { useSessionStore } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store";
@ -47,6 +72,18 @@ const emit = defineEmits<{
(e: "open-theme-picker"): void;
}>();
interface UpdateInfoData {
available: boolean;
currentVersion: string;
latestVersion: string;
downloadUrl: string;
sha256: string;
}
const updateAvailable = ref(false);
const updateInfo = ref<UpdateInfoData | null>(null);
const updateState = ref<"idle" | "downloading">("idle");
const connectionInfo = computed(() => {
const session = sessionStore.activeSession;
if (!session) return "";
@ -57,9 +94,38 @@ const connectionInfo = computed(() => {
return `root@${conn.hostname}:${conn.port}`;
});
/** Check for updates on mount. */
onMounted(async () => {
try {
// TODO: replace with Wails binding UpdateService.CheckForUpdate()
// const info = await UpdateService.CheckForUpdate();
// if (info.available) {
// updateAvailable.value = true;
// updateInfo.value = info;
// }
} catch {
// Silent fail update check is non-critical.
}
});
/** Download and apply an update. */
async function handleUpdate(): Promise<void> {
if (!updateInfo.value || updateState.value === "downloading") return;
updateState.value = "downloading";
try {
// TODO: replace with Wails bindings
// const path = await UpdateService.DownloadUpdate(updateInfo.value);
// await UpdateService.ApplyUpdate(path);
console.log("Update download triggered for", updateInfo.value.latestVersion);
} catch (e) {
console.error("Update failed:", e);
updateState.value = "idle";
}
}
function setThemeName(name: string): void {
activeThemeName.value = name;
}
defineExpose({ setThemeName, activeThemeName });
defineExpose({ setThemeName, activeThemeName, updateAvailable, updateInfo });
</script>

View File

@ -19,6 +19,7 @@ import (
"github.com/vstockwell/wraith/internal/sftp"
"github.com/vstockwell/wraith/internal/ssh"
"github.com/vstockwell/wraith/internal/theme"
"github.com/vstockwell/wraith/internal/updater"
"github.com/vstockwell/wraith/internal/vault"
)
@ -37,13 +38,16 @@ type WraithApp struct {
RDP *rdp.RDPService
Credentials *credentials.CredentialService
AI *ai.AIService
Updater *updater.UpdateService
oauthMgr *ai.OAuthManager
unlocked bool
}
// New creates and initializes the WraithApp, opening the database, running
// migrations, creating all services, and seeding built-in themes.
func New() (*WraithApp, error) {
// The version string is the build-time semver (e.g. "0.2.0") used by the
// auto-updater to check for newer releases.
func New(version string) (*WraithApp, error) {
dataDir := dataDirectory()
dbPath := filepath.Join(dataDir, "wraith.db")
@ -95,6 +99,8 @@ func New() (*WraithApp, error) {
slog.Warn("failed to seed themes", "error", err)
}
updaterSvc := updater.NewUpdateService(version)
return &WraithApp{
db: database,
Settings: settingsSvc,
@ -106,6 +112,7 @@ func New() (*WraithApp, error) {
SFTP: sftpSvc,
RDP: rdpSvc,
AI: aiSvc,
Updater: updaterSvc,
oauthMgr: oauthMgr,
}, nil
}

292
internal/updater/service.go Normal file
View File

@ -0,0 +1,292 @@
package updater
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// UpdateInfo describes the result of checking for an available update.
type UpdateInfo struct {
Available bool `json:"available"`
CurrentVer string `json:"currentVersion"`
LatestVer string `json:"latestVersion"`
DownloadURL string `json:"downloadUrl"`
SHA256 string `json:"sha256"`
}
// UpdateService checks a Gitea generic-package registry for new versions
// and can download + launch an installer to apply the update.
type UpdateService struct {
currentVersion string
baseURL string // e.g. https://git.command.vigilcyber.com
owner string // e.g. vstockwell
pkg string // e.g. wraith
httpClient *http.Client
}
// NewUpdateService creates an UpdateService that compares against the
// supplied currentVersion (semver without "v" prefix, e.g. "0.2.0").
func NewUpdateService(currentVersion string) *UpdateService {
return &UpdateService{
currentVersion: strings.TrimPrefix(currentVersion, "v"),
baseURL: "https://git.command.vigilcyber.com",
owner: "vstockwell",
pkg: "wraith",
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// giteaPackageVersion is the subset of the Gitea API response we need.
type giteaPackageVersion struct {
Version string `json:"version"`
}
// versionJSON is the schema of the version.json file in the package.
type versionJSON struct {
Version string `json:"version"`
SHA256 string `json:"sha256"`
DownloadURL string `json:"downloadUrl"`
}
// CheckForUpdate queries the Gitea package API for the latest version and
// compares it with the current version using semver comparison.
func (u *UpdateService) CheckForUpdate() (*UpdateInfo, error) {
info := &UpdateInfo{
CurrentVer: u.currentVersion,
}
// Fetch latest package version from the Gitea API.
apiURL := fmt.Sprintf(
"%s/api/v1/packages/%s/generic/%s?limit=1&sort=created_at&direction=desc",
u.baseURL, u.owner, u.pkg,
)
slog.Info("checking for updates", "url", apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
// Use GIT_TOKEN for private registries, if available.
if token := os.Getenv("GIT_TOKEN"); token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch package versions: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d from package API", resp.StatusCode)
}
var versions []giteaPackageVersion
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
return nil, fmt.Errorf("decode package versions: %w", err)
}
if len(versions) == 0 {
slog.Info("no package versions found")
return info, nil
}
latestVer := strings.TrimPrefix(versions[0].Version, "v")
info.LatestVer = latestVer
cmp := CompareVersions(u.currentVersion, latestVer)
if cmp >= 0 {
slog.Info("already up to date", "current", u.currentVersion, "latest", latestVer)
return info, nil
}
// Newer version is available — fetch version.json for SHA256 + download URL.
versionInfoURL := fmt.Sprintf(
"%s/api/packages/%s/generic/%s/%s/version.json",
u.baseURL, u.owner, u.pkg, versions[0].Version,
)
vInfo, err := u.fetchVersionJSON(versionInfoURL)
if err != nil {
// Non-fatal: we still know the version is available, just no SHA256.
slog.Warn("could not fetch version.json", "error", err)
}
info.Available = true
if vInfo != nil {
info.SHA256 = vInfo.SHA256
if vInfo.DownloadURL != "" {
info.DownloadURL = vInfo.DownloadURL
}
}
if info.DownloadURL == "" {
info.DownloadURL = fmt.Sprintf(
"%s/api/packages/%s/generic/%s/%s/wraith-%s-setup.exe",
u.baseURL, u.owner, u.pkg, versions[0].Version, versions[0].Version,
)
}
slog.Info("update available", "current", u.currentVersion, "latest", latestVer)
return info, nil
}
// fetchVersionJSON fetches and decodes a version.json file from the given URL.
func (u *UpdateService) fetchVersionJSON(url string) (*versionJSON, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token := os.Getenv("GIT_TOKEN"); token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var v versionJSON
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return nil, err
}
return &v, nil
}
// DownloadUpdate downloads the installer for the given version to a temporary
// file and returns its path. If the UpdateInfo includes a SHA256 hash the
// download is verified against it.
func (u *UpdateService) DownloadUpdate(info *UpdateInfo) (string, error) {
if info.DownloadURL == "" {
return "", fmt.Errorf("no download URL")
}
slog.Info("downloading update", "url", info.DownloadURL)
req, err := http.NewRequest("GET", info.DownloadURL, nil)
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
if token := os.Getenv("GIT_TOKEN"); token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("download installer: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %d downloading installer", resp.StatusCode)
}
// Write to a temp file.
tmpDir := os.TempDir()
filename := fmt.Sprintf("wraith-%s-setup.exe", info.LatestVer)
destPath := filepath.Join(tmpDir, filename)
f, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
defer f.Close()
hasher := sha256.New()
writer := io.MultiWriter(f, hasher)
if _, err := io.Copy(writer, resp.Body); err != nil {
os.Remove(destPath)
return "", fmt.Errorf("write installer: %w", err)
}
// Verify SHA256 if provided.
if info.SHA256 != "" {
got := hex.EncodeToString(hasher.Sum(nil))
if !strings.EqualFold(got, info.SHA256) {
os.Remove(destPath)
return "", fmt.Errorf("SHA256 mismatch: expected %s, got %s", info.SHA256, got)
}
slog.Info("SHA256 verified", "hash", got)
}
slog.Info("update downloaded", "path", destPath)
return destPath, nil
}
// ApplyUpdate launches the downloaded installer and signals the caller to
// exit. On Windows it starts the exe; on other platforms it logs a message.
func (u *UpdateService) ApplyUpdate(installerPath string) error {
slog.Info("applying update", "installer", installerPath)
if runtime.GOOS == "windows" {
cmd := exec.Command(installerPath, "/SILENT")
if err := cmd.Start(); err != nil {
return fmt.Errorf("launch installer: %w", err)
}
// The caller (frontend) should exit the app after this returns.
return nil
}
// Non-Windows: nothing to do automatically — inform the user.
slog.Info("update downloaded — please run the installer manually", "path", installerPath)
return nil
}
// CompareVersions compares two semver strings (major.minor.patch).
// Returns -1 if a < b, 0 if equal, 1 if a > b.
// Strings are trimmed of a leading "v" prefix.
func CompareVersions(a, b string) int {
a = strings.TrimPrefix(a, "v")
b = strings.TrimPrefix(b, "v")
aParts := parseSemver(a)
bParts := parseSemver(b)
for i := 0; i < 3; i++ {
if aParts[i] < bParts[i] {
return -1
}
if aParts[i] > bParts[i] {
return 1
}
}
return 0
}
// parseSemver splits a version string into [major, minor, patch].
// Missing components default to 0.
func parseSemver(v string) [3]int {
var parts [3]int
segments := strings.SplitN(v, ".", 3)
for i, s := range segments {
if i >= 3 {
break
}
n, _ := strconv.Atoi(s)
parts[i] = n
}
return parts
}

View File

@ -0,0 +1,223 @@
package updater
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewUpdateService(t *testing.T) {
svc := NewUpdateService("0.2.0")
if svc.currentVersion != "0.2.0" {
t.Errorf("currentVersion = %q, want %q", svc.currentVersion, "0.2.0")
}
if svc.baseURL == "" {
t.Error("baseURL should not be empty")
}
if svc.owner == "" {
t.Error("owner should not be empty")
}
if svc.pkg == "" {
t.Error("pkg should not be empty")
}
if svc.httpClient == nil {
t.Error("httpClient should not be nil")
}
}
func TestNewUpdateServiceStripsVPrefix(t *testing.T) {
svc := NewUpdateService("v1.2.3")
if svc.currentVersion != "1.2.3" {
t.Errorf("currentVersion = %q, want %q", svc.currentVersion, "1.2.3")
}
}
func TestCompareVersions(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"0.2.0", "0.2.1", -1},
{"0.2.1", "0.2.0", 1},
{"0.3.0", "0.2.9", 1},
{"0.2.9", "0.3.0", -1},
{"1.0.0", "0.9.9", 1},
{"0.9.9", "1.0.0", -1},
{"1.0.0", "1.0.0", 0},
{"0.0.1", "0.0.1", 0},
{"2.0.0", "1.99.99", 1},
{"0.1.0", "0.0.99", 1},
// With "v" prefix
{"v0.2.0", "v0.2.1", -1},
{"v1.0.0", "1.0.0", 0},
}
for _, tt := range tests {
got := CompareVersions(tt.a, tt.b)
if got != tt.want {
t.Errorf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
func TestParseSemver(t *testing.T) {
tests := []struct {
input string
want [3]int
}{
{"1.2.3", [3]int{1, 2, 3}},
{"0.0.0", [3]int{0, 0, 0}},
{"10.20.30", [3]int{10, 20, 30}},
{"1.2", [3]int{1, 2, 0}},
{"1", [3]int{1, 0, 0}},
{"", [3]int{0, 0, 0}},
}
for _, tt := range tests {
got := parseSemver(tt.input)
if got != tt.want {
t.Errorf("parseSemver(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestParseVersionJSON(t *testing.T) {
raw := `{"version":"0.3.0","sha256":"abcdef1234567890","downloadUrl":"https://example.com/setup.exe"}`
var v versionJSON
if err := json.Unmarshal([]byte(raw), &v); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if v.Version != "0.3.0" {
t.Errorf("Version = %q, want %q", v.Version, "0.3.0")
}
if v.SHA256 != "abcdef1234567890" {
t.Errorf("SHA256 = %q, want %q", v.SHA256, "abcdef1234567890")
}
if v.DownloadURL != "https://example.com/setup.exe" {
t.Errorf("DownloadURL = %q, want %q", v.DownloadURL, "https://example.com/setup.exe")
}
}
func TestCheckForUpdate_NewerAvailable(t *testing.T) {
// Mock the Gitea API: return a single package version that is newer.
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/vstockwell/generic/wraith", func(w http.ResponseWriter, r *http.Request) {
versions := []giteaPackageVersion{{Version: "0.3.0"}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(versions)
})
mux.HandleFunc("/api/packages/vstockwell/generic/wraith/0.3.0/version.json", func(w http.ResponseWriter, r *http.Request) {
v := versionJSON{
Version: "0.3.0",
SHA256: "abc123",
DownloadURL: "https://example.com/wraith-0.3.0-setup.exe",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
})
server := httptest.NewServer(mux)
defer server.Close()
svc := NewUpdateService("0.2.0")
svc.baseURL = server.URL
info, err := svc.CheckForUpdate()
if err != nil {
t.Fatalf("CheckForUpdate() error: %v", err)
}
if !info.Available {
t.Error("expected Available = true")
}
if info.LatestVer != "0.3.0" {
t.Errorf("LatestVer = %q, want %q", info.LatestVer, "0.3.0")
}
if info.SHA256 != "abc123" {
t.Errorf("SHA256 = %q, want %q", info.SHA256, "abc123")
}
if info.DownloadURL != "https://example.com/wraith-0.3.0-setup.exe" {
t.Errorf("DownloadURL = %q, want %q", info.DownloadURL, "https://example.com/wraith-0.3.0-setup.exe")
}
}
func TestCheckForUpdate_AlreadyUpToDate(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/vstockwell/generic/wraith", func(w http.ResponseWriter, r *http.Request) {
versions := []giteaPackageVersion{{Version: "0.2.0"}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(versions)
})
server := httptest.NewServer(mux)
defer server.Close()
svc := NewUpdateService("0.2.0")
svc.baseURL = server.URL
info, err := svc.CheckForUpdate()
if err != nil {
t.Fatalf("CheckForUpdate() error: %v", err)
}
if info.Available {
t.Error("expected Available = false (same version)")
}
}
func TestCheckForUpdate_CurrentIsNewer(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/vstockwell/generic/wraith", func(w http.ResponseWriter, r *http.Request) {
versions := []giteaPackageVersion{{Version: "0.1.0"}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(versions)
})
server := httptest.NewServer(mux)
defer server.Close()
svc := NewUpdateService("0.2.0")
svc.baseURL = server.URL
info, err := svc.CheckForUpdate()
if err != nil {
t.Fatalf("CheckForUpdate() error: %v", err)
}
if info.Available {
t.Error("expected Available = false (current is newer)")
}
}
func TestCheckForUpdate_NoVersions(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/vstockwell/generic/wraith", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]giteaPackageVersion{})
})
server := httptest.NewServer(mux)
defer server.Close()
svc := NewUpdateService("0.2.0")
svc.baseURL = server.URL
info, err := svc.CheckForUpdate()
if err != nil {
t.Fatalf("CheckForUpdate() error: %v", err)
}
if info.Available {
t.Error("expected Available = false (no versions)")
}
}
func TestCheckForUpdate_APIError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/vstockwell/generic/wraith", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
server := httptest.NewServer(mux)
defer server.Close()
svc := NewUpdateService("0.2.0")
svc.baseURL = server.URL
_, err := svc.CheckForUpdate()
if err == nil {
t.Error("expected error from API failure")
}
}

View File

@ -18,7 +18,7 @@ var assets embed.FS
func main() {
slog.Info("starting Wraith")
wraith, err := wraithapp.New()
wraith, err := wraithapp.New(version)
if err != nil {
log.Fatalf("failed to initialize: %v", err)
}
@ -35,6 +35,7 @@ func main() {
application.NewService(wraith.SFTP),
application.NewService(wraith.RDP),
application.NewService(wraith.AI),
application.NewService(wraith.Updater),
},
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),