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

@@ -6,6 +6,40 @@
<main class="flex-1 min-w-0 relative overflow-hidden">
<!-- Custom draggable title bar -->
<app-title-bar />
@if (desktopUpdateState().restartRequired) {
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
<div class="pointer-events-auto mx-auto max-w-4xl rounded-xl border border-primary/30 bg-primary/10 p-4 shadow-2xl backdrop-blur-sm">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
<p class="mt-1 text-sm text-muted-foreground">
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
(click)="openUpdatesSettings()"
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"
>
Update settings
</button>
<button
type="button"
(click)="restartToApplyUpdate()"
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 now
</button>
</div>
</div>
</div>
</div>
}
<!-- Content area fills below the title bar without global scroll -->
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
<router-outlet />
@@ -16,6 +50,47 @@
<app-floating-voice-controls />
</div>
@if (desktopUpdateState().serverBlocked) {
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
<p class="mt-3 text-sm text-muted-foreground">
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
</p>
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
</div>
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button
type="button"
(click)="refreshDesktopUpdateContext()"
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"
>
Retry
</button>
<button
type="button"
(click)="openNetworkSettings()"
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"
>
Open network settings
</button>
</div>
</div>
</div>
}
<!-- Unified Settings Modal -->
<app-settings-modal />

View File

@@ -14,10 +14,12 @@ import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { DatabaseService } from './core/services/database.service';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { ServerDirectoryService } from './core/services/server-directory.service';
import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionService } from './core/services/voice-session.service';
import { ExternalLinkService } from './core/services/external-link.service';
import { SettingsModalService } from './core/services/settings-modal.service';
import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
@@ -49,10 +51,13 @@ import {
export class App implements OnInit {
store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService);
desktopUpdateState = this.desktopUpdates.state;
private databaseService = inject(DatabaseService);
private router = inject(Router);
private servers = inject(ServerDirectoryService);
private settingsModal = inject(SettingsModalService);
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionService);
private externalLinks = inject(ExternalLinkService);
@@ -63,6 +68,8 @@ export class App implements OnInit {
}
async ngOnInit(): Promise<void> {
void this.desktopUpdates.initialize();
await this.databaseService.initialize();
try {
@@ -106,4 +113,20 @@ export class App implements OnInit {
}
});
}
openNetworkSettings(): void {
this.settingsModal.open('network');
}
openUpdatesSettings(): void {
this.settingsModal.open('updates');
}
async refreshDesktopUpdateContext(): Promise<void> {
await this.desktopUpdates.refreshServerContext();
}
async restartToApplyUpdate(): Promise<void> {
await this.desktopUpdates.restartToApplyUpdate();
}
}

View File

@@ -0,0 +1,401 @@
import {
Injectable,
Injector,
effect,
inject,
signal
} from '@angular/core';
import { PlatformService } from './platform.service';
import { ServerDirectoryService, type ServerEndpoint } from './server-directory.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';
type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
configuredManifestUrls: string[];
currentVersion: string;
defaultManifestUrls: string[];
isSupported: boolean;
lastCheckedAt: number | null;
latestVersion: string | null;
manifestUrl: string | null;
manifestUrls: string[];
minimumServerVersion: string | null;
preferredVersion: string | null;
restartRequired: boolean;
serverBlocked: boolean;
serverBlockMessage: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
status: DesktopUpdateStatus;
statusMessage: string | null;
targetVersion: string | null;
}
interface DesktopUpdateServerContext {
manifestUrls: string[];
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
interface DesktopUpdateElectronApi {
checkForAppUpdates?: () => Promise<DesktopUpdateState>;
configureAutoUpdateContext?: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
getAutoUpdateState?: () => Promise<DesktopUpdateState>;
onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void;
restartToApplyUpdate?: () => Promise<boolean>;
setDesktopSettings?: (patch: {
autoUpdateMode?: AutoUpdateMode;
manifestUrls?: string[];
preferredVersion?: string | null;
}) => Promise<unknown>;
}
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface ServerHealthSnapshot {
endpointId: string;
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
type DesktopUpdateWindow = Window & {
electronAPI?: DesktopUpdateElectronApi;
};
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
function createInitialState(): DesktopUpdateState {
return {
autoUpdateMode: 'auto',
availableVersions: [],
configuredManifestUrls: [],
currentVersion: '0.0.0',
defaultManifestUrls: [],
isSupported: false,
lastCheckedAt: null,
latestVersion: null,
manifestUrl: null,
manifestUrls: [],
minimumServerVersion: null,
preferredVersion: null,
restartRequired: false,
serverBlocked: false,
serverBlockMessage: null,
serverVersion: null,
serverVersionStatus: 'unknown',
status: 'idle',
statusMessage: null,
targetVersion: null
};
}
function normalizeOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeOptionalHttpUrl(value: unknown): string | null {
const nextValue = normalizeOptionalString(value);
if (!nextValue) {
return null;
}
try {
const parsedUrl = new URL(nextValue);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'
? parsedUrl.toString().replace(/\/+$/, '')
: null;
} catch {
return null;
}
}
function normalizeUrlList(values: readonly unknown[]): string[] {
const manifestUrls: string[] = [];
for (const entry of values) {
const manifestUrl = normalizeOptionalHttpUrl(entry);
if (!manifestUrl || manifestUrls.includes(manifestUrl)) {
continue;
}
manifestUrls.push(manifestUrl);
}
return manifestUrls;
}
@Injectable({ providedIn: 'root' })
export class DesktopAppUpdateService {
readonly isElectron = inject(PlatformService).isElectron;
readonly state = signal<DesktopUpdateState>(createInitialState());
private injector = inject(Injector);
private servers = inject(ServerDirectoryService);
private initialized = false;
private refreshTimerId: number | null = null;
private removeStateListener: (() => void) | null = null;
async initialize(): Promise<void> {
if (!this.isElectron || this.initialized) {
return;
}
this.initialized = true;
this.setupServerWatcher();
this.startRefreshTimer();
const api = this.getElectronApi();
if (!api) {
return;
}
try {
const currentState = await api.getAutoUpdateState?.();
if (currentState) {
this.state.set(currentState);
}
} catch {}
if (api.onAutoUpdateStateChanged) {
this.removeStateListener?.();
this.removeStateListener = api.onAutoUpdateStateChanged((nextState) => {
this.state.set(nextState);
});
}
await this.refreshServerContext();
}
async refreshServerContext(): Promise<void> {
if (!this.isElectron) {
return;
}
await this.syncServerHealth();
}
async checkForUpdates(): Promise<void> {
const api = this.getElectronApi();
if (!api?.checkForAppUpdates) {
return;
}
try {
const nextState = await api.checkForAppUpdates();
this.state.set(nextState);
} catch {}
}
async saveUpdatePreferences(mode: AutoUpdateMode, preferredVersion: string | null): Promise<void> {
const api = this.getElectronApi();
if (!api?.setDesktopSettings) {
return;
}
try {
await api.setDesktopSettings({
autoUpdateMode: mode,
preferredVersion: normalizeOptionalString(preferredVersion)
});
if (api.getAutoUpdateState) {
const nextState = await api.getAutoUpdateState();
this.state.set(nextState);
}
} catch {}
}
async saveManifestUrls(manifestUrls: string[]): Promise<void> {
const api = this.getElectronApi();
if (!api?.setDesktopSettings) {
return;
}
try {
await api.setDesktopSettings({
manifestUrls: normalizeUrlList(manifestUrls)
});
if (api.getAutoUpdateState) {
const nextState = await api.getAutoUpdateState();
this.state.set(nextState);
}
} catch {}
}
async restartToApplyUpdate(): Promise<void> {
const api = this.getElectronApi();
if (!api?.restartToApplyUpdate) {
return;
}
try {
await api.restartToApplyUpdate();
} catch {}
}
private setupServerWatcher(): void {
effect(() => {
this.servers.servers();
if (!this.initialized) {
return;
}
void this.syncServerHealth();
}, { injector: this.injector });
}
private startRefreshTimer(): void {
if (this.refreshTimerId !== null || typeof window === 'undefined') {
return;
}
this.refreshTimerId = window.setInterval(() => {
void this.refreshServerContext();
}, SERVER_CONTEXT_REFRESH_INTERVAL_MS);
}
private async syncServerHealth(): Promise<void> {
const api = this.getElectronApi();
if (!api?.configureAutoUpdateContext) {
return;
}
const endpoints = this.getPrioritizedServers();
if (endpoints.length === 0) {
await this.pushContext({
manifestUrls: [],
serverVersion: null,
serverVersionStatus: 'unknown'
});
return;
}
const healthSnapshots = await Promise.all(
endpoints.map((endpoint) => this.readServerHealth(endpoint))
);
const activeEndpoint = this.servers.activeServer() ?? endpoints[0] ?? null;
const activeSnapshot = activeEndpoint
? healthSnapshots.find((snapshot) => snapshot.endpointId === activeEndpoint.id) ?? null
: null;
await this.pushContext({
manifestUrls: normalizeUrlList(
healthSnapshots.map((snapshot) => snapshot.manifestUrl)
),
serverVersion: activeSnapshot?.serverVersion ?? null,
serverVersionStatus: activeSnapshot?.serverVersionStatus ?? 'unknown'
});
}
private getPrioritizedServers(): ServerEndpoint[] {
const endpoints = [...this.servers.servers()];
const activeServerId = this.servers.activeServer()?.id ?? null;
return endpoints.sort((left, right) => {
if (left.id === activeServerId) {
return -1;
}
if (right.id === activeServerId) {
return 1;
}
return 0;
});
}
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
try {
const response = await fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS)
});
if (!response.ok) {
return {
endpointId: endpoint.id,
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
const payload = await response.json() as ServerHealthResponse;
const serverVersion = normalizeOptionalString(payload.serverVersion);
return {
endpointId: endpoint.id,
manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl),
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
};
} catch {
return {
endpointId: endpoint.id,
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
}
private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> {
const api = this.getElectronApi();
if (!api?.configureAutoUpdateContext) {
return;
}
try {
const nextState = await api.configureAutoUpdateContext(context);
this.state.set(nextState);
} catch {}
}
private getElectronApi(): DesktopUpdateElectronApi | null {
return typeof window !== 'undefined'
? (window as DesktopUpdateWindow).electronAPI ?? null
: null;
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable, signal } from '@angular/core';
export type SettingsPage = 'network' | 'voice' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
export type SettingsPage = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
@Injectable({ providedIn: 'root' })
export class SettingsModalService {

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';
}
}
}

View File

@@ -170,7 +170,6 @@ export class RoomMembersSyncEffects {
oderId: signalingMessage.oderId,
displayName: signalingMessage.displayName
};
const members = upsertRoomMember(
room.members ?? [],
this.buildPresenceMember(room, joinedUser)