Add auto updater

This commit is contained in:
2026-03-10 23:38:57 +01:00
parent e8e5c24600
commit c3fbd7d4fe
20 changed files with 2272 additions and 14 deletions

View File

@@ -122,6 +122,9 @@
@case ('voice') {
Voice & Audio
}
@case ('updates') {
Updates
}
@case ('debugging') {
Debugging
}
@@ -160,6 +163,9 @@
@case ('voice') {
<app-voice-settings />
}
@case ('updates') {
<app-updates-settings />
}
@case ('debugging') {
<app-debugging-settings />
}

View File

@@ -15,6 +15,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideX,
lucideBug,
lucideDownload,
lucideGlobe,
lucideAudioLines,
lucideSettings,
@@ -37,6 +38,7 @@ import { MembersSettingsComponent } from './members-settings/members-settings.co
import { BansSettingsComponent } from './bans-settings/bans-settings.component';
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
@Component({
@@ -48,6 +50,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
NgIcon,
NetworkSettingsComponent,
VoiceSettingsComponent,
UpdatesSettingsComponent,
DebuggingSettingsComponent,
ServerSettingsComponent,
MembersSettingsComponent,
@@ -58,6 +61,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
provideIcons({
lucideX,
lucideBug,
lucideDownload,
lucideGlobe,
lucideAudioLines,
lucideSettings,
@@ -91,6 +95,9 @@ export class SettingsModalComponent {
{ id: 'voice',
label: 'Voice & Audio',
icon: 'lucideAudioLines' },
{ id: 'updates',
label: 'Updates',
icon: 'lucideDownload' },
{ id: 'debugging',
label: 'Debugging',
icon: 'lucideBug' }

View File

@@ -0,0 +1,185 @@
<div class="space-y-6">
<section class="rounded-xl border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Desktop app updates</h4>
<p class="mt-1 text-sm text-muted-foreground">
Use a hosted release manifest to check for new packaged desktop builds and apply them after a restart.
</p>
</div>
<span class="inline-flex items-center rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
{{ statusLabel() }}
</span>
</div>
</section>
@if (!isElectron) {
<section class="rounded-xl border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Automatic updates are only available in the packaged Electron desktop app.</p>
</section>
} @else {
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().currentVersion }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Latest in manifest</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().latestVersion || 'Unknown' }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Target version</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().targetVersion || 'Automatic' }}</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
<p class="mt-2 text-sm font-medium text-foreground">
{{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
</p>
</div>
</section>
<section class="space-y-4 rounded-xl border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Update policy</h5>
<p class="mt-1 text-sm text-muted-foreground">
Choose whether the app tracks the newest release, stays on a specific release, or turns updates off entirely.
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Mode</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="state().autoUpdateMode"
(change)="onModeChange($event)"
>
<option value="auto">Newest release</option>
<option value="version">Specific version</option>
<option value="off">Turn off auto updates</option>
</select>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Pinned version</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="state().autoUpdateMode !== 'version' || state().availableVersions.length === 0"
[value]="state().preferredVersion || ''"
(change)="onVersionChange($event)"
>
<option value="">Choose a release…</option>
@for (version of state().availableVersions; track version) {
<option [value]="version">{{ version }}</option>
}
</select>
</label>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-sm font-medium text-foreground">Status</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ state().statusMessage || 'Waiting for release information from the active server.' }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<button
type="button"
(click)="refreshReleaseInfo()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Refresh release info
</button>
@if (state().restartRequired) {
<button
type="button"
(click)="restartNow()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Restart to update
</button>
}
</div>
</section>
<section class="space-y-4 rounded-xl border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Manifest URL priority</h5>
<p class="mt-1 text-sm text-muted-foreground">
Add one manifest URL per line. The app tries them from top to bottom and falls back to the next URL when a manifest cannot be loaded or is
invalid.
</p>
</div>
<div class="rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground">
<p class="font-medium text-foreground">
{{ isUsingConnectedServerDefaults() ? 'Using connected server defaults' : 'Using saved manifest URLs' }}
</p>
<p class="mt-1">When this list is empty, the app automatically uses manifest URLs reported by your configured servers.</p>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Manifest URLs</span>
<textarea
rows="6"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="manifestUrlsText()"
(input)="onManifestUrlsInput($event)"
placeholder="https://example.com/releases/latest/download/release-manifest.json"
></textarea>
</label>
@if (!state().defaultManifestUrls.length && isUsingConnectedServerDefaults()) {
<p class="text-sm text-muted-foreground">None of your configured servers currently report a manifest URL.</p>
}
<div class="flex flex-wrap gap-3">
<button
type="button"
(click)="saveManifestUrls()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Save manifest URLs
</button>
<button
type="button"
(click)="useConnectedServerDefaults()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Use connected server defaults
</button>
</div>
</section>
@if (state().serverBlocked) {
<section class="rounded-xl border border-red-500/30 bg-red-500/10 p-5">
<h5 class="text-sm font-semibold text-foreground">Server update required</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ state().serverBlockMessage }}</p>
<div class="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-1">{{ state().serverVersion || 'Not reported' }}</p>
</div>
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-1">{{ state().minimumServerVersion || 'Unknown' }}</p>
</div>
</div>
</section>
}
<section class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Resolved manifest URL</p>
<p class="mt-2 break-all text-sm text-muted-foreground">{{ state().manifestUrl || 'No working manifest URL has been resolved yet.' }}</p>
</section>
}
</div>

View File

@@ -0,0 +1,142 @@
import {
Component,
computed,
effect,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { DesktopAppUpdateService } from '../../../../core/services/desktop-app-update.service';
type AutoUpdateMode = 'auto' | 'off' | 'version';
type DesktopUpdateStatus =
| 'idle'
| 'disabled'
| 'checking'
| 'downloading'
| 'up-to-date'
| 'restart-required'
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
@Component({
selector: 'app-updates-settings',
standalone: true,
imports: [CommonModule],
templateUrl: './updates-settings.component.html'
})
export class UpdatesSettingsComponent {
readonly updates = inject(DesktopAppUpdateService);
readonly isElectron = this.updates.isElectron;
readonly state = this.updates.state;
readonly hasPendingManifestUrlChanges = signal(false);
readonly manifestUrlsText = signal('');
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
readonly isUsingConnectedServerDefaults = computed(() => {
return this.state().configuredManifestUrls.length === 0;
});
constructor() {
effect(() => {
if (this.hasPendingManifestUrlChanges()) {
return;
}
this.manifestUrlsText.set(this.stringifyManifestUrls(
this.isUsingConnectedServerDefaults()
? this.state().defaultManifestUrls
: this.state().configuredManifestUrls
));
});
}
async onModeChange(event: Event): Promise<void> {
const select = event.target as HTMLSelectElement;
const mode = select.value as AutoUpdateMode;
const preferredVersion = mode === 'version'
? this.state().preferredVersion ?? this.state().availableVersions[0] ?? null
: this.state().preferredVersion;
await this.updates.saveUpdatePreferences(mode, preferredVersion);
}
async onVersionChange(event: Event): Promise<void> {
const select = event.target as HTMLSelectElement;
await this.updates.saveUpdatePreferences('version', select.value || null);
}
async refreshReleaseInfo(): Promise<void> {
await this.updates.refreshServerContext();
await this.updates.checkForUpdates();
}
onManifestUrlsInput(event: Event): void {
const textarea = event.target as HTMLTextAreaElement;
this.hasPendingManifestUrlChanges.set(true);
this.manifestUrlsText.set(textarea.value);
}
async saveManifestUrls(): Promise<void> {
await this.updates.saveManifestUrls(
this.parseManifestUrls(this.manifestUrlsText())
);
this.hasPendingManifestUrlChanges.set(false);
}
async useConnectedServerDefaults(): Promise<void> {
await this.updates.saveManifestUrls([]);
this.hasPendingManifestUrlChanges.set(false);
}
async restartNow(): Promise<void> {
await this.updates.restartToApplyUpdate();
}
private parseManifestUrls(rawValue: string): string[] {
return [
...new Set(
rawValue
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
)
];
}
private stringifyManifestUrls(manifestUrls: string[]): string {
return manifestUrls.join('\n');
}
private getStatusLabel(status: DesktopUpdateStatus): string {
switch (status) {
case 'checking':
return 'Checking';
case 'downloading':
return 'Downloading';
case 'restart-required':
return 'Restart required';
case 'up-to-date':
return 'Up to date';
case 'disabled':
return 'Disabled';
case 'unsupported':
return 'Unsupported';
case 'no-manifest':
return 'Manifest missing';
case 'target-unavailable':
return 'Version unavailable';
case 'target-older-than-installed':
return 'Pinned below current';
case 'error':
return 'Error';
default:
return 'Idle';
}
}
}