Add auto updater
This commit is contained in:
@@ -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 />
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
401
src/app/core/services/desktop-app-update.service.ts
Normal file
401
src/app/core/services/desktop-app-update.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,6 @@ export class RoomMembersSyncEffects {
|
||||
oderId: signalingMessage.oderId,
|
||||
displayName: signalingMessage.displayName
|
||||
};
|
||||
|
||||
const members = upsertRoomMember(
|
||||
room.members ?? [],
|
||||
this.buildPresenceMember(room, joinedUser)
|
||||
|
||||
Reference in New Issue
Block a user