Add auto updater
This commit is contained in:
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user