Adds self-hosted update system

Implements a self-hosted update mechanism, allowing the application to be updated from a corporate network share without relying on third-party services.

This includes:
- Functionality to check for updates
- Download installers
- Verify checksums
- Install updates with UAC elevation

Configuration is managed via the config.ini file, with automatic checks on startup. A new settings UI is also included.
This commit is contained in:
Flavio Fois
2026-02-06 18:50:11 +01:00
parent 43cce905eb
commit 0cfe1b65f3
12 changed files with 1113 additions and 12 deletions

View File

@@ -115,10 +115,6 @@
mailState.currentEmail.body = processedBody;
}
}
if (dev) {
console.debug('emailObj:', mailState.currentEmail);
}
console.info('Current email changed:', mailState.currentEmail?.subject);
if (mailState.currentEmail !== null) {

View File

@@ -39,6 +39,8 @@
WindowUnmaximise,
WindowIsMaximised,
Quit,
EventsOn,
EventsOff,
} from "$lib/wailsjs/runtime/runtime";
import { RefreshCcwDot } from "@lucide/svelte";
import { IsDebuggerRunning, QuitApp, TakeScreenshot, SubmitBugReport, OpenFolderInExplorer } from "$lib/wailsjs/go/main/App";
@@ -162,6 +164,26 @@
}
});
// Listen for automatic update notifications
$effect(() => {
if (!browser) return;
EventsOn("update:available", (status: any) => {
toast.info(`Update ${status.availableVersion} is available!`, {
description: "Go to Settings to download and install",
duration: 10000,
action: {
label: "Open Settings",
onClick: () => goto("/settings"),
},
});
});
return () => {
EventsOff("update:available");
};
});
async function captureScreenshot() {
isCapturing = true;
try {

View File

@@ -3,7 +3,7 @@
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Separator } from "$lib/components/ui/separator";
import { ChevronLeft, Heart, Code, Package, Globe, Github, Mail } from "@lucide/svelte";
import { ChevronLeft, Heart, Code, Package, Globe, Github, Mail, BadgeInfo } from "@lucide/svelte";
import * as m from "$lib/paraglide/messages";
import { OpenURLInBrowser } from "$lib/wailsjs/go/main/App";
@@ -94,7 +94,7 @@
<Card.Root>
<Card.Header class="space-y-1">
<Card.Title class="flex items-center gap-2">
<Heart class="size-5 text-red-500" />
<BadgeInfo class="size-5" />
{m.credits_about_title()}
</Card.Title>
<Card.Description>

View File

@@ -6,7 +6,7 @@
import { Label } from "$lib/components/ui/label";
import { Separator } from "$lib/components/ui/separator";
import { Switch } from "$lib/components/ui/switch";
import { ChevronLeft, Flame, Download, Upload } from "@lucide/svelte";
import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle } from "@lucide/svelte";
import type { EMLy_GUI_Settings } from "$lib/types";
import { toast } from "svelte-sonner";
import { It, Us } from "svelte-flags";
@@ -25,7 +25,8 @@
import { setLocale } from "$lib/paraglide/runtime";
import { mailState } from "$lib/stores/mail-state.svelte.js";
import { dev } from '$app/environment';
import { ExportSettings, ImportSettings } from "$lib/wailsjs/go/main/App";
import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, GetUpdateStatus } from "$lib/wailsjs/go/main/App";
import { EventsOn, EventsOff } from "$lib/wailsjs/runtime/runtime";
let { data } = $props();
let config = $derived(data.config);
@@ -214,6 +215,92 @@
toast.error(m.settings_import_error());
}
}
// Update System State
type UpdateStatus = {
currentVersion: string;
availableVersion: string;
updateAvailable: boolean;
checking: boolean;
downloading: boolean;
downloadProgress: number;
ready: boolean;
installerPath: string;
errorMessage: string;
releaseNotes?: string;
lastCheckTime: string;
};
let updateStatus = $state<UpdateStatus>({
currentVersion: "Unknown",
availableVersion: "",
updateAvailable: false,
checking: false,
downloading: false,
downloadProgress: 0,
ready: false,
installerPath: "",
errorMessage: "",
lastCheckTime: "",
});
// Sync current version from config
$effect(() => {
if (config?.GUISemver) {
updateStatus.currentVersion = config.GUISemver;
}
});
async function checkForUpdates() {
try {
const status = await CheckForUpdates();
updateStatus = status;
if (status.updateAvailable) {
toast.success(`Update available: ${status.availableVersion}`);
} else if (!status.errorMessage) {
toast.info("You're on the latest version");
} else {
toast.error(status.errorMessage);
}
} catch (err) {
console.error("Failed to check for updates:", err);
toast.error("Failed to check for updates");
}
}
async function downloadUpdate() {
try {
await DownloadUpdate();
toast.success("Update downloaded successfully");
} catch (err) {
console.error("Failed to download update:", err);
toast.error("Failed to download update");
}
}
async function installUpdate() {
try {
await InstallUpdate(true); // true = quit after launch
// App will quit, so no toast needed
} catch (err) {
console.error("Failed to install update:", err);
toast.error("Failed to launch installer");
}
}
// Listen for update status events
$effect(() => {
if (!browser) return;
EventsOn("update:status", (status: UpdateStatus) => {
updateStatus = status;
});
return () => {
EventsOff("update:status");
};
});
</script>
<div class="min-h-[calc(100vh-1rem)] from-background to-muted/30">
@@ -480,6 +567,135 @@
</Card.Content>
</Card.Root>
<!-- Update Section -->
<Card.Root>
<Card.Header class="space-y-1">
<Card.Title>Updates</Card.Title>
<Card.Description>Check for and install application updates from your network share</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Current Version -->
<div class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4">
<div>
<div class="font-medium">Current Version</div>
<div class="text-sm text-muted-foreground">
{updateStatus.currentVersion} ({config?.GUIReleaseChannel || "stable"})
</div>
</div>
{#if updateStatus.updateAvailable}
<div class="flex items-center gap-2 text-sm font-medium text-green-600 dark:text-green-400">
<AlertCircle class="size-4" />
Update Available
</div>
{:else if updateStatus.lastCheckTime}
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 class="size-4" />
Up to date
</div>
{/if}
</div>
<Separator />
<!-- Check for Updates -->
<div class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4">
<div>
<div class="font-medium">Check for Updates</div>
<div class="text-sm text-muted-foreground">
{#if updateStatus.lastCheckTime}
Last checked: {updateStatus.lastCheckTime}
{:else}
Click to check for available updates
{/if}
</div>
</div>
<Button
variant="outline"
class="cursor-pointer hover:cursor-pointer"
onclick={checkForUpdates}
disabled={updateStatus.checking || updateStatus.downloading}
>
<RefreshCw class="size-4 mr-2 {updateStatus.checking ? 'animate-spin' : ''}" />
{updateStatus.checking ? "Checking..." : "Check Now"}
</Button>
</div>
<!-- Download Update (shown when update available) -->
{#if updateStatus.updateAvailable && !updateStatus.ready}
<Separator />
<div class="flex items-center justify-between gap-4 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
<div>
<div class="font-medium">Version {updateStatus.availableVersion} Available</div>
<div class="text-sm text-muted-foreground">
{#if updateStatus.downloading}
Downloading... {updateStatus.downloadProgress}%
{:else}
Click to download the update
{/if}
</div>
{#if updateStatus.releaseNotes}
<div class="text-xs text-muted-foreground mt-2">
{updateStatus.releaseNotes}
</div>
{/if}
</div>
<Button
variant="default"
class="cursor-pointer hover:cursor-pointer"
onclick={downloadUpdate}
disabled={updateStatus.downloading}
>
<Download class="size-4 mr-2" />
{updateStatus.downloading ? `${updateStatus.downloadProgress}%` : "Download"}
</Button>
</div>
{/if}
<!-- Install Update (shown when download ready) -->
{#if updateStatus.ready}
<Separator />
<div class="flex items-center justify-between gap-4 rounded-lg border border-green-500/30 bg-green-500/10 p-4">
<div>
<div class="font-medium">Update Ready to Install</div>
<div class="text-sm text-muted-foreground">
Version {updateStatus.availableVersion} has been downloaded and verified
</div>
</div>
<Button
variant="default"
class="cursor-pointer hover:cursor-pointer bg-green-600 hover:bg-green-700"
onclick={installUpdate}
>
<CheckCircle2 class="size-4 mr-2" />
Install Now
</Button>
</div>
{/if}
<!-- Error Message -->
{#if updateStatus.errorMessage}
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
<div class="flex items-start gap-2">
<AlertCircle class="size-4 text-destructive mt-0.5" />
<div class="text-sm text-destructive">
{updateStatus.errorMessage}
</div>
</div>
</div>
{/if}
<!-- Info about update path -->
<div class="text-xs text-muted-foreground">
<strong>Info:</strong> Updates are checked from your configured network share path.
{#if (config as any)?.UpdatePath}
Current path: <code class="text-xs bg-muted px-1 py-0.5 rounded">{(config as any).UpdatePath}</code>
{:else}
<span class="text-amber-600 dark:text-amber-400">No update path configured</span>
{/if}
</div>
</Card.Content>
</Card.Root>
{#if $dangerZoneEnabled || dev}
<Card.Root class="border-destructive/50 bg-destructive/15">
<Card.Header class="space-y-1">