feat: Close to tray

This commit is contained in:
2026-03-30 04:48:34 +02:00
parent 42ac712571
commit e3b23247a9
9 changed files with 284 additions and 40 deletions

View File

@@ -7,7 +7,13 @@ import {
destroyDatabase, destroyDatabase,
getDataSource getDataSource
} from '../db/database'; } from '../db/database';
import { createWindow, getDockIconPath } from '../window/create-window'; import {
createWindow,
getDockIconPath,
getMainWindow,
prepareWindowForAppQuit,
showMainWindow
} from '../window/create-window';
import { import {
setupCqrsHandlers, setupCqrsHandlers,
setupSystemHandlers, setupSystemHandlers,
@@ -30,8 +36,13 @@ export function registerAppLifecycle(): void {
await createWindow(); await createWindow();
app.on('activate', () => { app.on('activate', () => {
if (getMainWindow()) {
void showMainWindow();
return;
}
if (BrowserWindow.getAllWindows().length === 0) if (BrowserWindow.getAllWindows().length === 0)
createWindow(); void createWindow();
}); });
}); });
@@ -41,6 +52,8 @@ export function registerAppLifecycle(): void {
}); });
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
prepareWindowForAppQuit();
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();
shutdownDesktopUpdater(); shutdownDesktopUpdater();

View File

@@ -7,6 +7,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
export interface DesktopSettings { export interface DesktopSettings {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
autoStart: boolean; autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -21,6 +22,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto', autoUpdateMode: 'auto',
autoStart: true, autoStart: true,
closeToTray: true,
hardwareAcceleration: true, hardwareAcceleration: true,
manifestUrls: [], manifestUrls: [],
preferredVersion: null, preferredVersion: null,
@@ -86,6 +88,9 @@ export function readDesktopSettings(): DesktopSettings {
autoStart: typeof parsed.autoStart === 'boolean' autoStart: typeof parsed.autoStart === 'boolean'
? parsed.autoStart ? parsed.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart, : DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof parsed.closeToTray === 'boolean'
? parsed.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean' vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
? parsed.vaapiVideoEncode ? parsed.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode, : DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
@@ -110,6 +115,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
autoStart: typeof mergedSettings.autoStart === 'boolean' autoStart: typeof mergedSettings.autoStart === 'boolean'
? mergedSettings.autoStart ? mergedSettings.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart, : DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
? mergedSettings.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean' hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration ? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,

View File

@@ -34,7 +34,11 @@ import {
} from '../update/desktop-updater'; } from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links'; import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start'; import { synchronizeAutoStartSetting } from '../app/auto-start';
import { getMainWindow, getWindowIconPath } from '../window/create-window'; import {
getMainWindow,
getWindowIconPath,
updateCloseToTraySetting
} from '../window/create-window';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [ const FILE_CLIPBOARD_FORMATS = [
@@ -407,6 +411,7 @@ export function setupSystemHandlers(): void {
const snapshot = updateDesktopSettings(patch); const snapshot = updateDesktopSettings(patch);
await synchronizeAutoStartSetting(snapshot.autoStart); await synchronizeAutoStartSetting(snapshot.autoStart);
updateCloseToTraySetting(snapshot.closeToTray);
await handleDesktopSettingsChanged(); await handleDesktopSettingsChanged();
return snapshot; return snapshot;
}); });

View File

@@ -138,6 +138,7 @@ export interface ElectronAPI {
getDesktopSettings: () => Promise<{ getDesktopSettings: () => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean; autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -157,6 +158,7 @@ export interface ElectronAPI {
setDesktopSettings: (patch: { setDesktopSettings: (patch: {
autoUpdateMode?: 'auto' | 'off' | 'version'; autoUpdateMode?: 'auto' | 'off' | 'version';
autoStart?: boolean; autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean; hardwareAcceleration?: boolean;
manifestUrls?: string[]; manifestUrls?: string[];
preferredVersion?: string | null; preferredVersion?: string | null;
@@ -164,6 +166,7 @@ export interface ElectronAPI {
}) => Promise<{ }) => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean; autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;

View File

@@ -2,13 +2,19 @@ import {
app, app,
BrowserWindow, BrowserWindow,
desktopCapturer, desktopCapturer,
Menu,
session, session,
shell shell,
Tray
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let closeToTrayEnabled = true;
let appQuitting = false;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
@@ -40,12 +46,107 @@ export function getDockIconPath(): string | undefined {
return getExistingAssetPath('macos', '1024x1024.png'); return getExistingAssetPath('macos', '1024x1024.png');
} }
function getTrayIconPath(): string | undefined {
if (process.platform === 'win32')
return getExistingAssetPath('windows', 'icon.ico');
return getExistingAssetPath('icon.png');
}
export { getWindowIconPath }; export { getWindowIconPath };
export function getMainWindow(): BrowserWindow | null { export function getMainWindow(): BrowserWindow | null {
return mainWindow; return mainWindow;
} }
function destroyTray(): void {
if (!tray) {
return;
}
tray.destroy();
tray = null;
}
function requestAppQuit(): void {
prepareWindowForAppQuit();
app.quit();
}
function ensureTray(): void {
if (tray) {
return;
}
const trayIconPath = getTrayIconPath();
if (!trayIconPath) {
return;
}
tray = new Tray(trayIconPath);
tray.setToolTip('MetoYou');
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: 'Open MetoYou',
click: () => {
void showMainWindow();
}
},
{
type: 'separator'
},
{
label: 'Close MetoYou',
click: () => {
requestAppQuit();
}
}
])
);
tray.on('click', () => {
void showMainWindow();
});
}
function hideWindowToTray(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.hide();
emitWindowState();
}
export function updateCloseToTraySetting(enabled: boolean): void {
closeToTrayEnabled = enabled;
}
export function prepareWindowForAppQuit(): void {
appQuitting = true;
destroyTray();
}
export async function showMainWindow(): Promise<void> {
if (!mainWindow || mainWindow.isDestroyed()) {
await createWindow();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
emitWindowState();
}
function emitWindowState(): void { function emitWindowState(): void {
if (!mainWindow || mainWindow.isDestroyed()) { if (!mainWindow || mainWindow.isDestroyed()) {
return; return;
@@ -60,6 +161,9 @@ function emitWindowState(): void {
export async function createWindow(): Promise<void> { export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath(); const windowIconPath = getWindowIconPath();
closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray();
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, width: 1400,
height: 900, height: 900,
@@ -120,6 +224,15 @@ export async function createWindow(): Promise<void> {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html')); await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
} }
mainWindow.on('close', (event) => {
if (appQuitting || !closeToTrayEnabled) {
return;
}
event.preventDefault();
hideWindowToTray();
});
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null; mainWindow = null;
}); });
@@ -141,6 +254,14 @@ export async function createWindow(): Promise<void> {
emitWindowState(); emitWindowState();
}); });
mainWindow.on('show', () => {
emitWindowState();
});
mainWindow.on('hide', () => {
emitWindowState();
});
emitWindowState(); emitWindowState();
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {

View File

@@ -89,6 +89,7 @@ export interface DesktopUpdateState {
export interface DesktopSettingsSnapshot { export interface DesktopSettingsSnapshot {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
autoStart: boolean; autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -99,6 +100,7 @@ export interface DesktopSettingsSnapshot {
export interface DesktopSettingsPatch { export interface DesktopSettingsPatch {
autoUpdateMode?: AutoUpdateMode; autoUpdateMode?: AutoUpdateMode;
autoStart?: boolean; autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean; hardwareAcceleration?: boolean;
manifestUrls?: string[]; manifestUrls?: string[];
preferredVersion?: string | null; preferredVersion?: string | null;

View File

@@ -13,7 +13,7 @@ export enum AppSound {
} }
/** Path prefix for audio assets (served from the `assets/audio/` folder). */ /** Path prefix for audio assets (served from the `assets/audio/` folder). */
const AUDIO_BASE = '/assets/audio'; const AUDIO_BASE = 'assets/audio';
/** File extension used for all sound-effect assets. */ /** File extension used for all sound-effect assets. */
const AUDIO_EXT = 'wav'; const AUDIO_EXT = 'wav';
/** localStorage key for persisting notification volume. */ /** localStorage key for persisting notification volume. */
@@ -36,6 +36,8 @@ export class NotificationAudioService {
/** Pre-loaded audio buffers keyed by {@link AppSound}. */ /** Pre-loaded audio buffers keyed by {@link AppSound}. */
private readonly cache = new Map<AppSound, HTMLAudioElement>(); private readonly cache = new Map<AppSound, HTMLAudioElement>();
private readonly sources = new Map<AppSound, string>();
/** Reactive notification volume (0 - 1), persisted to localStorage. */ /** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume()); readonly notificationVolume = signal(this.loadVolume());
@@ -46,13 +48,22 @@ export class NotificationAudioService {
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
private preload(): void { private preload(): void {
for (const sound of Object.values(AppSound)) { for (const sound of Object.values(AppSound)) {
const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`); const src = this.resolveAudioUrl(sound);
const audio = new Audio();
audio.preload = 'auto'; audio.preload = 'auto';
audio.src = src;
audio.load();
this.sources.set(sound, src);
this.cache.set(sound, audio); this.cache.set(sound, audio);
} }
} }
private resolveAudioUrl(sound: AppSound): string {
return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString();
}
/** Read persisted volume from localStorage, falling back to the default. */ /** Read persisted volume from localStorage, falling back to the default. */
private loadVolume(): number { private loadVolume(): number {
try { try {
@@ -96,8 +107,9 @@ export class NotificationAudioService {
*/ */
play(sound: AppSound, volumeOverride?: number): void { play(sound: AppSound, volumeOverride?: number): void {
const cached = this.cache.get(sound); const cached = this.cache.get(sound);
const src = this.sources.get(sound);
if (!cached) if (!cached || !src)
return; return;
const vol = volumeOverride ?? this.notificationVolume(); const vol = volumeOverride ?? this.notificationVolume();
@@ -105,12 +117,23 @@ export class NotificationAudioService {
if (vol === 0) if (vol === 0)
return; // skip playback when muted return; // skip playback when muted
if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) {
cached.load();
}
// Clone so overlapping plays don't cut each other off. // Clone so overlapping plays don't cut each other off.
const clone = cached.cloneNode(true) as HTMLAudioElement; const clone = cached.cloneNode(true) as HTMLAudioElement;
clone.preload = 'auto';
clone.volume = Math.max(0, Math.min(1, vol)); clone.volume = Math.max(0, Math.min(1, vol));
clone.play().catch(() => { clone.play().catch(() => {
/* swallow autoplay errors */ const fallback = new Audio(src);
fallback.preload = 'auto';
fallback.volume = clone.volume;
fallback.play().catch(() => {
/* swallow autoplay errors */
});
}); });
} }
} }

View File

@@ -8,39 +8,77 @@
<h4 class="text-sm font-semibold text-foreground">Application</h4> <h4 class="text-sm font-semibold text-foreground">Application</h4>
</div> </div>
<div <div class="space-y-3">
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity" <div
[class.opacity-60]="!isElectron" class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
> [class.opacity-60]="!isElectron"
<div class="flex items-center justify-between gap-4"> >
<div> <div class="flex items-center justify-between gap-4">
<p class="text-sm font-medium text-foreground">Launch on system startup</p> <div>
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
@if (isElectron) { @if (isElectron) {
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p> <p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
} @else { } @else {
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p> <p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
} }
</div>
<label
class="relative inline-flex items-center"
[class.cursor-pointer]="isElectron && !savingAutoStart()"
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
>
<input
type="checkbox"
[checked]="autoStart()"
[disabled]="!isElectron || savingAutoStart()"
(change)="onAutoStartChange($event)"
id="general-auto-start-toggle"
aria-label="Toggle launch on startup"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div> </div>
</div>
<label <div
class="relative inline-flex items-center" class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
[class.cursor-pointer]="isElectron && !savingAutoStart()" [class.opacity-60]="!isElectron"
[class.cursor-not-allowed]="!isElectron || savingAutoStart()" >
> <div class="flex items-center justify-between gap-4">
<input <div>
type="checkbox" <p class="text-sm font-medium text-foreground">Minimize to tray on close</p>
[checked]="autoStart()"
[disabled]="!isElectron || savingAutoStart()" @if (isElectron) {
(change)="onAutoStartChange($event)" <p class="text-xs text-muted-foreground">Keep MetoYou running in the tray when you click the X button</p>
id="general-auto-start-toggle" } @else {
aria-label="Toggle launch on startup" <p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
class="sr-only peer" }
/> </div>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all" <label
></div> class="relative inline-flex items-center"
</label> [class.cursor-pointer]="isElectron && !savingCloseToTray()"
[class.cursor-not-allowed]="!isElectron || savingCloseToTray()"
>
<input
type="checkbox"
[checked]="closeToTray()"
[disabled]="!isElectron || savingCloseToTray()"
(change)="onCloseToTrayChange($event)"
id="general-close-to-tray-toggle"
aria-label="Toggle minimize to tray on close"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -8,6 +8,7 @@ import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePower } from '@ng-icons/lucide'; import { lucidePower } from '@ng-icons/lucide';
import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform'; import { PlatformService } from '../../../../core/platform';
@@ -28,7 +29,9 @@ export class GeneralSettingsComponent {
readonly isElectron = this.platform.isElectron; readonly isElectron = this.platform.isElectron;
autoStart = signal(false); autoStart = signal(false);
closeToTray = signal(true);
savingAutoStart = signal(false); savingAutoStart = signal(false);
savingCloseToTray = signal(false);
constructor() { constructor() {
if (this.isElectron) { if (this.isElectron) {
@@ -51,7 +54,7 @@ export class GeneralSettingsComponent {
try { try {
const snapshot = await api.setDesktopSettings({ autoStart: enabled }); const snapshot = await api.setDesktopSettings({ autoStart: enabled });
this.autoStart.set(snapshot.autoStart); this.applyDesktopSettings(snapshot);
} catch { } catch {
input.checked = this.autoStart(); input.checked = this.autoStart();
} finally { } finally {
@@ -59,6 +62,29 @@ export class GeneralSettingsComponent {
} }
} }
async onCloseToTrayChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const enabled = !!input.checked;
const api = this.electronBridge.getApi();
if (!this.isElectron || !api) {
input.checked = this.closeToTray();
return;
}
this.savingCloseToTray.set(true);
try {
const snapshot = await api.setDesktopSettings({ closeToTray: enabled });
this.applyDesktopSettings(snapshot);
} catch {
input.checked = this.closeToTray();
} finally {
this.savingCloseToTray.set(false);
}
}
private async loadDesktopSettings(): Promise<void> { private async loadDesktopSettings(): Promise<void> {
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();
@@ -69,7 +95,12 @@ export class GeneralSettingsComponent {
try { try {
const snapshot = await api.getDesktopSettings(); const snapshot = await api.getDesktopSettings();
this.autoStart.set(snapshot.autoStart); this.applyDesktopSettings(snapshot);
} catch {} } catch {}
} }
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
this.autoStart.set(snapshot.autoStart);
this.closeToTray.set(snapshot.closeToTray);
}
} }