From cbf8037099e2cbba87614e2585481f01abb117a1 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 20 Jul 2026 22:25:44 -0700 Subject: [PATCH] improve app logging - consolidate electron-log and expand support-oriented logging - consolidate log levels to info / trace only - add system/server diagnostics exporter --- src/i18n/locales/en.json | 7 +- src/main/features/core/autodiscover/index.ts | 5 +- src/main/features/core/custom-themes/index.ts | 20 +- src/main/features/core/discord-rpc/index.ts | 32 ++- src/main/features/core/lyrics/genius.ts | 8 +- src/main/features/core/lyrics/index.ts | 5 +- src/main/features/core/lyrics/lrclib.ts | 8 +- src/main/features/core/lyrics/netease.ts | 6 +- src/main/features/core/lyrics/simpmusic.ts | 10 +- src/main/features/core/player/index.ts | 34 ++- src/main/features/core/remote/index.ts | 35 ++- src/main/features/core/settings/index.ts | 17 +- src/main/features/linux/mpris.ts | 3 +- src/main/index.ts | 139 +++++++---- src/main/logger.ts | 229 ++++++++++++++++++ src/main/utils.ts | 44 ---- src/main/utils/zip.ts | 89 +++++++ src/preload/utils.ts | 14 ++ src/remote/store/index.ts | 210 +++++----------- src/renderer/api/controller.ts | 144 ++++++++++- src/renderer/api/navidrome/navidrome-api.ts | 5 +- .../api/subsonic/subsonic-controller.ts | 10 +- .../analytics/hooks/use-app-tracker.ts | 13 +- .../analytics/hooks/use-page-tracker.ts | 9 +- .../features/discord-rpc/use-discord-rpc.ts | 92 +++---- .../audio-player/engine/web-player-engine.tsx | 55 +++-- .../player/components/audio-players.tsx | 24 +- .../player/context/player-context.tsx | 183 ++++---------- .../features/player/hooks/use-auto-dj.ts | 15 +- .../features/player/hooks/use-scrobble.ts | 98 +++----- .../player/hooks/use-update-current-song.ts | 20 +- src/renderer/features/player/utils.ts | 34 ++- .../components/edit-radio-station-form.tsx | 7 +- .../features/remote/hooks/use-remote.tsx | 120 ++++----- .../components/advanced/logger-settings.tsx | 171 +++++++++---- .../components/playback/audio-settings.tsx | 5 +- .../components/sidebar-playlist-list.tsx | 1 - .../hooks/use-server-authenticated.ts | 162 +++++-------- .../hooks/use-sync-settings-to-main.ts | 11 +- src/renderer/utils/logger-message.ts | 124 ---------- src/renderer/utils/logger.ts | 177 +++++++++----- src/shared/logger/types.ts | 5 + src/shared/utils/is-local-url.ts | 43 ++++ src/shared/utils/sanitize-for-diagnostics.ts | 50 ++++ tsconfig.web.json | 3 + 45 files changed, 1429 insertions(+), 1067 deletions(-) create mode 100644 src/main/logger.ts create mode 100644 src/main/utils/zip.ts delete mode 100644 src/renderer/utils/logger-message.ts create mode 100644 src/shared/logger/types.ts create mode 100644 src/shared/utils/is-local-url.ts create mode 100644 src/shared/utils/sanitize-for-diagnostics.ts diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index fd74b27b0..cfffc4103 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1006,11 +1006,12 @@ "lyricOffset_description": "Offset the lyric by the specified amount of milliseconds", "lyricOffset": "Lyric offset (ms)", "logLevel": "Log level", - "logLevel_description": "Sets the minimum log level to display. Debug shows all logs, error only shows errors", + "logLevel_description": "Sets the minimum log level to display", "logLevel_optionDebug": "Debug", - "logLevel_optionError": "Error", + "logLevel_optionDebugDescription": "Includes info, plus verbose debug information and redacted API responses", "logLevel_optionInfo": "Info", - "logLevel_optionWarn": "Warn", + "logLevel_optionInfoDescription": "Includes info, warnings, and errors", + "exportDiagnostics": "Export diagnostics", "minimizeToTray_description": "Minimize the application to the system tray", "minimizeToTray": "Minimize to tray", "minimumScrobblePercentage_description": "The minimum percentage of the song that must be played before it is scrobbled", diff --git a/src/main/features/core/autodiscover/index.ts b/src/main/features/core/autodiscover/index.ts index 9155b1011..c7c5576d7 100644 --- a/src/main/features/core/autodiscover/index.ts +++ b/src/main/features/core/autodiscover/index.ts @@ -1,6 +1,7 @@ import { createSocket } from 'dgram'; import { ipcMain } from 'electron'; +import log from '/@/main/logger'; import { DiscoveredServerItem, ServerType } from '/@/shared/types/types'; type JellyfinResponse = { @@ -26,7 +27,7 @@ function discoverJellyfin(reply: (server: DiscoveredServerItem) => void) { }); } catch (e) { // Got a spurious response, ignore? - console.error(e); + log.error(e); } }); @@ -51,5 +52,5 @@ ipcMain.on('autodiscover-ping', (ev) => { discoverAll((result) => port.postMessage(result)) .then(() => port.close()) - .catch((err) => console.error(err)); + .catch((err) => log.error(err)); }); diff --git a/src/main/features/core/custom-themes/index.ts b/src/main/features/core/custom-themes/index.ts index 8a8a9f479..6f13f808f 100644 --- a/src/main/features/core/custom-themes/index.ts +++ b/src/main/features/core/custom-themes/index.ts @@ -5,6 +5,8 @@ import { promises as fs, watch as fsWatch } from 'fs'; import path from 'path'; import { validateHTMLColor } from 'validate-color'; +import log from '/@/main/logger'; + const isDevelopment = process.env.NODE_ENV === 'development'; const defaultUserDataPath = app.getPath('userData'); @@ -48,7 +50,7 @@ const sanitizeColors = ( sanitized[key] = value; } else { invalidKeys.push(key); - console.warn(`Custom theme "${themeId}" has an invalid color for "${key}": ${value}`); + log.warn(`Custom theme "${themeId}" has an invalid color for "${key}": ${value}`); } } @@ -128,7 +130,7 @@ const resolveStylesheetPaths = (themeDir: string, stylesheets?: string[]): strin relativeToThemes.startsWith('..') || path.isAbsolute(relativeToThemes); if (escapesThemesDir) { - console.warn( + log.warn( `Skipping linked file outside themes folder: ${relativePath} (from ${themeDir})`, ); continue; @@ -146,7 +148,7 @@ const readStylesheetContents = async (stylesheetPaths: string[]): Promise MAX_EXTENDS_DEPTH) { - console.warn( - `Custom theme "${id}" has a circular or too-deep "extends" chain, ignoring it`, - ); + log.warn(`Custom theme "${id}" has a circular or too-deep "extends" chain, ignoring it`); return { fields: { mode: 'dark' }, invalidColorKeys: [] }; } @@ -322,14 +322,14 @@ const reloadThemes = async () => { cache = await loadThemesFromDisk(); broadcastThemes(cache); } catch (error) { - console.error('Failed to load custom themes', error); + log.error('Failed to load custom themes', error); } }; const scheduleReload = () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - reloadThemes().catch((error) => console.error('Failed to reload custom themes', error)); + reloadThemes().catch((error) => log.error('Failed to reload custom themes', error)); }, RELOAD_DEBOUNCE_MS); }; @@ -357,7 +357,7 @@ const startWatcher = async () => { } }); } catch (error) { - console.error('Failed to watch themes folder', error); + log.error('Failed to watch themes folder', error); } }; @@ -384,7 +384,7 @@ app.whenReady() await startWatcher(); await reloadThemes(); }) - .catch((error) => console.error('Failed to initialize custom themes', error)); + .catch((error) => log.error('Failed to initialize custom themes', error)); app.on('before-quit', () => { if (watcher) { diff --git a/src/main/features/core/discord-rpc/index.ts b/src/main/features/core/discord-rpc/index.ts index a94c75269..f4085532f 100644 --- a/src/main/features/core/discord-rpc/index.ts +++ b/src/main/features/core/discord-rpc/index.ts @@ -1,6 +1,8 @@ import { Client, SetActivity } from '@xhayper/discord-rpc'; import { ipcMain } from 'electron'; +import log from '/@/main/logger'; + const FEISHIN_DISCORD_APPLICATION_ID = '1165957668758900787'; let client: Client | null = null; @@ -21,26 +23,36 @@ const isConnected = () => { const setActivity = (activity: SetActivity) => { if (client) { - client.user?.setActivity({ - ...activity, + void client.user?.setActivity({ ...activity }).catch((error) => { + log.warn('Discord RPC set activity failed', error); }); } }; const clearActivity = () => { if (client) { - client.user?.clearActivity(); + void client.user?.clearActivity().catch((error) => { + log.warn('Discord RPC clear activity failed', error); + }); } }; const quit = () => { if (client) { - client?.destroy(); + void client.destroy().catch((error) => { + log.error('Discord RPC destroy failed', error); + }); } }; ipcMain.handle('discord-rpc-initialize', async (_event, clientId?: string) => { - await createClient(clientId); + try { + await createClient(clientId); + log.info('Discord RPC initialized'); + } catch (error) { + log.error('Discord RPC initialize failed', error); + throw error; + } }); ipcMain.handle('discord-rpc-is-connected', () => { @@ -48,19 +60,17 @@ ipcMain.handle('discord-rpc-is-connected', () => { }); ipcMain.handle('discord-rpc-set-activity', (_event, activity: SetActivity) => { - if (client) { - setActivity(activity); - } + setActivity(activity); }); ipcMain.handle('discord-rpc-clear-activity', () => { - if (client) { - clearActivity(); - } + clearActivity(); }); ipcMain.handle('discord-rpc-quit', () => { quit(); + client = null; + log.info('Discord RPC quit'); }); export const discordRpc = { diff --git a/src/main/features/core/lyrics/genius.ts b/src/main/features/core/lyrics/genius.ts index ff2029d21..3bea5a6fc 100644 --- a/src/main/features/core/lyrics/genius.ts +++ b/src/main/features/core/lyrics/genius.ts @@ -9,6 +9,8 @@ import { } from '.'; import { orderSearchResults } from './shared'; +import log from '/@/main/logger'; + const SEARCH_URL = 'https://genius.com/api/search/song'; // Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/genius.ts @@ -100,7 +102,7 @@ export async function getLyricsBySongId(url: string): Promise { try { result = await axios.get(url, { responseType: 'text' }); } catch (e) { - console.error('Genius lyrics request got an error!', (e as Error)?.message); + log.error('Genius lyrics request got an error!', (e as Error)?.message); return null; } @@ -138,7 +140,7 @@ export async function getSearchResults( }, }); } catch (e) { - console.error('Genius search request got an error!', (e as Error)?.message); + log.error('Genius search request got an error!', (e as Error)?.message); return null; } @@ -193,7 +195,7 @@ async function getSongId( }, }); } catch (e) { - console.error('Genius search request got an error!', (e as Error)?.message); + log.error('Genius search request got an error!', (e as Error)?.message); return null; } diff --git a/src/main/features/core/lyrics/index.ts b/src/main/features/core/lyrics/index.ts index d6061db5a..2a0aa7eb6 100644 --- a/src/main/features/core/lyrics/index.ts +++ b/src/main/features/core/lyrics/index.ts @@ -17,6 +17,7 @@ import { getSearchResults as searchSimpMusic, } from './simpmusic'; +import log from '/@/main/logger'; import { Song } from '/@/shared/types/domain-types'; export enum LyricSource { @@ -110,7 +111,7 @@ const searchAllSources = async ( allSearchResults.push(...result.value.searchResults); } else if (result.status === 'rejected') { const index = settled.indexOf(result); - console.error(`Error searching ${sources[index]} for lyrics:`, result.reason); + log.error(`Error searching ${sources[index]} for lyrics:`, result.reason); } } return allSearchResults; @@ -174,7 +175,7 @@ const getRemoteLyrics = async (song: Song) => { }; } } catch (error) { - console.error(`Error fetching lyrics from ${bestMatch.source}:`, error); + log.error(`Error fetching lyrics from ${bestMatch.source}:`, error); } if (lyricsFromSource) { diff --git a/src/main/features/core/lyrics/lrclib.ts b/src/main/features/core/lyrics/lrclib.ts index 0d18feb52..361b900fe 100644 --- a/src/main/features/core/lyrics/lrclib.ts +++ b/src/main/features/core/lyrics/lrclib.ts @@ -9,6 +9,8 @@ import { } from '.'; import { orderSearchResults } from './shared'; +import log from '/@/main/logger'; + const FETCH_URL = 'https://lrclib.net/api/get'; const SEEARCH_URL = 'https://lrclib.net/api/search'; @@ -46,7 +48,7 @@ export async function getLyricsBySongId(songId: string): Promise try { result = await axios.get(`${FETCH_URL}/${songId}`); } catch (e) { - console.error('LrcLib lyrics request got an error!', (e as Error)?.message); + log.error('LrcLib lyrics request got an error!', (e as Error)?.message); return null; } @@ -71,7 +73,7 @@ export async function getSearchResults( }, }); } catch (e) { - console.error('LrcLib search request got an error!', (e as Error)?.message); + log.error('LrcLib search request got an error!', (e as Error)?.message); return null; } @@ -109,7 +111,7 @@ export async function query( timeout: TIMEOUT_MS, }); } catch (e) { - console.error('LrcLib search request got an error!', (e as Error).message); + log.error('LrcLib search request got an error!', (e as Error).message); return null; } diff --git a/src/main/features/core/lyrics/netease.ts b/src/main/features/core/lyrics/netease.ts index b06cbf7ff..c846e2367 100644 --- a/src/main/features/core/lyrics/netease.ts +++ b/src/main/features/core/lyrics/netease.ts @@ -9,6 +9,8 @@ import { import { store } from '../settings'; import { orderSearchResults } from './shared'; +import log from '/@/main/logger'; + const SEARCH_URL = 'https://music.163.com/api/search/get'; const LYRICS_URL = 'https://music.163.com/api/song/lyric'; @@ -81,7 +83,7 @@ export async function getLyricsBySongId(songId: string): Promise }, }); } catch (e) { - console.error('NetEase lyrics request got an error!', e); + log.error('NetEase lyrics request got an error!', e); return null; } const enableTranslation = store.get('enableNeteaseTranslation', false) as boolean; @@ -114,7 +116,7 @@ export async function getSearchResults( }, }); } catch (e) { - console.error('NetEase search request got an error!', e); + log.error('NetEase search request got an error!', e); return null; } diff --git a/src/main/features/core/lyrics/simpmusic.ts b/src/main/features/core/lyrics/simpmusic.ts index a85ecf0b8..40715ac01 100644 --- a/src/main/features/core/lyrics/simpmusic.ts +++ b/src/main/features/core/lyrics/simpmusic.ts @@ -8,6 +8,8 @@ import { } from '.'; import { orderSearchResults } from './shared'; +import log from '/@/main/logger'; + const API_URL = 'https://api-lyrics.simpmusic.org/v1'; const TIMEOUT_MS = 5000; @@ -38,7 +40,7 @@ export async function getLyricsBySongId(songId: string): Promise timeout: TIMEOUT_MS, }); } catch (e) { - console.error('SimpMusic lyrics request errored:', (e as Error)?.message); + log.error('SimpMusic lyrics request errored:', (e as Error)?.message); return null; } @@ -63,7 +65,7 @@ export async function getSearchResults( timeout: TIMEOUT_MS, }); } catch (e) { - console.error('SimpMusic search errored:', (e as Error)?.message); + log.error('SimpMusic search errored:', (e as Error)?.message); return null; } @@ -95,7 +97,7 @@ export async function query( timeout: TIMEOUT_MS, }); } catch (e) { - console.error('SimpMusic search errored:', (e as Error).message); + log.error('SimpMusic search errored:', (e as Error).message); return null; } @@ -109,7 +111,7 @@ export async function query( timeout: TIMEOUT_MS, }); } catch (e) { - console.error('SimpMusic lyrics fetch errored:', (e as Error).message); + log.error('SimpMusic lyrics fetch errored:', (e as Error).message); return null; } diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index 836e5645b..a9491a5ce 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -1,4 +1,3 @@ -import console from 'console'; import { app, ipcMain, powerMonitor } from 'electron'; import { access, rm } from 'fs/promises'; import uniq from 'lodash/uniq'; @@ -7,7 +6,7 @@ import { pid } from 'node:process'; import process from 'process'; import { getMainWindow, sendToastToRenderer } from '../../../index'; -import { createLog } from '../../../utils'; +import log from '../../../logger'; import { store } from '../settings'; import { isMacOS, isWindows } from '/@/main/env'; @@ -49,24 +48,29 @@ type NodeMpvError = { }; const mpvLog = ( - data: { action: string; toast?: 'info' | 'success' | 'warning' }, + data: { + action: string; + level?: 'debug' | 'error' | 'info' | 'warn'; + toast?: 'info' | 'success' | 'warning'; + }, err?: NodeMpvError, ) => { const { action, toast } = data; if (err) { - const message = `[AUDIO PLAYER] ${action} - mpv errorcode ${err.errcode} - ${ + const message = `${action} - mpv errorcode ${err.errcode} - ${ NodeMpvErrorCode[err.errcode as keyof typeof NodeMpvErrorCode] }`; sendToastToRenderer({ message, type: 'error' }); - createLog({ message, type: 'error' }); + log.error(message); + return; } - const message = `[AUDIO PLAYER] ${action}`; - createLog({ message, type: 'error' }); + const level = data.level ?? 'info'; + log[level](action); if (toast) { - sendToastToRenderer({ message, type: toast }); + sendToastToRenderer({ message: action, type: toast }); } }; @@ -156,8 +160,9 @@ const createMpv = async (data: { try { await mpv.start(); + log.info('mpv initialized', { binary: resolvedBinaryPath ?? 'bundled/default' }); } catch (error: any) { - console.error('mpv failed to start', error); + log.error('mpv failed to start', error); } finally { await mpv.setMultipleProperties(properties || {}); } @@ -265,11 +270,14 @@ const quit = async (instance?: MpvAPI | null) => { }; const setAudioPlayerFallback = (isError: boolean) => { + if (isError) { + log.warn('Falling back to web player'); + } getMainWindow()?.webContents.send('renderer-player-fallback', isError); }; ipcMain.on('player-set-properties', async (_event, data: Record) => { - mpvLog({ action: `Setting properties: ${JSON.stringify(data)}` }); + mpvLog({ action: `Setting properties: ${JSON.stringify(data)}`, level: 'debug' }); if (data.length === 0) { return; } @@ -291,6 +299,7 @@ ipcMain.handle( try { mpvLog({ action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`, + level: 'debug', }); // Clean up previous mpv instance @@ -318,6 +327,7 @@ ipcMain.handle( try { mpvLog({ action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`, + level: 'debug', }); mpvInstance = await createMpv(data); setAudioPlayerFallback(false); @@ -778,7 +788,7 @@ process.on('SIGTERM', async () => { // Handle uncaught exceptions - cleanup mpv before crashing process.on('uncaughtException', async (error) => { - console.error('Uncaught exception:', error); + log.error('Uncaught exception:', error); await cleanupMpv(true).catch(() => { // Ignore cleanup errors during crash }); @@ -786,7 +796,7 @@ process.on('uncaughtException', async (error) => { // Handle unhandled rejections - cleanup mpv process.on('unhandledRejection', async (reason) => { - console.error('Unhandled rejection:', reason); + log.error('Unhandled rejection:', reason); await cleanupMpv(true).catch(() => { // Ignore cleanup errors }); diff --git a/src/main/features/core/remote/index.ts b/src/main/features/core/remote/index.ts index 124931297..754d53ec7 100644 --- a/src/main/features/core/remote/index.ts +++ b/src/main/features/core/remote/index.ts @@ -11,6 +11,7 @@ import manifest from './manifest.json'; import { isLinux } from '/@/main/env'; import { getMainWindow } from '/@/main/index'; +import log from '/@/main/logger'; import { QueueSong } from '/@/shared/types/domain-types'; import { ClientEvent, ServerEvent } from '/@/shared/types/remote-types'; import { PlayerRepeat, PlayerStatus, SongState } from '/@/shared/types/types'; @@ -76,6 +77,10 @@ function send({ client, data, event }: SendData): void { } export const shutdownServer = () => { + if (wsServer || server) { + log.info('Remote server shutting down'); + } + if (wsServer) { wsServer.clients.forEach((client) => client.close(4000)); wsServer.close(); @@ -332,24 +337,44 @@ const enableServer = (config: RemoteConfig): Promise => { } }); - server.listen(config.port, resolve); + let settled = false; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + }; + + server.listen(config.port, () => { + log.info('Remote server listening', { port: config.port }); + settle(() => resolve()); + }); + server.on('error', (error) => { + log.error('Remote server listen failed', { error, port: config.port }); + settle(() => reject(error)); + }); wsServer = new WebSocketServer({ server }); wsServer!.on('connection', (ws: StatefulWebSocket) => { let authFail: number | undefined; ws.alive = true; + log.info('Remote client connected', { clients: wsServer?.clients.size }); if (!settings.username && !settings.password) { ws.auth = true; } else { authFail = setTimeout(() => { if (!ws.auth) { + log.warn('Remote client auth timeout'); ws.close(); } }, 10000) as unknown as number; } - ws.on('error', console.error); + ws.on('error', log.error); + + ws.on('close', () => { + log.info('Remote client disconnected', { clients: wsServer?.clients.size }); + }); ws.on('message', (data) => { try { @@ -365,7 +390,9 @@ const enableServer = (config: RemoteConfig): Promise => { if (login === settings.username && password === settings.password) { ws.auth = true; + log.info('Remote client authenticated'); } else { + log.warn('Remote client auth failed'); ws.close(); } @@ -488,7 +515,7 @@ const enableServer = (config: RemoteConfig): Promise => { } } } catch (error) { - console.error(error); + log.error(error); } }); @@ -516,7 +543,7 @@ const enableServer = (config: RemoteConfig): Promise => { }); setTimeout(() => { - reject(new Error('Server did not come up')); + settle(() => reject(new Error('Server did not come up'))); }, UP_TIMEOUT_MS); } catch (error) { reject(error); diff --git a/src/main/features/core/settings/index.ts b/src/main/features/core/settings/index.ts index cf49d64c2..6dd166952 100644 --- a/src/main/features/core/settings/index.ts +++ b/src/main/features/core/settings/index.ts @@ -15,6 +15,8 @@ import Store from 'electron-store'; import { promises as fs, watch as fsWatch } from 'fs'; import path from 'path'; +import log from '/@/main/logger'; + const getFrame = () => { const isWindows = process.platform === 'win32'; const isMacOS = process.platform === 'darwin'; @@ -52,7 +54,7 @@ const readCustomCss = async (): Promise<{ content: string; exists: boolean }> => return { content: '', exists: false }; } - console.error('Failed to read custom css file', error); + log.error('Failed to read custom css file', error); return { content: '', exists: false }; } }; @@ -75,7 +77,7 @@ const scheduleCustomCssUpdate = () => { customCssDebounce = setTimeout(() => { notifyCustomCssUpdate().catch((error) => { - console.error('Failed to broadcast custom css update', error); + log.error('Failed to broadcast custom css update', error); }); }, 100); }; @@ -94,13 +96,13 @@ const startCustomCssWatcher = async () => { } }); } catch (error) { - console.error('Failed to watch custom css file', error); + log.error('Failed to watch custom css file', error); } }; export const store = new Store({ beforeEachMigration: (_store, context) => { - console.log(`settings migrate from ${context.fromVersion} → ${context.toVersion}`); + log.info(`settings migrate from ${context.fromVersion} → ${context.toVersion}`); }, cwd: storePath, defaults: { @@ -164,6 +166,7 @@ ipcMain.handle('password-get', (_event, server: string): null | string => { return decrypted; } + log.warn('Password encryption unavailable', { serverId: server }); return null; }); @@ -173,6 +176,7 @@ ipcMain.on('password-remove', (_event, server: string) => { delete passwords[server]; } store.set({ server: passwords }); + log.info('Password removed', { serverId: server }); }); ipcMain.handle('password-set', (_event, password: string, server: string) => { @@ -182,8 +186,11 @@ ipcMain.handle('password-set', (_event, password: string, server: string) => { passwords[server] = encrypted.toString('hex'); store.set({ server: passwords }); + log.info('Password saved', { serverId: server }); return true; } + + log.warn('Password encryption unavailable', { serverId: server }); return false; }); @@ -226,7 +233,7 @@ ipcMain.handle('custom-css-open-folder', async () => { app.whenReady() .then(() => startCustomCssWatcher()) - .catch((error) => console.error('Failed to start custom css watcher', error)); + .catch((error) => log.error('Failed to start custom css watcher', error)); app.on('before-quit', () => { if (customCssWatcher) { diff --git a/src/main/features/linux/mpris.ts b/src/main/features/linux/mpris.ts index 950dcc74f..34c97d689 100644 --- a/src/main/features/linux/mpris.ts +++ b/src/main/features/linux/mpris.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron'; import Player from 'mpris-service'; import { getMainWindow } from '/@/main/index'; +import log from '/@/main/logger'; import { MPV_VOLUME_MAX_CEILING } from '/@/shared/constants/volume'; import { QueueSong } from '/@/shared/types/domain-types'; import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types'; @@ -200,7 +201,7 @@ ipcMain.on( 'xesam:userRating': song.userRating ? song.userRating / 5 : null, }; } catch (err) { - console.error(err); + log.error(err); } }, ); diff --git a/src/main/index.ts b/src/main/index.ts index 9829e6827..6da74f9e1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -21,7 +21,6 @@ import { Tray, } from 'electron'; import electronLocalShortcut from 'electron-localshortcut'; -import log from 'electron-log/main'; import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater'; import { access, constants } from 'fs'; import path, { join } from 'path'; @@ -32,9 +31,10 @@ import { disableMediaKeys, enableMediaKeys } from './features/core/player/media- import { shutdownServer } from './features/core/remote'; import { store } from './features/core/settings'; import { canHandleVisualizerDisplayMedia } from './features/core/visualizer'; +import log, { autoUpdaterLogInterface } from './logger'; import MenuBuilder, { MenuPlaybackState } from './menu'; import './features'; -import { autoUpdaterLogInterface, createLog, hotkeyToElectronAccelerator } from './utils'; +import { hotkeyToElectronAccelerator } from './utils'; import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env'; import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types'; @@ -62,13 +62,21 @@ type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoU class AppUpdater { constructor() { const effectiveChannel = store.get('release_channel') as string; - console.log('Effective update channel:', effectiveChannel); + log.info('Effective update channel:', effectiveChannel); if (effectiveChannel === 'alpha') { checkAllChannelsAndGetBest().then(({ result, updater: updaterInstance }) => { + attachUpdaterMilestoneLogs(updaterInstance); + if (!result?.isUpdateAvailable) { + log.info('Updater check complete', { available: false }); return; } + log.info('Updater check complete', { + available: true, + version: result.updateInfo.version, + }); + updaterInstance.autoInstallOnAppQuit = true; updaterInstance.autoRunAppAfterInstall = true; if (isMacOS()) { @@ -77,6 +85,7 @@ class AppUpdater { result.updateInfo.version, ); } else { + log.info('Updater download starting', { version: result.updateInfo.version }); updaterInstance.autoDownload = true; updaterInstance.checkForUpdatesAndNotify(); } @@ -84,26 +93,65 @@ class AppUpdater { return; } - configureAndGetUpdater(); + const updater = configureAndGetUpdater(); + attachUpdaterMilestoneLogs(updater); + if (isMacOS()) { autoUpdater.autoDownload = false; autoUpdater .checkForUpdates() .then((result) => { if (result?.isUpdateAvailable) { + log.info('Updater check complete', { + available: true, + version: result.updateInfo.version, + }); getMainWindow()?.webContents.send( 'update-available', result.updateInfo.version, ); + } else { + log.info('Updater check complete', { available: false }); } }) - .catch((err) => console.error('Check for updates failed', err)); + .catch((err) => log.error('Check for updates failed', err)); } else { autoUpdater.checkForUpdatesAndNotify(); } } } +function attachUpdaterMilestoneLogs(updater: UpdaterInstance): void { + let downloadStarted = false; + + updater.on('checking-for-update', () => { + log.info('Updater checking for update'); + }); + + updater.on('update-available', (info) => { + log.info('Updater update available', { version: info.version }); + }); + + updater.on('update-not-available', (info) => { + log.info('Updater update not available', { version: info.version }); + }); + + updater.on('download-progress', () => { + if (!downloadStarted) { + downloadStarted = true; + log.info('Updater download starting'); + } + }); + + updater.on('update-downloaded', (info) => { + log.info('Updater download complete', { version: info.version }); + }); + + updater.on('error', (error) => { + log.error('Updater error', error); + }); +} + // When release channel is alpha, check alpha and latest for updates and return // the updater + result for the newest version found (so alpha users can receive // latest updates when they are newer than the current alpha). @@ -121,7 +169,7 @@ async function checkAllChannelsAndGetBest(): Promise<{ const alphaUpdater = createAlphaUpdaterInstance({ probeOnly: true }); try { - console.log('Checking for updates on alpha channel'); + log.info('Checking for updates on alpha channel'); const alphaResult = await alphaUpdater.checkForUpdates(); if ( alphaResult?.updateInfo?.version && @@ -137,7 +185,7 @@ async function checkAllChannelsAndGetBest(): Promise<{ try { const latestUpdater = createGithubUpdaterInstance('latest', { probeOnly: true }); - console.log('Checking for updates on latest channel (GitHub)'); + log.info('Checking for updates on latest channel (GitHub)'); const latestResult = await latestUpdater.checkForUpdates(); if ( latestResult?.updateInfo?.version && @@ -173,13 +221,13 @@ function configureAndGetUpdater(): UpdaterInstance { let releaseChannel = store.get('release_channel'); const isNotConfigured = !releaseChannel; - console.log('Release channel:', releaseChannel); - console.log('Is beta version:', isBetaVersion); - console.log('Is alpha version:', isAlphaVersion); - console.log('Is not configured:', isNotConfigured); + log.info('Release channel:', releaseChannel); + log.info('Is beta version:', isBetaVersion); + log.info('Is alpha version:', isAlphaVersion); + log.info('Is not configured:', isNotConfigured); if (isNotConfigured) { - console.log('Release channel not configured, setting default channel'); + log.info('Release channel not configured, setting default channel'); const defaultChannel = isAlphaVersion ? 'alpha' : isBetaVersion ? 'beta' : 'latest'; store.set('release_channel', defaultChannel); releaseChannel = defaultChannel; @@ -188,11 +236,9 @@ function configureAndGetUpdater(): UpdaterInstance { const effectiveChannel = store.get('release_channel') as string; if (effectiveChannel === 'alpha') { - log.transports.file.level = 'info'; return createAlphaUpdaterInstance(); } - log.transports.file.level = 'info'; autoUpdater.logger = autoUpdaterLogInterface; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoRunAppAfterInstall = true; @@ -216,7 +262,6 @@ function configureAndGetUpdater(): UpdaterInstance { * Used when checking multiple channels or when the winning channel is beta/latest. */ function configureAutoUpdaterForChannel(channel: 'beta' | 'latest'): void { - log.transports.file.level = 'info'; autoUpdater.logger = autoUpdaterLogInterface; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoRunAppAfterInstall = true; @@ -296,7 +341,7 @@ protocol.registerSchemesAsPrivileged([ ]); process.on('uncaughtException', (error: any) => { - console.error('Error in main process', error); + log.error('Error in main process', error); }); if (store.get('ignore_ssl')) { @@ -320,6 +365,13 @@ let currentPrivateMode = false; let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE; let currentSidebarCollapsed = false; let currentShuffleEnabled = false; + +app.on('before-quit', () => { + if (isMacOS()) { + forceQuit = true; + } + log.info('App quitting', { reason: exitFromTray ? 'tray' : 'before-quit' }); +}); let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {}; let inputFocused = false; @@ -357,10 +409,7 @@ const installExtensions = async () => { { forceDownload }, ) .then((installedExtensions) => { - createLog({ - message: `Installed extension: ${installedExtensions}`, - type: 'info', - }); + log.info(`Installed extension: ${installedExtensions}`); }) .catch(() => { // Ignore @@ -542,7 +591,7 @@ const validateUrl = (url: string): boolean => { async function createWindow(first = true): Promise { if (isDevelopment) { - await installExtensions().catch(console.log); + await installExtensions().catch((error) => log.error(error)); } const nativeFrame = store.get('window_window_bar_style', 'linux') === 'linux'; @@ -635,6 +684,7 @@ async function createWindow(first = true): Promise { }); ipcMain.on('window-quit', () => { + log.info('App quitting', { reason: 'window-quit' }); shutdownServer(); mainWindow?.close(); app.exit(); @@ -700,9 +750,23 @@ async function createWindow(first = true): Promise { mainWindow.show(); createWinThumbarButtons(); } + + log.info('Main window created', { startMinimized: startWindowMinimized && first }); + }); + + mainWindow.webContents.on('render-process-gone', (_event, details) => { + log.error('Renderer process gone', { + exitCode: details.exitCode, + reason: details.reason, + }); + }); + + mainWindow.webContents.on('unresponsive', () => { + log.error('Renderer process unresponsive'); }); mainWindow.on('closed', () => { + log.info('Main window closed'); ipcMain.removeHandler('window-clear-cache'); ipcMain.removeHandler('app-check-for-updates'); mainWindow = null; @@ -715,10 +779,12 @@ async function createWindow(first = true): Promise { if (!exitFromTray && store.get('window_exit_to_tray')) { event.preventDefault(); + log.info('Main window hidden to tray'); mainWindow?.hide(); } if (forceQuit) { + log.info('App quitting', { reason: 'forceQuit' }); app.exit(); } }); @@ -726,6 +792,7 @@ async function createWindow(first = true): Promise { (mainWindow as any).on('minimize', (event: any) => { if (store.get('window_minimize_to_tray') === true) { event.preventDefault(); + log.info('Main window minimized to tray'); mainWindow?.hide(); } }); @@ -734,12 +801,6 @@ async function createWindow(first = true): Promise { app.setAppUserModelId('org.jeffvli.feishin'); } - if (isMacOS()) { - app.on('before-quit', () => { - forceQuit = true; - }); - } - menuBuilder = new MenuBuilder(mainWindow); rebuildMainMenu(); @@ -949,19 +1010,6 @@ ipcMain.on( }, ); -ipcMain.on( - 'logger', - ( - _event, - data: { - message: string; - type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning'; - }, - ) => { - createLog(data); - }, -); - ipcMain.handle('power-save-blocker-start', (_event, { full }: { full: boolean }) => { if (powerSaveBlockerId !== null) { return powerSaveBlockerId; @@ -1044,6 +1092,15 @@ if (!singleInstance) { app.whenReady() .then(() => { + log.info('App ready', { + arch: process.arch, + electron: process.versions.electron, + ignoreCors: !!store.get('ignore_cors'), + ignoreSsl: !!store.get('ignore_ssl'), + platform: process.platform, + version: packageJson.version, + }); + protocol.handle('feishin', async () => { const filePath = store.get('local_font_path'); if (typeof filePath !== 'string') { @@ -1114,7 +1171,7 @@ if (!singleInstance) { } }); }) - .catch(console.log); + .catch((error) => log.error(error)); } // Register 'open-item' handler globally, ensuring it is only registered once diff --git a/src/main/logger.ts b/src/main/logger.ts new file mode 100644 index 000000000..269c0ddbe --- /dev/null +++ b/src/main/logger.ts @@ -0,0 +1,229 @@ +import type { LogLevel } from '/@/shared/logger/types'; + +import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; +import log from 'electron-log/main'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import util from 'node:util'; + +import { createZipBuffer } from '/@/main/utils/zip'; +import { sanitizeForDiagnostics } from '/@/shared/utils/sanitize-for-diagnostics'; + +export type { LogLevel }; +export type { LogSeverity } from '/@/shared/logger/types'; + +const PROCESS_WIDTH = 10; // width of "[renderer]" +const LEVEL_WIDTH = 5; // width of "DEBUG" / "ERROR" +const RESET = '\x1B[0m'; + +const levelColors: Record = { + debug: '\x1B[38;2;100;149;237m', // #6495ED + error: '\x1B[38;2;255;100;100m', // #ff6464 + info: '\x1B[38;2;76;175;80m', // #4caf50 + warn: '\x1B[38;2;225;125;50m', // #e17d32 +}; + +const formatLogLine = ({ + colorize = false, + data, + level, + message, +}: { + colorize?: boolean; + data: unknown[]; + level: string; + message: { date: Date; variables?: { processType?: string } }; +}): string[] => { + const processType = message.variables?.processType === 'renderer' ? 'renderer' : 'main'; + const paddedLevel = String(level).toUpperCase().padEnd(LEVEL_WIDTH, ' '); + const levelLabel = + colorize && levelColors[level] + ? `${levelColors[level]}${paddedLevel}${RESET}` + : paddedLevel; + const processLabel = `[${processType}]`.padEnd(PROCESS_WIDTH, ' '); + const text = data + .map((item) => { + if (typeof item === 'string') { + return item; + } + + return util.inspect(item, { + breakLength: 80, + colors: colorize, + compact: false, + depth: null, + }); + }) + .join(' '); + + return [`${message.date.toISOString()} ${levelLabel} ${processLabel} ${text}`]; +}; + +const isLogLevel = (value: unknown): value is LogLevel => { + return value === 'debug' || value === 'info'; +}; + +export const setLogLevel = (level: LogLevel) => { + log.transports.file.level = level; + log.transports.console.level = level; +}; + +log.initialize(); +setLogLevel( + process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' ? 'debug' : 'info', +); +log.transports.file.format = (params) => formatLogLine({ ...params, colorize: false }); +log.transports.file.maxSize = 1024 * 1024 * 10; // 10MB +log.transports.console.format = (params) => formatLogLine({ ...params, colorize: true }); + +ipcMain.on('logger-set-level', (_event, level: unknown) => { + if (isLogLevel(level)) { + setLogLevel(level); + } +}); + +ipcMain.handle('logger-open-folder', async () => { + const logFilePath = log.transports.file.getFile().path; + const logsPath = path.dirname(logFilePath); + await shell.openPath(logsPath); + return true; +}); + +type ExportDiagnosticsPayload = { + logLevel?: null | string; + rendererSettings?: null | Record; + server?: null | Record; +}; + +const exportDiagnosticsArchive = async (payload: ExportDiagnosticsPayload = {}) => { + const parentWindow = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const stamp = new Date().toISOString().slice(0, 10); + const saveOptions = { + defaultPath: `feishin-diagnostics-${stamp}.zip`, + filters: [{ extensions: ['zip'], name: 'Zip' }], + }; + const result = parentWindow + ? await dialog.showSaveDialog(parentWindow, saveOptions) + : await dialog.showSaveDialog(saveOptions); + + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + const logFile = log.transports.file.getFile(); + const logsDir = path.dirname(logFile.path); + const entries: { data: Buffer; name: string }[] = []; + + const { store } = await import('/@/main/features/core/settings'); + + const diagnostics = { + app: { + name: app.getName(), + version: app.getVersion(), + }, + arch: process.arch, + chrome: process.versions.chrome, + electron: process.versions.electron, + exportedAt: new Date().toISOString(), + locale: app.getLocale(), + logLevel: payload.logLevel ?? null, + node: process.versions.node, + os: { + release: os.release(), + type: os.type(), + version: typeof os.version === 'function' ? os.version() : undefined, + }, + platform: process.platform, + server: payload.server ?? null, + settings: { + ignoreCors: store.get('ignore_cors'), + ignoreSsl: store.get('ignore_ssl'), + releaseChannel: store.get('release_channel'), + }, + }; + + entries.push({ + data: Buffer.from(`${JSON.stringify(diagnostics, null, 2)}\n`, 'utf8'), + name: 'diagnostics.json', + }); + + try { + const configRaw = await fs.readFile(store.path, 'utf8'); + const configJson = JSON.parse(configRaw) as unknown; + entries.push({ + data: Buffer.from( + `${JSON.stringify(sanitizeForDiagnostics(configJson), null, 2)}\n`, + 'utf8', + ), + name: 'main-config.json', + }); + } catch (error) { + log.warn('Failed to read main config for diagnostics export', error); + } + + if (payload.rendererSettings) { + entries.push({ + data: Buffer.from( + `${JSON.stringify(sanitizeForDiagnostics(payload.rendererSettings), null, 2)}\n`, + 'utf8', + ), + name: 'renderer-settings.json', + }); + } + + try { + const logFiles = await fs.readdir(logsDir); + for (const fileName of logFiles) { + if (!fileName.endsWith('.log')) { + continue; + } + const filePath = path.join(logsDir, fileName); + const data = await fs.readFile(filePath); + entries.push({ data, name: `logs/${fileName}` }); + } + } catch (error) { + log.warn('Failed to read log files for export', error); + } + + if (!entries.some((entry) => entry.name.startsWith('logs/'))) { + try { + entries.push({ + data: await fs.readFile(logFile.path), + name: `logs/${path.basename(logFile.path)}`, + }); + } catch (error) { + log.error('Failed to read active log file for export', error); + throw error; + } + } + + const zipBuffer = createZipBuffer(entries); + await fs.writeFile(result.filePath, zipBuffer); + + return { canceled: false, path: result.filePath }; +}; + +ipcMain.handle('logger-export-diagnostics', async (_event, payload?: ExportDiagnosticsPayload) => { + return exportDiagnosticsArchive(payload); +}); + +export default log; + +export const autoUpdaterLogInterface = { + debug: (message: string) => { + log.debug(message); + }, + + error: (message: string) => { + log.error(message); + }, + + info: (message: string) => { + log.info(message); + }, + + warn: (message: string) => { + log.warn(message); + }, +}; diff --git a/src/main/utils.ts b/src/main/utils.ts index a6e5ed909..1b3b938f0 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -1,4 +1,3 @@ -import log from 'electron-log/main'; import path from 'path'; import process from 'process'; import { URL } from 'url'; @@ -42,46 +41,3 @@ export const hotkeyToElectronAccelerator = (hotkey: string) => { return accelerator; }; - -const logMethod = { - debug: log.debug, - error: log.error, - info: log.info, - success: log.info, - verbose: log.verbose, - warning: log.warn, -}; - -const logColor = { - debug: 'blue', - error: 'red', - info: 'blue', - success: 'green', - verbose: 'blue', - warning: 'yellow', -}; - -export const createLog = (data: { - message: string; - type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning'; -}) => { - logMethod[data.type](`%c${data.message}`, `color: ${logColor[data.type]}`); -}; - -export const autoUpdaterLogInterface = { - debug: (message: string) => { - createLog({ message: `[SYSTEM] ${message}`, type: 'debug' }); - }, - - error: (message: string) => { - createLog({ message: `[SYSTEM] ${message}`, type: 'error' }); - }, - - info: (message: string) => { - createLog({ message: `[SYSTEM] ${message}`, type: 'info' }); - }, - - warn: (message: string) => { - createLog({ message: `[SYSTEM] ${message}`, type: 'warning' }); - }, -}; diff --git a/src/main/utils/zip.ts b/src/main/utils/zip.ts new file mode 100644 index 000000000..f3014d614 --- /dev/null +++ b/src/main/utils/zip.ts @@ -0,0 +1,89 @@ +import { deflateRawSync } from 'node:zlib'; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i += 1) { + let crc = i; + for (let j = 0; j < 8; j += 1) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + table[i] = crc >>> 0; + } + return table; +})(); + +const crc32 = (data: Buffer): number => { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i += 1) { + crc = CRC_TABLE[(crc ^ data[i]) & 0xff]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +export type ZipEntry = { + data: Buffer; + name: string; +}; + +/** Build a zip archive (deflate) without external dependencies. */ +export const createZipBuffer = (entries: ZipEntry[]): Buffer => { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + + for (const entry of entries) { + const nameBuf = Buffer.from(entry.name, 'utf8'); + const compressed = deflateRawSync(entry.data); + const checksum = crc32(entry.data); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0, 6); // flags + localHeader.writeUInt16LE(8, 8); // deflate + localHeader.writeUInt16LE(0, 10); // time + localHeader.writeUInt16LE(0, 12); // date + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(compressed.length, 18); + localHeader.writeUInt32LE(entry.data.length, 22); + localHeader.writeUInt16LE(nameBuf.length, 26); + localHeader.writeUInt16LE(0, 28); // extra length + + localParts.push(localHeader, nameBuf, compressed); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); // version made by + centralHeader.writeUInt16LE(20, 6); // version needed + centralHeader.writeUInt16LE(0, 8); + centralHeader.writeUInt16LE(8, 10); + centralHeader.writeUInt16LE(0, 12); + centralHeader.writeUInt16LE(0, 14); + centralHeader.writeUInt32LE(checksum, 16); + centralHeader.writeUInt32LE(compressed.length, 20); + centralHeader.writeUInt32LE(entry.data.length, 24); + centralHeader.writeUInt16LE(nameBuf.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE(0, 38); + centralHeader.writeUInt32LE(offset, 42); + + centralParts.push(centralHeader, nameBuf); + offset += localHeader.length + nameBuf.length + compressed.length; + } + + const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, ...centralParts, end]); +}; diff --git a/src/preload/utils.ts b/src/preload/utils.ts index 9fa245a8c..5db48cc82 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -67,6 +67,18 @@ const openCustomCssFolder = async () => { return ipcRenderer.invoke('custom-css-open-folder'); }; +const openLogsFolder = async () => { + return ipcRenderer.invoke('logger-open-folder'); +}; + +const exportDiagnostics = async (payload?: { + logLevel?: null | string; + rendererSettings?: null | Record; + server?: null | Record; +}): Promise<{ canceled: boolean; path?: string }> => { + return ipcRenderer.invoke('logger-export-diagnostics', payload); +}; + const customCssUpdatedListener = ( cb: (data: { content?: string; exists?: boolean; path?: string }) => void, ) => { @@ -164,6 +176,7 @@ export const utils = { customCssUpdatedListener, disableAutoUpdates, download, + exportDiagnostics, forceGarbageCollection, getCustomCss, isLinux, @@ -175,6 +188,7 @@ export const utils = { openApplicationDirectory, openCustomCssFolder, openItem, + openLogsFolder, playerErrorListener, readLocalImage, readSongMetadataBatch, diff --git a/src/remote/store/index.ts b/src/remote/store/index.ts index f1cbc901d..b9c863578 100644 --- a/src/remote/store/index.ts +++ b/src/remote/store/index.ts @@ -3,8 +3,7 @@ import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { createWithEqualityFn } from 'zustand/traditional'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { toast } from '/@/shared/components/toast/toast'; import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/shared/types/remote-types'; @@ -42,9 +41,7 @@ export const useRemoteStore = createWithEqualityFn()( immer((set, get) => ({ actions: { reconnect: async () => { - logFn.debug(logMsg[LogCategory.REMOTE].reconnectInitiated, { - category: LogCategory.REMOTE, - }); + logger.info('Reconnect initiated'); const existing = get().socket; if (existing) { @@ -52,9 +49,8 @@ export const useRemoteStore = createWithEqualityFn()( existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING ) { - logFn.debug(logMsg[LogCategory.REMOTE].closingExistingSocket, { - category: LogCategory.REMOTE, - meta: { readyState: existing.readyState }, + logger.debug('Closing existing socket', { + readyState: existing.readyState, }); existing.natural = true; existing.close(4001); @@ -64,28 +60,17 @@ export const useRemoteStore = createWithEqualityFn()( let authHeader: string | undefined; try { - logFn.debug(logMsg[LogCategory.REMOTE].fetchingCredentials, { - category: LogCategory.REMOTE, - }); + logger.debug('Fetching credentials'); const credentials = await fetch('/credentials'); authHeader = await credentials.text(); - logFn.debug(logMsg[LogCategory.REMOTE].credentialsFetched, { - category: LogCategory.REMOTE, - meta: { hasAuthHeader: !!authHeader }, - }); + logger.debug('Credentials fetched', { hasAuthHeader: !!authHeader }); } catch (error) { - logFn.error(logMsg[LogCategory.REMOTE].failedToGetCredentials, { - category: LogCategory.REMOTE, - meta: { error }, - }); + logger.error('Failed to get credentials', { error }); } set((state) => { const wsUrl = location.href.replace('http', 'ws'); - logFn.debug(logMsg[LogCategory.REMOTE].creatingWebSocket, { - category: LogCategory.REMOTE, - meta: { url: wsUrl }, - }); + logger.info('Creating new WebSocket', { url: wsUrl }); const socket = new WebSocket(wsUrl) as StatefulWebSocket; socket.natural = false; @@ -93,34 +78,19 @@ export const useRemoteStore = createWithEqualityFn()( socket.addEventListener('message', (message) => { const { data, event } = JSON.parse(message.data) as ServerEvent; - logFn.debug(logMsg[LogCategory.REMOTE].webSocketMessageReceived, { - category: LogCategory.REMOTE, - meta: { data, event }, - }); + logger.debug('WebSocket message received', { data, event }); switch (event) { case 'error': { - logFn.error( - logMsg[LogCategory.REMOTE].webSocketErrorEvent, - { - category: LogCategory.REMOTE, - meta: { data }, - }, - ); + logger.error('WebSocket error event', { data }); toast.error({ message: data, title: 'Socket error' }); break; } case 'favorite': { - logFn.debug( - logMsg[LogCategory.REMOTE].favoriteEventReceived, - { - category: LogCategory.REMOTE, - meta: { - favorite: data.favorite, - id: data.id, - }, - }, - ); + logger.debug('Favorite event received', { + favorite: data.favorite, + id: data.id, + }); set((state) => { if (state.info.song?.id === data.id) { state.info.song.userFavorite = data.favorite; @@ -129,38 +99,23 @@ export const useRemoteStore = createWithEqualityFn()( break; } case 'playback': { - logFn.debug( - logMsg[LogCategory.REMOTE].playbackEventReceived, - { - category: LogCategory.REMOTE, - meta: { status: data }, - }, - ); + logger.debug('Playback event received', { status: data }); set((state) => { state.info.status = data; }); break; } case 'position': { - logFn.debug( - logMsg[LogCategory.REMOTE].positionEventReceived, - { - category: LogCategory.REMOTE, - meta: { position: data }, - }, - ); + logger.debug('Position event received', { position: data }); set((state) => { state.info.position = data; }); break; } case 'proxy': { - logFn.debug(logMsg[LogCategory.REMOTE].proxyEventReceived, { - category: LogCategory.REMOTE, - meta: { - dataLength: data?.length, - hasData: !!data, - }, + logger.debug('Proxy event received (image update)', { + dataLength: data?.length, + hasData: !!data, }); set((state) => { if (state.info.song) { @@ -170,16 +125,10 @@ export const useRemoteStore = createWithEqualityFn()( break; } case 'rating': { - logFn.debug( - logMsg[LogCategory.REMOTE].ratingEventReceived, - { - category: LogCategory.REMOTE, - meta: { - id: data.id, - rating: data.rating, - }, - }, - ); + logger.debug('Rating event received', { + id: data.id, + rating: data.rating, + }); set((state) => { if (state.info.song?.id === data.id) { state.info.song.userRating = data.rating; @@ -188,39 +137,24 @@ export const useRemoteStore = createWithEqualityFn()( break; } case 'repeat': { - logFn.debug( - logMsg[LogCategory.REMOTE].repeatEventReceived, - { - category: LogCategory.REMOTE, - meta: { repeat: data }, - }, - ); + logger.debug('Repeat event received', { repeat: data }); set((state) => { state.info.repeat = data; }); break; } case 'shuffle': { - logFn.debug( - logMsg[LogCategory.REMOTE].shuffleEventReceived, - { - category: LogCategory.REMOTE, - meta: { shuffle: data }, - }, - ); + logger.debug('Shuffle event received', { shuffle: data }); set((state) => { state.info.shuffle = data; }); break; } case 'song': { - logFn.debug(logMsg[LogCategory.REMOTE].songEventReceived, { - category: LogCategory.REMOTE, - meta: { - artistName: data?.artistName, - id: data?.id, - name: data?.name, - }, + logger.debug('Song event received', { + artistName: data?.artistName, + id: data?.id, + name: data?.name, }); set((state) => { state.info.song = data; @@ -228,14 +162,11 @@ export const useRemoteStore = createWithEqualityFn()( break; } case 'state': { - logFn.debug(logMsg[LogCategory.REMOTE].stateEventReceived, { - category: LogCategory.REMOTE, - meta: { - hasSong: !!data.song, - position: data.position, - status: data.status, - volume: data.volume, - }, + logger.debug('State event received (full state update)', { + hasSong: !!data.song, + position: data.position, + status: data.status, + volume: data.volume, }); set((state) => { state.info = data; @@ -243,13 +174,7 @@ export const useRemoteStore = createWithEqualityFn()( break; } case 'volume': { - logFn.debug( - logMsg[LogCategory.REMOTE].volumeEventReceived, - { - category: LogCategory.REMOTE, - meta: { volume: data }, - }, - ); + logger.debug('Volume event received', { volume: data }); set((state) => { state.info.volume = data; }); @@ -258,17 +183,12 @@ export const useRemoteStore = createWithEqualityFn()( }); socket.addEventListener('open', () => { - logFn.debug(logMsg[LogCategory.REMOTE].webSocketOpened, { - category: LogCategory.REMOTE, - meta: { - hasAuthHeader: !!authHeader, - readyState: socket.readyState, - }, + logger.info('WebSocket opened', { + hasAuthHeader: !!authHeader, + readyState: socket.readyState, }); if (authHeader) { - logFn.debug(logMsg[LogCategory.REMOTE].sendingAuthentication, { - category: LogCategory.REMOTE, - }); + logger.debug('Sending authentication'); socket.send( JSON.stringify({ event: 'authenticate', @@ -280,40 +200,28 @@ export const useRemoteStore = createWithEqualityFn()( }); socket.addEventListener('close', (reason) => { - logFn.debug(logMsg[LogCategory.REMOTE].webSocketClosed, { - category: LogCategory.REMOTE, - meta: { - code: reason.code, - natural: socket.natural, - reason: reason.reason, - wasClean: reason.wasClean, - }, + logger.info('WebSocket closed', { + code: reason.code, + natural: socket.natural, + reason: reason.reason, + wasClean: reason.wasClean, }); if (reason.code === 4002 || reason.code === 4003) { - logFn.debug(logMsg[LogCategory.REMOTE].reloadingPage, { - category: LogCategory.REMOTE, - meta: { code: reason.code }, + logger.debug('Reloading page due to close code', { + code: reason.code, }); location.reload(); } else if (reason.code === 4000) { - logFn.warn(logMsg[LogCategory.REMOTE].serverIsDown, { - category: LogCategory.REMOTE, - }); + logger.warn('Server is down'); toast.warn({ message: 'Feishin remote server is down', title: 'Connection closed', }); } else if (reason.code !== 4001 && !socket.natural) { - logFn.error( - logMsg[LogCategory.REMOTE].socketClosedUnexpectedly, - { - category: LogCategory.REMOTE, - meta: { - code: reason.code, - reason: reason.reason, - }, - }, - ); + logger.error('Socket closed unexpectedly', { + code: reason.code, + reason: reason.reason, + }); toast.error({ message: 'Socket closed for unexpected reason', title: 'Connection closed', @@ -331,19 +239,15 @@ export const useRemoteStore = createWithEqualityFn()( send: (data: ClientEvent) => { const socket = get().socket; if (socket) { - logFn.debug(logMsg[LogCategory.REMOTE].sendingEventToServer, { - category: LogCategory.REMOTE, - meta: { - data: data, - event: data.event, - readyState: socket.readyState, - }, + logger.debug('Sending event to server', { + data: data, + event: data.event, + readyState: socket.readyState, }); socket.send(JSON.stringify(data)); } else { - logFn.warn(logMsg[LogCategory.REMOTE].cannotSendEvent, { - category: LogCategory.REMOTE, - meta: { event: data.event }, + logger.warn('Cannot send event - socket not available', { + event: data.event, }); } }, diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index a015ecce5..c0cc1dee5 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -4,6 +4,7 @@ import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-control import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import { mergeMusicFolderId } from '/@/renderer/api/utils-music-folder'; import { getServerById, useAuthStore, useSettingsStore } from '/@/renderer/store'; +import { logger } from '/@/renderer/utils/logger'; import { toast } from '/@/shared/components/toast/toast'; import { AuthenticationResponse, @@ -26,6 +27,95 @@ const endpoints: ApiController = { subsonic: SubsonicController, }; +const SENSITIVE_KEY_PATTERN = /password|token|credential|authorization|secret|cookie/i; +const MAX_ARRAY_ITEMS = 20; +const MAX_STRING_LENGTH = 200; + +const sanitizeValue = (value: unknown, depth = 0): unknown => { + if (value === null || value === undefined) { + return value; + } + + if (typeof value === 'string') { + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}…(${value.length})` + : value; + } + + if (typeof value !== 'object') { + return value; + } + + if (depth >= 4) { + return '[Truncated]'; + } + + if (Array.isArray(value)) { + const items = value.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitizeValue(item, depth + 1)); + if (value.length > MAX_ARRAY_ITEMS) { + items.push(`…(+${value.length - MAX_ARRAY_ITEMS} more)`); + } + return items; + } + + const result: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + if (SENSITIVE_KEY_PATTERN.test(key)) { + result[key] = '[Redacted]'; + continue; + } + result[key] = sanitizeValue(nested, depth + 1); + } + return result; +}; + +const OMITTED_CONTROLLER_ARG_KEYS = new Set([ + 'apiClientProps', + 'body', + 'context', + 'query', + 'signal', +]); + +const sanitizeControllerArgs = (endpoint: string, args: unknown[]): unknown => { + if (endpoint === 'authenticate') { + const [url] = args; + return { url: typeof url === 'string' ? url : undefined }; + } + + const first = args[0]; + if (!first || typeof first !== 'object') { + return args.length === 0 ? undefined : sanitizeValue(args); + } + + const input = first as Record; + const sanitized: Record = {}; + + if (input.query !== undefined) { + sanitized.query = sanitizeValue(input.query); + } + + if (input.body !== undefined) { + sanitized.body = sanitizeValue(input.body); + } + + for (const [key, value] of Object.entries(input)) { + if (OMITTED_CONTROLLER_ARG_KEYS.has(key)) { + continue; + } + sanitized[key] = sanitizeValue(value); + } + + return sanitized; +}; + +const OMITTED_CONTROLLER_LOG_ENDPOINTS = new Set([ + 'getDownloadUrl', + 'getImageRequest', + 'getImageUrl', + 'getStreamUrl', +]); + const apiController = ( endpoint: K, type?: ServerType, @@ -37,27 +127,71 @@ const apiController = ( message: i18n.t('error.serverNotSelectedError') as string, title: i18n.t('error.apiRouteError') as string, }); + + logger.error('No server selected', { + serverType, + }); throw new Error(`No server selected`); } const controllerFn = endpoints?.[serverType]?.[endpoint]; if (typeof controllerFn !== 'function') { - toast.error({ - message: `Endpoint ${endpoint} is not implemented for ${serverType}`, - title: i18n.t('error.apiRouteError') as string, + logger.error(`Endpoint ${endpoint} is not implemented for ${serverType}`, { + endpoint, + serverType, }); throw new Error( i18n.t('error.endpointNotImplementedError', { endpoint, - serverType, }) as string, ); } - return controllerFn; + if (OMITTED_CONTROLLER_LOG_ENDPOINTS.has(endpoint)) { + return controllerFn; + } + + return ((...args: unknown[]) => { + const started = performance.now(); + const serverId = (args[0] as undefined | { apiClientProps?: { serverId?: string } }) + ?.apiClientProps?.serverId; + + const logResult = (error?: unknown, value?: unknown) => { + logger.debug(`Controller ${String(endpoint)}${error != null ? ' failed' : ''}`, { + args: sanitizeControllerArgs(String(endpoint), args), + durationMs: Math.round(performance.now() - started), + serverId, + serverType, + ...(error != null + ? { error: error instanceof Error ? error.message : String(error) } + : { result: sanitizeValue(value) }), + }); + }; + + try { + const result = (controllerFn as (...a: unknown[]) => unknown)(...args); + if (result instanceof Promise) { + return result.then( + (value) => { + logResult(undefined, value); + return value; + }, + (error) => { + logResult(error); + throw error; + }, + ); + } + logResult(undefined, result); + return result; + } catch (error) { + logResult(error); + throw error; + } + }) as NonNullable; }; const getPathReplaceSettings = () => { diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index e4c6fb399..f5215fa0c 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -8,6 +8,7 @@ import qs from 'qs'; import i18n from '/@/i18n/i18n'; import { authenticationFailure } from '/@/renderer/api/utils'; import { useAuthStore } from '/@/renderer/store'; +import { logger } from '/@/renderer/utils/logger'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ndType } from '/@/shared/api/navidrome/navidrome-types'; import { resultWithHeaders } from '/@/shared/api/utils'; @@ -443,7 +444,7 @@ axiosClient.interceptors.response.use( console.error('Error when trying to reauthenticate: ', newError); if (isAxiosError(newError) && newError.code === 'ERR_NETWORK') { - console.log( + logger.warn( 'Network error during reauthentication - preserving credentials', ); } else { @@ -460,7 +461,7 @@ axiosClient.interceptors.response.use( } if (isAxiosError(error) && error.code === 'ERR_NETWORK') { - console.log('Network error during authentication - preserving credentials'); + logger.warn('Network error during authentication - preserving credentials'); } else { limitedFail(currentServer); } diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 34701a62f..572908bb6 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -14,7 +14,7 @@ import { getDirectPlayProfiles, } from '/@/renderer/features/player/components/audio-players'; import { randomString } from '/@/renderer/utils'; -import { logFn } from '/@/renderer/utils/logger'; +import { logger } from '/@/renderer/utils/logger'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { @@ -1426,13 +1426,13 @@ export const SubsonicController: InternalControllerEndpoint = { if (jukeboxStatus.status === 200 && !(jukeboxStatus.body as any)?.error) { features[ServerFeature.JUKEBOX] = [1]; } else { - console.log( + logger.warn( 'Jukebox endpoint returned an error payload:', (jukeboxStatus.body as any)?.error, ); } } catch (error) { - console.log('Jukebox is not supported by this server:', error); + logger.warn('Jukebox is not supported by this server:', error); } return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; @@ -1949,7 +1949,7 @@ export const SubsonicController: InternalControllerEndpoint = { // If the server returns an error for transcodeDecision, fall back to direct stream so that we don't break the player if (transcodeDecision.status !== 200) { - logFn.error( + logger.error( `Failed to get transcode decision for song ${id}, falling back to direct stream`, ); return streamUrl; @@ -1963,7 +1963,7 @@ export const SubsonicController: InternalControllerEndpoint = { return streamUrl; } - logFn.info(`Song ${id} requires transcoding: ${[td.transcodeReason].join(', ')}`); + logger.info(`Song ${id} requires transcoding: ${[td.transcodeReason].join(', ')}`); // If the server does not return transcode params, manually create the transcode params if (!td.transcodeParams) { diff --git a/src/renderer/features/analytics/hooks/use-app-tracker.ts b/src/renderer/features/analytics/hooks/use-app-tracker.ts index 218814995..24c8c719d 100644 --- a/src/renderer/features/analytics/hooks/use-app-tracker.ts +++ b/src/renderer/features/analytics/hooks/use-app-tracker.ts @@ -16,8 +16,7 @@ import { usePlayerStore, useSettingsStore, } from '/@/renderer/store'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { LyricSource, ServerType } from '/@/shared/types/domain-types'; import { FontType, Platform, PlayerStyle, PlayerType } from '/@/shared/types/types'; @@ -275,10 +274,7 @@ export const useAppTracker = () => { if (lastTrackedDate !== todayUTC) { appTrackerInFlight = true; const properties = getProperties(); - logFn.info(logMsg[LogCategory.ANALYTICS].appTracked, { - category: LogCategory.ANALYTICS, - meta: { properties, todayUTC }, - }); + logger.debug('Analytics sent', { properties, todayUTC }); trackAppViewMutation(undefined, { onError: () => {}, @@ -295,10 +291,7 @@ export const useAppTracker = () => { appTrackerLastSentDate = utcDate; localStorage.setItem('analytics_app_tracker_timestamp', utcDate); - logFn.debug(logMsg[LogCategory.ANALYTICS].appTracked, { - category: LogCategory.ANALYTICS, - meta: { properties }, - }); + logger.debug('Analytics sent', { properties }); }, }); } diff --git a/src/renderer/features/analytics/hooks/use-page-tracker.ts b/src/renderer/features/analytics/hooks/use-page-tracker.ts index d6d266412..5f5ff7bb8 100644 --- a/src/renderer/features/analytics/hooks/use-page-tracker.ts +++ b/src/renderer/features/analytics/hooks/use-page-tracker.ts @@ -4,9 +4,7 @@ import { useLocation } from 'react-router'; import { isAnalyticsDisabled } from '/@/renderer/features/analytics/hooks/use-analytics-disabled'; import { getRoutePattern } from '/@/renderer/features/analytics/utils/get-route-pattern'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; - +import { logger } from '/@/renderer/utils/logger'; const trackPageView = (routePattern: string) => { window.umami?.track((props) => ({ language: props.language, @@ -28,10 +26,7 @@ export const usePageTracker = () => { trackPageViewMutation(routePattern, { onSettled: () => { - logFn.debug(logMsg[LogCategory.ANALYTICS].pageViewTracked, { - category: LogCategory.ANALYTICS, - meta: { route: routePattern }, - }); + logger.debug('Page view tracked', { route: routePattern }); }, }); }, [routePattern, trackPageViewMutation]); diff --git a/src/renderer/features/discord-rpc/use-discord-rpc.ts b/src/renderer/features/discord-rpc/use-discord-rpc.ts index 16f8b88ab..733c370a8 100644 --- a/src/renderer/features/discord-rpc/use-discord-rpc.ts +++ b/src/renderer/features/discord-rpc/use-discord-rpc.ts @@ -22,8 +22,7 @@ import { useTimestampStoreBase, } from '/@/renderer/store'; import { sentenceCase } from '/@/renderer/utils'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { PlayerStatus } from '/@/shared/types/types'; @@ -112,13 +111,10 @@ export const useDiscordRpc = () => { reason = 'paused_with_show_paused_disabled'; } - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcActivityCleared, { - category: LogCategory.EXTERNAL, - meta: { - reason, - status: current[2], - trigger, - }, + logger.debug('Activity was cleared for Discord RPC', { + reason, + status: current[2], + trigger, }); return discordRpc?.clearActivity(); } @@ -151,24 +147,20 @@ export const useDiscordRpc = () => { const isConnected = await discordRpc?.isConnected(); if (!isConnected) { - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, { - category: LogCategory.EXTERNAL, - meta: { clientId: discordSettings.clientId }, + logger.info('Discord RPC was initialized', { + clientId: discordSettings.clientId, }); previousEnabledRef.current = true; await discordRpc?.initialize(discordSettings.clientId); } - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, { - category: LogCategory.EXTERNAL, - meta: { - currentStatus: current[2], - reason: 'radio', - showAsListening: discordSettings.showAsListening, - stationName: stationName || 'Radio', - title, - trigger, - }, + logger.debug('Activity was set for Discord RPC', { + currentStatus: current[2], + reason: 'radio', + showAsListening: discordSettings.showAsListening, + stationName: stationName || 'Radio', + title, + trigger, }); discordRpc?.setActivity(activity); return; @@ -179,13 +171,10 @@ export const useDiscordRpc = () => { } if (trackChanged) { - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, { - category: LogCategory.EXTERNAL, - meta: { - artistName: song.artists?.[0]?.name, - songId: song._uniqueId, - songName: song.name, - }, + logger.debug('Track was changed for Discord RPC', { + artistName: song.artists?.[0]?.name, + songId: song._uniqueId, + songName: song.name, }); setlastUniqueId(song._uniqueId); } @@ -319,11 +308,8 @@ export const useDiscordRpc = () => { // Initialize if needed const isConnected = await discordRpc?.isConnected(); if (!isConnected) { - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, { - category: LogCategory.EXTERNAL, - meta: { - clientId: discordSettings.clientId, - }, + logger.info('Discord RPC was initialized', { + clientId: discordSettings.clientId, }); previousEnabledRef.current = true; @@ -331,22 +317,19 @@ export const useDiscordRpc = () => { await discordRpc?.initialize(discordSettings.clientId); } - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, { - category: LogCategory.EXTERNAL, - meta: { - albumName: song.album, - artistName: song.artists?.[0]?.name, - currentStatus: current[2], - currentTime: current[1], - displayType: discordSettings.displayType, - hasLargeImage: !!activity.largeImageKey, - hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp), - reason, - showAsListening: discordSettings.showAsListening, - songName: song.name, - trackChanged, - trigger, - }, + logger.debug('Activity was set for Discord RPC', { + albumName: song.album, + artistName: song.artists?.[0]?.name, + currentStatus: current[2], + currentTime: current[1], + displayType: discordSettings.displayType, + hasLargeImage: !!activity.largeImageKey, + hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp), + reason, + showAsListening: discordSettings.showAsListening, + songName: song.name, + trackChanged, + trigger, }); discordRpc?.setActivity(activity); }, @@ -374,12 +357,9 @@ export const useDiscordRpc = () => { // Quit Discord RPC if it was enabled and is now disabled useEffect(() => { if ((!discordSettings.enabled || privateMode) && Boolean(previousEnabledRef.current)) { - logFn.info(logMsg[LogCategory.EXTERNAL].discordRpcQuit, { - category: LogCategory.EXTERNAL, - meta: { - enabled: discordSettings.enabled, - privateMode, - }, + logger.info('Discord RPC was quit', { + enabled: discordSettings.enabled, + privateMode, }); previousEnabledRef.current = false; diff --git a/src/renderer/features/player/audio-player/engine/web-player-engine.tsx b/src/renderer/features/player/audio-player/engine/web-player-engine.tsx index d8ffc919f..165c1a3f8 100644 --- a/src/renderer/features/player/audio-player/engine/web-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/web-player-engine.tsx @@ -5,8 +5,7 @@ import { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'r import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types'; import { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { PlayerStatus } from '/@/shared/types/types'; export interface WebPlayerEngineHandle extends AudioPlayer { @@ -174,6 +173,21 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => { player2Ref.current?.getInternalPlayer()?.pause(); }, []); + const mediaErrorLabel = (code: number | undefined) => { + switch (code) { + case MediaError.MEDIA_ERR_ABORTED: + return 'ABORTED'; + case MediaError.MEDIA_ERR_DECODE: + return 'DECODE'; + case MediaError.MEDIA_ERR_NETWORK: + return 'NETWORK'; + case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: + return 'SRC_NOT_SUPPORTED'; + default: + return 'unknown'; + } + }; + const handleOnError = ( playerRef: React.RefObject, onEnded: () => void, @@ -188,27 +202,30 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => { } const { error } = target; - - logFn.error(logMsg[LogCategory.PLAYER].playbackError, { - category: LogCategory.PLAYER, - meta: { error }, - }); + const code = error?.code; + const label = mediaErrorLabel(code); const isNetworkError = - error?.code === MediaError.MEDIA_ERR_NETWORK || - error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED; + code === MediaError.MEDIA_ERR_NETWORK || + code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED; if (isNetworkError) { if (networkRetryCountRef.current < MAX_NETWORK_RETRIES) { networkRetryCountRef.current += 1; + logger.warn('Playback error, retrying', { + code, + label, + retryCount: networkRetryCountRef.current, + }); const audio = target; setTimeout(() => { pauseBothPlayers(); audio.load(); audio.play().catch(() => { - logFn.error(logMsg[LogCategory.PLAYER].playbackError, { - category: LogCategory.PLAYER, - meta: { error: 'Failed to play audio after network error' }, + logger.error('Playback error, retries exhausted', { + code, + label, + retryCount: networkRetryCountRef.current, }); }); }, NETWORK_RETRY_DELAY_MS); @@ -216,14 +233,24 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => { } } - if (error?.code !== MediaError.MEDIA_ERR_DECODE && !isNetworkError) { + if (code !== MediaError.MEDIA_ERR_DECODE && !isNetworkError) { return; } pauseBothPlayers(); - if (error?.code === MediaError.MEDIA_ERR_DECODE) { + if (code === MediaError.MEDIA_ERR_DECODE) { + logger.error('Playback decode error, skipping track', { + code, + label, + retryCount: networkRetryCountRef.current, + }); onEnded(); } else { + logger.error('Playback error, pausing', { + code, + label, + retryCount: networkRetryCountRef.current, + }); if (onErrorPause) { onErrorPause(); } diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index 8ab9af5d2..b5fb3096d 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -39,7 +39,7 @@ import { usePlaybackType, useSettingsStoreActions, } from '/@/renderer/store'; -import { logFn } from '/@/renderer/utils/logger'; +import { logger } from '/@/renderer/utils/logger'; import { toast } from '/@/shared/components/toast/toast'; import { LibraryItem } from '/@/shared/types/domain-types'; import { PlayerType } from '/@/shared/types/types'; @@ -99,7 +99,7 @@ function detectBrowserProfile() { } } - logFn.info('DIRECT_PLAY_PROFILES', { meta: DIRECT_PLAY_PROFILES }); + logger.debug('DIRECT_PLAY_PROFILES', DIRECT_PLAY_PROFILES); return DIRECT_PLAY_PROFILES; } @@ -158,6 +158,8 @@ export const AudioPlayers = () => { ); }; +const mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null; + const AudioPlayersContent = ({ audioContext, audioDeviceId, @@ -179,6 +181,24 @@ const AudioPlayersContent = ({ }) => { const isRadioActive = useIsRadioActive(); + useEffect(() => { + logger.info('Playback engine', { playbackType }); + }, [playbackType]); + + useEffect(() => { + if (!mpvPlayerListener) { + return; + } + + mpvPlayerListener.rendererPlayerFallback((isFallback: boolean) => { + if (isFallback) { + logger.warn('Playback engine fell back to web'); + } else { + logger.info('Playback engine using local (mpv)'); + } + }); + }, []); + useEffect(() => { if (webAudio && 'AudioContext' in window) { let context: AudioContext; diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index 88b3dec81..010f35a69 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -18,8 +18,7 @@ import { import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { AddToQueueType, usePlayerActions, useSettingsStore } from '/@/renderer/store'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { shuffle as shuffleArray } from '/@/renderer/utils/shuffle'; import { sortSongsByFetchedOrder } from '/@/shared/api/utils'; import { Checkbox } from '/@/shared/components/checkbox/checkbox'; @@ -229,22 +228,20 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { if (typeof type === 'object' && 'edge' in type && type.edge !== null) { const edge = type.edge === 'top' ? 'top' : 'bottom'; - logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByData, { - category: LogCategory.PLAYER, - meta: { - data: data.length, - edge, - filtered: filteredData.length, - type, - uniqueId: type.uniqueId, - }, + logger.debug('Added to queue by data', { + data: data.length, + edge, + filtered: filteredData.length, + type, + uniqueId: type.uniqueId, }); storeActions.addToQueueByUniqueId(filteredData, type.uniqueId, edge, playSongId); } else { - logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByType, { - category: LogCategory.PLAYER, - meta: { data: data.length, filtered: filteredData.length, type }, + logger.debug('Added to queue by type', { + data: data.length, + filtered: filteredData.length, + type, }); storeActions.addToQueueByType(filteredData, type as Play, playSongId); @@ -281,10 +278,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { }; try { - logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByFetch, { - category: LogCategory.PLAYER, - meta: { ids: id, itemType, serverId, type }, - }); + logger.debug('Added to queue by fetch', { ids: id, itemType, serverId, type }); const songs = await queryClient.fetchQuery({ gcTime: 0, @@ -361,10 +355,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { let toastId: null | string = null; let fetchId: null | string = null; - logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByListQuery, { - category: LogCategory.PLAYER, - meta: { itemType, query, serverId, type }, - }); + logger.debug('Added to queue by list query', { itemType, query, serverId, type }); try { let totalCount = 0; @@ -440,10 +431,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { autoClose: false, message: t('player.playbackFetchCancel'), onClose: () => { - logFn.debug(logMsg[LogCategory.PLAYER].cancelledFetch, { - category: LogCategory.PLAYER, - meta: { itemType, serverId }, - }); + logger.debug('Cancelled fetch', { itemType, serverId }); queryClient.cancelQueries({ exact: false, @@ -538,19 +526,14 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { ); const clearQueue = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].clearQueue, { - category: LogCategory.PLAYER, - }); + logger.debug('Cleared queue'); storeActions.clearQueue(); }, [storeActions]); const clearSelected = useCallback( (items: QueueSong[]) => { - logFn.debug(logMsg[LogCategory.PLAYER].clearSelected, { - category: LogCategory.PLAYER, - meta: { items: items.length }, - }); + logger.debug('Cleared selected', { items: items.length }); storeActions.clearSelected(items); }, @@ -559,10 +542,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const decreaseVolume = useCallback( (amount: number) => { - logFn.debug(logMsg[LogCategory.PLAYER].decreaseVolume, { - category: LogCategory.PLAYER, - meta: { amount }, - }); + logger.debug('Decreased volume', { amount }); storeActions.decreaseVolume(amount); }, @@ -570,9 +550,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { ); const getQueue = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].clearQueue, { - category: LogCategory.PLAYER, - }); + logger.debug('Cleared queue'); const queue = storeActions.getQueue(); return queue.items; @@ -580,10 +558,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const increaseVolume = useCallback( (amount: number) => { - logFn.debug(logMsg[LogCategory.PLAYER].increaseVolume, { - category: LogCategory.PLAYER, - meta: { amount }, - }); + logger.debug('Increased volume', { amount }); storeActions.increaseVolume(amount); }, @@ -592,9 +567,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const mediaNext = useCallback( (toNextAlbum: boolean) => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaNext, { - category: LogCategory.PLAYER, - }); + logger.debug('Media next'); storeActions.mediaNext(toNextAlbum); }, @@ -602,19 +575,14 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { ); const mediaPause = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaPause, { - category: LogCategory.PLAYER, - }); + logger.debug('Media pause'); storeActions.mediaPause(); }, [storeActions]); const mediaPlay = useCallback( (id?: string) => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaPlay, { - category: LogCategory.PLAYER, - meta: { id }, - }); + logger.debug('Media play', { id }); storeActions.mediaPlay(id); }, @@ -623,10 +591,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const mediaPlayByIndex = useCallback( (index: number) => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaPlayByIndex, { - category: LogCategory.PLAYER, - meta: { index }, - }); + logger.debug('Media play by index', { index }); storeActions.mediaPlayByIndex(index); }, @@ -635,9 +600,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const mediaPrevious = useCallback( (toPreviousAlbum: boolean) => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaPrevious, { - category: LogCategory.PLAYER, - }); + logger.debug('Media previous'); storeActions.mediaPrevious(toPreviousAlbum); }, @@ -646,10 +609,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const mediaStop = useCallback( (options?: { reset?: boolean }) => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, { - category: LogCategory.PLAYER, - meta: { reset: options?.reset }, - }); + logger.debug('Media stop', { reset: options?.reset }); storeActions.mediaStop(options); }, @@ -658,10 +618,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const mediaSeekToTimestamp = useCallback( (timestamp: number) => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaSeekToTimestamp, { - category: LogCategory.PLAYER, - meta: { timestamp }, - }); + logger.debug('Media seek to timestamp', { timestamp }); storeActions.mediaSeekToTimestamp(timestamp); }, @@ -669,30 +626,23 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { ); const mediaSkipBackward = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaSkipBackward, { - category: LogCategory.PLAYER, - }); + logger.debug('Media skip backward'); storeActions.mediaSkipBackward(); }, [storeActions]); const mediaSkipForward = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaSkipForward, { - category: LogCategory.PLAYER, - }); + logger.debug('Media skip forward'); storeActions.mediaSkipForward(); }, [storeActions]); const setQueue = useCallback( (data: Song[], index?: number, position?: number) => { - logFn.debug(logMsg[LogCategory.PLAYER].setQueue, { - category: LogCategory.PLAYER, - meta: { - data: data.length, - index, - position, - }, + logger.debug('Set queue', { + data: data.length, + index, + position, }); storeActions.setQueue(data, index, position); @@ -702,10 +652,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const setSpeed = useCallback( (speed: number) => { - logFn.debug(logMsg[LogCategory.PLAYER].setSpeed, { - category: LogCategory.PLAYER, - meta: { speed }, - }); + logger.debug('Set speed', { speed }); storeActions.setSpeed(speed); }, @@ -713,27 +660,20 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { ); const mediaToggleMute = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaToggleMute, { - category: LogCategory.PLAYER, - }); + logger.debug('Media toggle mute'); storeActions.mediaToggleMute(); }, [storeActions]); const mediaTogglePlayPause = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].mediaTogglePlayPause, { - category: LogCategory.PLAYER, - }); + logger.debug('Media toggle play pause'); storeActions.mediaTogglePlayPause(); }, [storeActions]); const moveSelectedTo = useCallback( (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => { - logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedTo, { - category: LogCategory.PLAYER, - meta: { edge, items, uniqueId }, - }); + logger.debug('Moved selected to', { edge, items, uniqueId }); storeActions.moveSelectedTo(items, uniqueId, edge); }, @@ -742,10 +682,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const moveSelectedToBottom = useCallback( (items: QueueSong[]) => { - logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToBottom, { - category: LogCategory.PLAYER, - meta: { items }, - }); + logger.debug('Moved selected to bottom', { items }); storeActions.moveSelectedToBottom(items); }, @@ -754,10 +691,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const moveSelectedToNext = useCallback( (items: QueueSong[]) => { - logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToNext, { - category: LogCategory.PLAYER, - meta: { items }, - }); + logger.debug('Moved selected to next', { items }); storeActions.moveSelectedToNext(items); }, @@ -766,10 +700,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const moveSelectedToTop = useCallback( (items: QueueSong[]) => { - logFn.debug(logMsg[LogCategory.PLAYER].moveSelectedToTop, { - category: LogCategory.PLAYER, - meta: { items }, - }); + logger.debug('Moved selected to top', { items }); storeActions.moveSelectedToTop(items); }, @@ -778,10 +709,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const setVolume = useCallback( (volume: number) => { - logFn.debug(logMsg[LogCategory.PLAYER].setVolume, { - category: LogCategory.PLAYER, - meta: { volume }, - }); + logger.debug('Set volume', { volume }); storeActions.setVolume(volume); }, @@ -790,10 +718,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const setRepeat = useCallback( (repeat: PlayerRepeat) => { - logFn.debug(logMsg[LogCategory.PLAYER].setRepeat, { - category: LogCategory.PLAYER, - meta: { repeat }, - }); + logger.debug('Set repeat', { repeat }); storeActions.setRepeat(repeat); }, @@ -802,10 +727,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { const setShuffle = useCallback( (shuffle: PlayerShuffle) => { - logFn.debug(logMsg[LogCategory.PLAYER].setShuffle, { - category: LogCategory.PLAYER, - meta: { shuffle }, - }); + logger.debug('Set shuffle', { shuffle }); storeActions.setShuffle(shuffle); }, @@ -813,27 +735,20 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { ); const shuffle = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].shuffle, { - category: LogCategory.PLAYER, - }); + logger.debug('Shuffle'); storeActions.shuffle(); }, [storeActions]); const shuffleAll = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].shuffleAll, { - category: LogCategory.PLAYER, - }); + logger.debug('Shuffle all'); storeActions.shuffleAll(); }, [storeActions]); const shuffleSelected = useCallback( (items: QueueSong[]) => { - logFn.debug(logMsg[LogCategory.PLAYER].shuffleSelected, { - category: LogCategory.PLAYER, - meta: { items }, - }); + logger.debug('Shuffle selected', { items }); storeActions.shuffleSelected(items); }, @@ -841,17 +756,13 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { ); const toggleRepeat = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].toggleRepeat, { - category: LogCategory.PLAYER, - }); + logger.debug('Toggle repeat'); storeActions.toggleRepeat(); }, [storeActions]); const toggleShuffle = useCallback(() => { - logFn.debug(logMsg[LogCategory.PLAYER].toggleShuffle, { - category: LogCategory.PLAYER, - }); + logger.debug('Toggle shuffle'); storeActions.toggleShuffle(); }, [storeActions]); diff --git a/src/renderer/features/player/hooks/use-auto-dj.ts b/src/renderer/features/player/hooks/use-auto-dj.ts index ee2fd782c..6ccb4e361 100644 --- a/src/renderer/features/player/hooks/use-auto-dj.ts +++ b/src/renderer/features/player/hooks/use-auto-dj.ts @@ -16,8 +16,7 @@ import { usePlayerStoreBase, useSettingsStore, } from '/@/renderer/store'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { hasFeature } from '/@/shared/api/utils'; import { LibraryItem } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; @@ -65,9 +64,9 @@ export const useAutoDJ = () => { return; } - logFn.debug(logMsg[LogCategory.PLAYER].autoPlayTriggered, { - category: LogCategory.PLAYER, - meta: { remaining: properties.remaining, songId: properties.song?.id }, + logger.info('Auto play triggered', { + remaining: properties.remaining, + songId: properties.song?.id, }); try { @@ -143,9 +142,9 @@ export const useAutoDJ = () => { }); } } catch (error) { - logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, { - category: LogCategory.PLAYER, - meta: { error: (error as Error).message, songId: properties.song?.id }, + logger.error('Auto play failed', { + error: (error as Error).message, + songId: properties.song?.id, }); } }, diff --git a/src/renderer/features/player/hooks/use-scrobble.ts b/src/renderer/features/player/hooks/use-scrobble.ts index c5be600da..b8970298c 100644 --- a/src/renderer/features/player/hooks/use-scrobble.ts +++ b/src/renderer/features/player/hooks/use-scrobble.ts @@ -14,8 +14,7 @@ import { useSettingsStore, useTimestampStoreBase, } from '/@/renderer/store'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { hasFeature } from '/@/shared/api/utils'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; @@ -211,12 +210,9 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, { - category: LogCategory.SCROBBLE, - meta: { - id: song.id, - reason: 'after submission', - }, + logger.debug('Scrobbled a timeupdate event', { + id: song.id, + reason: 'after submission', }); }, }, @@ -305,8 +301,7 @@ export const useScrobble = () => { // }, // { // onSuccess: () => { - // logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, { - // category: LogCategory.SCROBBLE, + // logFn.debug("Scrobbled a timeupdate event", { // meta: { // id: currentSong.id, // }, @@ -342,12 +337,9 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, { - category: LogCategory.SCROBBLE, - meta: { - id: currentSong.id, - reason: 'from listened time', - }, + logger.info('Scrobbled a submission event', { + id: currentSong.id, + reason: 'from listened time', }); sendProgressAfterSubmission(currentSong); }, @@ -399,11 +391,8 @@ export const useScrobble = () => { silent: true, }); } catch (error) { - logFn.error('an error occurred while sending a desktop notification', { - category: LogCategory.SCROBBLE, - meta: { - error: error as Error, - }, + logger.error('an error occurred while sending a desktop notification', { + error: error as Error, }); } } @@ -447,11 +436,8 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, { - category: LogCategory.SCROBBLE, - meta: { - id: currentSong.id, - }, + logger.info('Scrobbled a start event', { + id: currentSong.id, }); }, }, @@ -480,11 +466,8 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, { - category: LogCategory.SCROBBLE, - meta: { - id: previousSong.id, - }, + logger.info('Scrobbled a stop event', { + id: previousSong.id, }); }, }, @@ -578,11 +561,8 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, { - category: LogCategory.SCROBBLE, - meta: { - id: currentSong.id, - }, + logger.debug('Scrobbled a timeupdate event', { + id: currentSong.id, }); }, }, @@ -633,11 +613,8 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledPause, { - category: LogCategory.SCROBBLE, - meta: { - id: currentSong.id, - }, + logger.debug('Scrobbled a pause event', { + id: currentSong.id, }); }, }, @@ -661,11 +638,8 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledUnpause, { - category: LogCategory.SCROBBLE, - meta: { - id: currentSong.id, - }, + logger.debug('Scrobbled an unpause event', { + id: currentSong.id, }); }, }, @@ -694,11 +668,8 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, { - category: LogCategory.SCROBBLE, - meta: { - id: currentSong.id, - }, + logger.info('Scrobbled a start event', { + id: currentSong.id, }); }, }, @@ -726,11 +697,8 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, { - category: LogCategory.SCROBBLE, - meta: { - id: currentSong.id, - }, + logger.info('Scrobbled a stop event', { + id: currentSong.id, }); }, }, @@ -777,12 +745,9 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, { - category: LogCategory.SCROBBLE, - meta: { - id: currentSong.id, - reason: 'from repeat', - }, + logger.info('Scrobbled a start event', { + id: currentSong.id, + reason: 'from repeat', }); }, }, @@ -840,12 +805,9 @@ export const useScrobble = () => { }, { onSuccess: () => { - logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, { - category: LogCategory.SCROBBLE, - meta: { - id: song.id, - reason: 'forced from UI', - }, + logger.info('Scrobbled a submission event', { + id: song.id, + reason: 'forced from UI', }); sendProgressAfterSubmission(song); }, diff --git a/src/renderer/features/player/hooks/use-update-current-song.ts b/src/renderer/features/player/hooks/use-update-current-song.ts index 72ff1801d..508965045 100644 --- a/src/renderer/features/player/hooks/use-update-current-song.ts +++ b/src/renderer/features/player/hooks/use-update-current-song.ts @@ -6,7 +6,7 @@ import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { updateQueueSong } from '/@/renderer/store/player.store'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; +import { logger } from '/@/renderer/utils/logger'; import { QueueSong, SongDetailQuery } from '/@/shared/types/domain-types'; export const useUpdateCurrentSong = () => { @@ -43,22 +43,16 @@ export const useUpdateCurrentSong = () => { if (!isEqual(currentSongData, updatedSong)) { updateQueueSong(currentSong.id, updatedSong); - logFn.debug('Song updated in queue', { - category: LogCategory.PLAYER, - meta: { - id: currentSong.id, - name: updatedSong.name, - }, + logger.debug('Song updated in queue', { + id: currentSong.id, + name: updatedSong.name, }); } } } catch (error) { - logFn.error('Failed to update song in queue', { - category: LogCategory.PLAYER, - meta: { - error: error instanceof Error ? error.message : String(error), - id: currentSong.id, - }, + logger.error('Failed to update song in queue', { + error: error instanceof Error ? error.message : String(error), + id: currentSong.id, }); } }, diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index cfbc3c978..9f9d13d5b 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -4,8 +4,7 @@ import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { folderQueries } from '/@/renderer/features/folders/api/folder-api'; import { PlayerFilter, useSettingsStore } from '/@/renderer/store'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { resolveSongPath } from '/@/renderer/utils/resolve-song-path'; import { sortSongList } from '/@/shared/api/utils'; import { @@ -435,23 +434,20 @@ export const filterSongsByPlayerFilters = (songs: Song[], filters: PlayerFilter[ }); if (filteredSongs.length > 0) { - logFn.debug(logMsg[LogCategory.PLAYER].playerFiltersApplied, { - category: LogCategory.PLAYER, - meta: { - filteredCount: filteredSongs.length, - filteredSongs: filteredSongs.map(({ filter, song }) => ({ - artist: song.artistName, - condition: { - field: filter.field, - operator: filter.operator, - value: filter.value, - }, - songId: song.id, - songName: song.name, - })), - originalCount: songs.length, - remainingCount: filtered.length, - }, + logger.debug('Player filters applied', { + filteredCount: filteredSongs.length, + filteredSongs: filteredSongs.map(({ filter, song }) => ({ + artist: song.artistName, + condition: { + field: filter.field, + operator: filter.operator, + value: filter.value, + }, + songId: song.id, + songName: song.name, + })), + originalCount: songs.length, + remainingCount: filtered.length, }); } diff --git a/src/renderer/features/radio/components/edit-radio-station-form.tsx b/src/renderer/features/radio/components/edit-radio-station-form.tsx index 9cf319c01..811b868ca 100644 --- a/src/renderer/features/radio/components/edit-radio-station-form.tsx +++ b/src/renderer/features/radio/components/edit-radio-station-form.tsx @@ -7,8 +7,7 @@ import { useDeleteInternetRadioStationImage } from '/@/renderer/features/radio/m import { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation'; import { useUploadInternetRadioStationImage } from '/@/renderer/features/radio/mutations/upload-internet-radio-station-image-mutation'; import { useCurrentServer } from '/@/renderer/store'; -import { logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { hasFeature } from '/@/shared/api/utils'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Box } from '/@/shared/components/box/box'; @@ -108,9 +107,7 @@ export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationForm }); closeAllModals(); } catch (err: unknown) { - logFn.error(logMsg.other.error, { - meta: { error: err as Error }, - }); + logger.error('An error occurred', { error: err as Error }); toast.error({ message: (err as Error)?.message, diff --git a/src/renderer/features/remote/hooks/use-remote.tsx b/src/renderer/features/remote/hooks/use-remote.tsx index af8b1e459..49630a271 100644 --- a/src/renderer/features/remote/hooks/use-remote.tsx +++ b/src/renderer/features/remote/hooks/use-remote.tsx @@ -7,8 +7,7 @@ import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { usePlayerActions, usePlayerStore, useRemoteSettings } from '/@/renderer/store'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { toast } from '/@/shared/components/toast/toast'; import { LibraryItem } from '/@/shared/types/domain-types'; import { PlayerShuffle } from '/@/shared/types/types'; @@ -32,13 +31,10 @@ export const useRemote = () => { // we must send this EVEN IF the remote is disabled, as this is what // makes sure that the main process gets the port/username/password on startup - logFn.debug(logMsg[LogCategory.REMOTE].initializingRemoteSettings, { - category: LogCategory.REMOTE, - meta: { - enabled: remoteSettings.enabled, - port: remoteSettings.port, - username: remoteSettings.username, - }, + logger.info('Initializing remote settings', { + enabled: remoteSettings.enabled, + port: remoteSettings.port, + username: remoteSettings.username, }); remote @@ -49,10 +45,7 @@ export const useRemote = () => { remoteSettings.password, ) .catch((error) => { - logFn.error(logMsg[LogCategory.REMOTE].failedToEnableRemote, { - category: LogCategory.REMOTE, - meta: { error }, - }); + logger.error('Failed to enable remote', { error }); toast.warn({ message: error, title: 'Failed to enable remote' }); }); // We only want to fire this once @@ -65,42 +58,35 @@ export const useRemote = () => { } remote.requestPosition((data: { position: number }) => { - logFn.debug(logMsg[LogCategory.REMOTE].requestPositionReceived, { - category: LogCategory.REMOTE, - meta: { position: data.position }, - }); + logger.debug('Request position received', { position: data.position }); const newTime = data.position; player.mediaSeekToTimestamp(newTime); }); remote.requestSeek((data: { offset: number }) => { - logFn.debug(logMsg[LogCategory.REMOTE].requestSeekReceived, { - category: LogCategory.REMOTE, - meta: { offset: data.offset }, - }); + logger.debug('Request seek received', { offset: data.offset }); mediaSkipForward(data.offset); }); remote.requestRating((data: { id: string; rating: number; serverId: string }) => { - logFn.debug(logMsg[LogCategory.REMOTE].requestRatingReceived, { - category: LogCategory.REMOTE, - meta: { id: data.id, rating: data.rating, serverId: data.serverId }, + logger.debug('Request rating received', { + id: data.id, + rating: data.rating, + serverId: data.serverId, }); setRating(data.serverId, [data.id], LibraryItem.SONG, data.rating); }); remote.requestVolume((data: { volume: number }) => { - logFn.debug(logMsg[LogCategory.REMOTE].requestVolumeReceived, { - category: LogCategory.REMOTE, - meta: { volume: data.volume }, - }); + logger.debug('Request volume received', { volume: data.volume }); setVolume(data.volume); }); remote.requestFavorite((data: { favorite: boolean; id: string; serverId: string }) => { - logFn.debug(logMsg[LogCategory.REMOTE].requestFavoriteReceived, { - category: LogCategory.REMOTE, - meta: { favorite: data.favorite, id: data.id, serverId: data.serverId }, + logger.debug('Request favorite received', { + favorite: data.favorite, + id: data.id, + serverId: data.serverId, }); const mutator = data.favorite ? addToFavoritesMutation : removeFromFavoritesMutation; mutator.mutate({ @@ -141,13 +127,10 @@ export const useRemote = () => { const currentSong = player.getCurrentSong(); if (currentSong) { - logFn.debug(logMsg[LogCategory.REMOTE].sendingInitialSong, { - category: LogCategory.REMOTE, - meta: { - artistName: currentSong.artistName, - id: currentSong.id, - name: currentSong.name, - }, + logger.debug('Sending initial song', { + artistName: currentSong.artistName, + id: currentSong.id, + name: currentSong.name, }); const imageUrl = @@ -171,14 +154,11 @@ export const useRemote = () => { return; } - logFn.debug(logMsg[LogCategory.REMOTE].updateSongSent, { - category: LogCategory.REMOTE, - meta: { - artistName: properties.song?.artistName, - id: properties.song?.id, - index: properties.index, - name: properties.song?.name, - }, + logger.debug('Update song sent', { + artistName: properties.song?.artistName, + id: properties.song?.id, + index: properties.index, + name: properties.song?.name, }); if (properties.song) { const song = properties.song; @@ -202,10 +182,7 @@ export const useRemote = () => { return; } - logFn.debug(logMsg[LogCategory.REMOTE].updatePositionSent, { - category: LogCategory.REMOTE, - meta: { timestamp: properties.timestamp }, - }); + logger.debug('Update position sent', { timestamp: properties.timestamp }); remote.updatePosition(properties.timestamp); }, onPlayerRepeat: (properties) => { @@ -213,10 +190,7 @@ export const useRemote = () => { return; } - logFn.debug(logMsg[LogCategory.REMOTE].updateRepeatSent, { - category: LogCategory.REMOTE, - meta: { repeat: properties.repeat }, - }); + logger.debug('Update repeat sent', { repeat: properties.repeat }); remote.updateRepeat(properties.repeat); }, onPlayerShuffle: (properties) => { @@ -225,9 +199,9 @@ export const useRemote = () => { } const isShuffleEnabled = properties.shuffle !== PlayerShuffle.NONE; - logFn.debug(logMsg[LogCategory.REMOTE].updateShuffleSent, { - category: LogCategory.REMOTE, - meta: { isShuffleEnabled, shuffle: properties.shuffle }, + logger.debug('Update shuffle sent', { + isShuffleEnabled, + shuffle: properties.shuffle, }); remote.updateShuffle(isShuffleEnabled); }, @@ -236,10 +210,7 @@ export const useRemote = () => { return; } - logFn.debug(logMsg[LogCategory.REMOTE].updatePlaybackSent, { - category: LogCategory.REMOTE, - meta: { status: properties.status }, - }); + logger.debug('Update playback sent', { status: properties.status }); remote.updatePlayback(properties.status); }, onPlayerVolume: (properties) => { @@ -247,10 +218,7 @@ export const useRemote = () => { return; } - logFn.debug(logMsg[LogCategory.REMOTE].updateVolumeSent, { - category: LogCategory.REMOTE, - meta: { volume: properties.volume }, - }); + logger.debug('Update volume sent', { volume: properties.volume }); remote.updateVolume(properties.volume); }, onUserFavorite: (properties) => { @@ -258,13 +226,10 @@ export const useRemote = () => { return; } - logFn.debug(logMsg[LogCategory.REMOTE].updateFavoriteSent, { - category: LogCategory.REMOTE, - meta: { - favorite: properties.favorite, - id: properties.id, - serverId: properties.serverId, - }, + logger.debug('Update favorite sent', { + favorite: properties.favorite, + id: properties.id, + serverId: properties.serverId, }); remote.updateFavorite(properties.favorite, properties.serverId, properties.id); }, @@ -273,13 +238,10 @@ export const useRemote = () => { return; } - logFn.debug(logMsg[LogCategory.REMOTE].updateRatingSent, { - category: LogCategory.REMOTE, - meta: { - id: properties.id, - rating: properties.rating || 0, - serverId: properties.serverId, - }, + logger.debug('Update rating sent', { + id: properties.id, + rating: properties.rating || 0, + serverId: properties.serverId, }); remote.updateRating(properties.rating || 0, properties.serverId, properties.id); }, diff --git a/src/renderer/features/settings/components/advanced/logger-settings.tsx b/src/renderer/features/settings/components/advanced/logger-settings.tsx index 374a715a4..0f9d45f1e 100644 --- a/src/renderer/features/settings/components/advanced/logger-settings.tsx +++ b/src/renderer/features/settings/components/advanced/logger-settings.tsx @@ -1,71 +1,160 @@ -import { memo } from 'react'; +import { ComboboxItem, ComboboxLikeRenderOptionInput } from '@mantine/core'; +import isElectron from 'is-electron'; +import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SettingOption, SettingsSection, } from '/@/renderer/features/settings/components/settings-section'; -import { logFn, LogLevel } from '/@/renderer/utils/logger'; +import { useCurrentServer, useSettingsStore } from '/@/renderer/store'; +import { logger, LogLevel, normalizeLogLevel } from '/@/renderer/utils/logger'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; +import { isLocalUrl } from '/@/shared/utils/is-local-url'; const DEFAULT_LOG_LEVEL: LogLevel = process.env.NODE_ENV === 'production' ? 'info' : 'debug'; +const utils = isElectron() ? window.api.utils : null; + +const LOG_LEVEL_DESCRIPTION_CONTEXT: Record = { + debug: 'optionDebugDescription', + info: 'optionInfoDescription', +}; + +const LogLevelSelectOption = ({ option }: ComboboxLikeRenderOptionInput) => { + const { t } = useTranslation(); + const level = normalizeLogLevel(option.value); + + return ( + + {option.label} + + {t('setting.logLevel', { + context: LOG_LEVEL_DESCRIPTION_CONTEXT[level], + })} + + + ); +}; + +const getRendererSettingsForExport = (): Record => { + const state = { ...useSettingsStore.getState() } as Record; + delete state.actions; + return state; +}; export const LoggerSettings = memo(() => { const { t } = useTranslation(); + const currentServer = useCurrentServer(); + const [isExporting, setIsExporting] = useState(false); const getCurrentLogLevel = (): LogLevel => { - const stored = localStorage.getItem('log_level'); - if (stored && ['debug', 'error', 'info', 'warn'].includes(stored)) { - return stored as LogLevel; - } - return DEFAULT_LOG_LEVEL; + return normalizeLogLevel(localStorage.getItem('log_level') ?? DEFAULT_LOG_LEVEL); }; const handleLogLevelChange = (value: null | string) => { if (!value) return; - const logLevel = value as LogLevel; + const logLevel = normalizeLogLevel(value); localStorage.setItem('log_level', logLevel); + logger.updateLogLevel(logLevel); + }; - // Update the logger dynamically - if (logFn.updateLogLevel) { - logFn.updateLogLevel(logLevel); + const handleOpenLogsFolder = async () => { + await utils?.openLogsFolder(); + }; + + const handleExportDiagnostics = async () => { + if (!utils || isExporting) { + return; + } + + setIsExporting(true); + try { + const result = await utils.exportDiagnostics({ + logLevel: localStorage.getItem('log_level'), + rendererSettings: getRendererSettingsForExport(), + server: currentServer + ? { + isAdmin: currentServer.isAdmin, + isLocalUrl: isLocalUrl(currentServer.url), + musicFolderId: currentServer.musicFolderId, + name: currentServer.name, + preferInstantMix: currentServer.preferInstantMix, + preferRemoteUrl: currentServer.preferRemoteUrl, + ...(currentServer.remoteUrl + ? { + isLocalRemoteUrl: isLocalUrl(currentServer.remoteUrl), + remoteUrl: '[Redacted]', + } + : {}), + type: currentServer.type, + url: '[Redacted]', + version: currentServer.version, + } + : null, + }); + + if (result.canceled) { + return; + } + } catch (error) { + toast.error({ + message: (error as Error).message, + }); + } finally { + setIsExporting(false); } }; const loggerOptions: SettingOption[] = [ { control: ( - + {utils && ( + + + + + )} + ), description: t('setting.logLevel', { context: 'description', diff --git a/src/renderer/features/settings/components/playback/audio-settings.tsx b/src/renderer/features/settings/components/playback/audio-settings.tsx index 3230a55a5..a9da6cc74 100644 --- a/src/renderer/features/settings/components/playback/audio-settings.tsx +++ b/src/renderer/features/settings/components/playback/audio-settings.tsx @@ -9,12 +9,14 @@ import { } from '/@/renderer/features/settings/components/settings-section'; import { useCurrentServer, usePlaybackType, usePlayerStatus } from '/@/renderer/store'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { logger } from '/@/renderer/utils/logger'; import { hasFeature } from '/@/shared/api/utils'; import { Select } from '/@/shared/components/select/select'; import { Switch } from '/@/shared/components/switch/switch'; import { toast } from '/@/shared/components/toast/toast'; import { ServerFeature } from '/@/shared/types/features-types'; import { PlayerStatus, PlayerType } from '/@/shared/types/types'; + const ipc = isElectron() ? window.api.ipc : null; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; @@ -25,14 +27,13 @@ const getAudioDevices = async () => { const getMpvAudioDevices = async () => { if (!mpvPlayer) { - console.log('mpvPlayer not found'); return []; } try { return await mpvPlayer.getAudioDevices(); } catch (error) { - console.error('Failed to get MPV audio devices:', error); + logger.error('Failed to get MPV audio devices:', error); return []; } }; diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index 69b9a2695..b3b749116 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -162,7 +162,6 @@ export const PlaylistRowButton = memo( }; }, onDrag: () => { - console.log('started drag'); return; }, onDragLeave: () => { diff --git a/src/renderer/hooks/use-server-authenticated.ts b/src/renderer/hooks/use-server-authenticated.ts index 3fd8169f6..3e29c2f3f 100644 --- a/src/renderer/hooks/use-server-authenticated.ts +++ b/src/renderer/hooks/use-server-authenticated.ts @@ -9,8 +9,7 @@ import { api } from '/@/renderer/api'; import { controller } from '/@/renderer/api/controller'; import { AppRoute } from '/@/renderer/router/routes'; import { getServerById, useAuthStoreActions, useCurrentServerId } from '/@/renderer/store'; -import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; +import { logger } from '/@/renderer/utils/logger'; import { toast } from '/@/shared/components/toast/toast'; import { AuthState } from '/@/shared/types/types'; @@ -67,14 +66,11 @@ export const useServerAuthenticated = () => { } // First, try getUserInfo to check if current credentials are still valid - logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, { - category: LogCategory.SYSTEM, - meta: { - method: 'getUserInfo', - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - serverType: serverWithAuth.type, - }, + logger.info('Authenticating server', { + method: 'getUserInfo', + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, }); try { @@ -122,27 +118,21 @@ export const useServerAuthenticated = () => { } } catch (serverInfoError) { // Log but don't fail authentication if server info fetch fails - logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { - category: LogCategory.SYSTEM, - meta: { - action: 'server_info_fetch_failed', - error: (serverInfoError as Error).message, - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - }, + logger.warn('Server info fetch failed after auth', { + action: 'server_info_fetch_failed', + error: (serverInfoError as Error).message, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, }); } - logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { - category: LogCategory.SYSTEM, - meta: { - isAdmin: userInfo.isAdmin, - method: 'getUserInfo', - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - serverType: serverWithAuth.type, - userId: userInfo.id, - }, + logger.info('Server authentication successful', { + isAdmin: userInfo.isAdmin, + method: 'getUserInfo', + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + userId: userInfo.id, }); const elapsedTime = Date.now() - authStartTime; @@ -167,16 +157,13 @@ export const useServerAuthenticated = () => { const password = await localSettings.passwordGet(serverWithAuth.id); if (password) { - logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, { - category: LogCategory.SYSTEM, - meta: { - method: 'authenticate', - reason: 'getUserInfo failed with forbidden error', - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - serverType: serverWithAuth.type, - url: serverWithAuth.url, - }, + logger.info('Authenticating server', { + method: 'authenticate', + reason: 'getUserInfo failed with forbidden error', + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + url: serverWithAuth.url, }); // Authenticate using the API controller @@ -232,28 +219,22 @@ export const useServerAuthenticated = () => { } } catch (serverInfoError) { // Log but don't fail authentication if server info fetch fails - logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { - category: LogCategory.SYSTEM, - meta: { - action: 'server_info_fetch_failed', - error: (serverInfoError as Error).message, - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - }, + logger.warn('Server info fetch failed after auth', { + action: 'server_info_fetch_failed', + error: (serverInfoError as Error).message, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, }); } - logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { - category: LogCategory.SYSTEM, - meta: { - isAdmin: authData.isAdmin, - method: 'authenticate', - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - serverType: serverWithAuth.type, - userId: authData.userId, - username: authData.username, - }, + logger.info('Server authentication successful', { + isAdmin: authData.isAdmin, + method: 'authenticate', + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + userId: authData.userId, + username: authData.username, }); // Ensure minimum delay before completing authentication @@ -280,18 +261,15 @@ export const useServerAuthenticated = () => { if (isNetwork && retryAttempt < MAX_NETWORK_RETRIES) { const nextRetry = retryAttempt + 1; - logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { - category: LogCategory.SYSTEM, - meta: { - action: 'network_error_retry', - attempt: nextRetry, - error: errorMessage, - maxRetries: MAX_NETWORK_RETRIES, - retryDelayMs: NETWORK_RETRY_DELAY_MS, - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - serverType: serverWithAuth.type, - }, + logger.warn('Server authentication failed', { + action: 'network_error_retry', + attempt: nextRetry, + error: errorMessage, + maxRetries: MAX_NETWORK_RETRIES, + retryDelayMs: NETWORK_RETRY_DELAY_MS, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, }); // Wait before retrying @@ -304,16 +282,13 @@ export const useServerAuthenticated = () => { // If network error and retries exhausted, redirect to no-network page if (isNetwork && retryAttempt >= MAX_NETWORK_RETRIES) { - logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { - category: LogCategory.SYSTEM, - meta: { - action: 'network_error_max_retries_exceeded', - attempts: retryAttempt + 1, - error: errorMessage, - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - serverType: serverWithAuth.type, - }, + logger.error('Server authentication failed', { + action: 'network_error_max_retries_exceeded', + attempts: retryAttempt + 1, + error: errorMessage, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, }); // Don't clear credentials on network failure - preserve them for when network returns @@ -323,14 +298,11 @@ export const useServerAuthenticated = () => { } // For non-network errors, handle normally - logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { - category: LogCategory.SYSTEM, - meta: { - error: errorMessage, - serverId: serverWithAuth.id, - serverName: serverWithAuth.name, - serverType: serverWithAuth.type, - }, + logger.error('Server authentication failed', { + error: errorMessage, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, }); // Clear server credentials and saved password on failure @@ -360,11 +332,8 @@ export const useServerAuthenticated = () => { useEffect(() => { if (!serverId) { - logFn.debug(logMsg[LogCategory.SYSTEM].serverAuthenticationInvalid, { - category: LogCategory.SYSTEM, - meta: { - reason: 'No server selected', - }, + logger.info('Server authentication invalid', { + reason: 'No server selected', }); setReady(AuthState.INVALID); return; @@ -376,12 +345,9 @@ export const useServerAuthenticated = () => { retryCountRef.current = 0; // Reset retry count when server changes if (!serverWithAuth) { - logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationError, { - category: LogCategory.SYSTEM, - meta: { - reason: 'Server not found in store', - serverId, - }, + logger.error('Server authentication error', { + reason: 'Server not found in store', + serverId, }); setReady(AuthState.INVALID); return; diff --git a/src/renderer/hooks/use-sync-settings-to-main.ts b/src/renderer/hooks/use-sync-settings-to-main.ts index 52e754528..3a50c2c5c 100644 --- a/src/renderer/hooks/use-sync-settings-to-main.ts +++ b/src/renderer/hooks/use-sync-settings-to-main.ts @@ -4,9 +4,7 @@ import { useEffect, useRef } from 'react'; import i18n from '/@/i18n/i18n'; import { openRestartRequiredToast } from '/@/renderer/features/settings/restart-toast'; import { useSettingsStore } from '/@/renderer/store/settings.store'; -import { logFn } from '/@/renderer/utils/logger'; -import { logMsg } from '/@/renderer/utils/logger-message'; - +import { logger } from '/@/renderer/utils/logger'; // Synchronizes settings from the renderer store to the main process electron store // on app initialization. If there are differences, it updates the main store and shows // a restart required toast. @@ -125,13 +123,14 @@ export const useSyncSettingsToMain = () => { JSON.stringify(rendererValueNormalized) ) { hasDifferences = true; - logFn.warn(logMsg.system.settingsSynchronized, { - meta: { + logger.warn( + 'Differences found between renderer and main process settings', + { mainStoreKey: mapping.mainStoreKey, mainValue: mainValueNormalized, rendererValue: rendererValueNormalized, }, - }); + ); localSettings.set(mapping.mainStoreKey, rendererValue); } } diff --git a/src/renderer/utils/logger-message.ts b/src/renderer/utils/logger-message.ts deleted file mode 100644 index 137c28cc5..000000000 --- a/src/renderer/utils/logger-message.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { LogCategory } from '/@/renderer/utils/logger'; - -export const logMsg = { - [LogCategory.ANALYTICS]: { - appTracked: 'Analytics sent', - pageViewTracked: 'Page view tracked', - }, - [LogCategory.API]: {}, - [LogCategory.EXTERNAL]: { - discordRpcActivityCleared: 'Activity was cleared for Discord RPC', - discordRpcInitialized: 'Discord RPC was initialized', - discordRpcQuit: 'Discord RPC was quit', - discordRpcSetActivity: 'Activity was set for Discord RPC', - discordRpcTrackChanged: 'Track was changed for Discord RPC', - discordRpcUpdateSkipped: 'Activity was not updated for Discord RPC', - }, - [LogCategory.OTHER]: { - error: 'An error occurred', - warning: 'A warning occurred', - }, - [LogCategory.PLAYER]: { - addToQueueByData: 'Added to queue by data', - addToQueueByFetch: 'Added to queue by fetch', - addToQueueByListQuery: 'Added to queue by list query', - addToQueueByType: 'Added to queue by type', - autoPlayFailed: 'Auto play failed', - autoPlayTriggered: 'Auto play triggered', - cancelledFetch: 'Cancelled fetch', - clearQueue: 'Cleared queue', - clearSelected: 'Cleared selected', - decreaseVolume: 'Decreased volume', - increaseVolume: 'Increased volume', - mediaNext: 'Media next', - mediaPause: 'Media pause', - mediaPlay: 'Media play', - mediaPlayByIndex: 'Media play by index', - mediaPrevious: 'Media previous', - mediaSeekToTimestamp: 'Media seek to timestamp', - mediaSkipBackward: 'Media skip backward', - mediaSkipForward: 'Media skip forward', - mediaStop: 'Media stop', - mediaToggleMute: 'Media toggle mute', - mediaTogglePlayPause: 'Media toggle play pause', - moveSelectedTo: 'Moved selected to', - moveSelectedToBottom: 'Moved selected to bottom', - moveSelectedToNext: 'Moved selected to next', - moveSelectedToTop: 'Moved selected to top', - playbackError: 'An error occurred during playback', - playerFiltersApplied: 'Player filters applied', - setFavorite: 'Set favorite', - setQueue: 'Set queue', - setRating: 'Set rating', - setRepeat: 'Set repeat', - setShuffle: 'Set shuffle', - setSpeed: 'Set speed', - setVolume: 'Set volume', - shuffle: 'Shuffle', - shuffleAll: 'Shuffle all', - shuffleSelected: 'Shuffle selected', - toggleRepeat: 'Toggle repeat', - toggleShuffle: 'Toggle shuffle', - }, - [LogCategory.REMOTE]: { - cannotSendEvent: 'Cannot send event - socket not available', - closingExistingSocket: 'Closing existing socket', - creatingWebSocket: 'Creating new WebSocket', - credentialsFetched: 'Credentials fetched', - failedToEnableRemote: 'Failed to enable remote', - failedToGetCredentials: 'Failed to get credentials', - favoriteEventReceived: 'Favorite event received', - fetchingCredentials: 'Fetching credentials', - initializingRemoteSettings: 'Initializing remote settings', - playbackEventReceived: 'Playback event received', - positionEventReceived: 'Position event received', - proxyEventReceived: 'Proxy event received (image update)', - ratingEventReceived: 'Rating event received', - reconnectInitiated: 'Reconnect initiated', - reloadingPage: 'Reloading page due to close code', - repeatEventReceived: 'Repeat event received', - requestFavoriteReceived: 'Request favorite received', - requestPositionReceived: 'Request position received', - requestRatingReceived: 'Request rating received', - requestSeekReceived: 'Request seek received', - requestVolumeReceived: 'Request volume received', - sendingAuthentication: 'Sending authentication', - sendingEventToServer: 'Sending event to server', - sendingInitialSong: 'Sending initial song', - serverIsDown: 'Server is down', - shuffleEventReceived: 'Shuffle event received', - socketClosedUnexpectedly: 'Socket closed unexpectedly', - songEventReceived: 'Song event received', - stateEventReceived: 'State event received (full state update)', - updateFavoriteSent: 'Update favorite sent', - updatePlaybackSent: 'Update playback sent', - updatePositionSent: 'Update position sent', - updateRatingSent: 'Update rating sent', - updateRepeatSent: 'Update repeat sent', - updateShuffleSent: 'Update shuffle sent', - updateSongSent: 'Update song sent', - updateVolumeSent: 'Update volume sent', - volumeEventReceived: 'Volume event received', - webSocketClosed: 'WebSocket closed', - webSocketErrorEvent: 'WebSocket error event', - webSocketMessageReceived: 'WebSocket message received', - webSocketOpened: 'WebSocket opened', - }, - [LogCategory.SCROBBLE]: { - scrobbledPause: 'Scrobbled a pause event', - scrobbledStart: 'Scrobbled a start event', - scrobbledStop: 'Scrobbled a stop event', - scrobbledSubmission: 'Scrobbled a submission event', - scrobbledTimeupdate: 'Scrobbled a timeupdate event', - scrobbledUnpause: 'Scrobbled an unpause event', - }, - [LogCategory.SYSTEM]: { - authenticatingServer: 'Authenticating server', - serverAuthenticationAborted: 'Server authentication aborted', - serverAuthenticationError: 'Server authentication error', - serverAuthenticationFailed: 'Server authentication failed', - serverAuthenticationInvalid: 'Server authentication invalid', - serverAuthenticationSuccess: 'Server authentication successful', - settingsSynchronized: 'Differences found between renderer and main process settings', - }, -}; diff --git a/src/renderer/utils/logger.ts b/src/renderer/utils/logger.ts index 84f4049fc..21224647a 100644 --- a/src/renderer/utils/logger.ts +++ b/src/renderer/utils/logger.ts @@ -1,27 +1,21 @@ -import dayjs from 'dayjs'; +import { LogLevel, LogSeverity } from '/@/shared/logger/types'; -export enum LogCategory { - ANALYTICS = 'analytics', - API = 'api', - EXTERNAL = 'external', - GENERAL = 'general', - OTHER = 'other', - PLAYER = 'player', - REMOTE = 'remote', - SCROBBLE = 'scrobble', - SYSTEM = 'system', -} +export type { LogLevel, LogSeverity }; -export type LogLevel = 'debug' | 'error' | 'info' | 'warn'; +type ElectronLogApi = { + debug: (...params: any[]) => void; + error: (...params: any[]) => void; + info: (...params: any[]) => void; + sendToMain?: (message: { + data: any[]; + level: LogSeverity; + variables?: { processType: string }; + }) => void; + warn: (...params: any[]) => void; +}; interface LogFn { - ( - message?: string, - options?: { - category?: string; - meta?: any; - }, - ): void; + (message?: string, meta?: any): void; } interface Logger { @@ -32,18 +26,89 @@ interface Logger { warn: LogFn; } -const DEFAULT_LOG_LEVEL = process.env.NODE_ENV === 'production' ? 'info' : 'debug'; +const DEFAULT_LOG_LEVEL: LogLevel = process.env.NODE_ENV === 'production' ? 'info' : 'debug'; +const PROCESS_LABEL = '[renderer]'; +const PROCESS_WIDTH = 10; +const LEVEL_WIDTH = 5; +const RESET = '\x1B[0m'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const NO_OP: LogFn = (_message?: string, ..._optionalParams: any[]) => {}; - -const colors = { +const levelColors: Record = { debug: '\x1B[38;2;100;149;237m', // #6495ED error: '\x1B[38;2;255;100;100m', // #ff6464 info: '\x1B[38;2;76;175;80m', // #4caf50 warn: '\x1B[38;2;225;125;50m', // #e17d32 }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const NO_OP: LogFn = (_message?: string, ..._optionalParams: any[]) => {}; + +const getElectronLog = (): ElectronLogApi | null => { + if (typeof window === 'undefined') { + return null; + } + + const electronLog = (window as Window & { __electronLog?: ElectronLogApi }).__electronLog; + return electronLog ?? null; +}; + +const formatLogLine = (level: LogSeverity, message: string, count = 1): string => { + const countStr = count > 1 ? ` (x${count})` : ''; + const levelLabel = `${levelColors[level]}${level.toUpperCase().padEnd(LEVEL_WIDTH, ' ')}${RESET}`; + const processLabel = PROCESS_LABEL.padEnd(PROCESS_WIDTH, ' '); + return `${new Date().toISOString()} ${levelLabel} ${processLabel} ${message}${countStr}`; +}; + +const forwardToElectronLog = (level: LogSeverity, message: string, meta?: any, count = 1) => { + const electronLog = getElectronLog(); + if (!electronLog) { + return; + } + + const countStr = count > 1 ? ` (x${count})` : ''; + const forwardMessage = `${message}${countStr}`; + const data = meta !== undefined ? [forwardMessage, meta] : [forwardMessage]; + + if (typeof electronLog.sendToMain === 'function') { + electronLog.sendToMain({ + data, + level, + variables: { processType: 'renderer' }, + }); + return; + } + + if (meta !== undefined) { + electronLog[level](forwardMessage, meta); + } else { + electronLog[level](forwardMessage); + } +}; + +const syncLogLevelToMain = (level: LogLevel) => { + if (typeof window === 'undefined' || !window.api?.ipc) { + return; + } + + window.api.ipc.send('logger-set-level', level); +}; + +export const normalizeLogLevel = (value: null | string | undefined): LogLevel => { + if (value === 'debug' || value === 'info') { + return value; + } + + // Legacy warn/error/trace thresholds map to nearby levels. + if (value === 'warn' || value === 'error') { + return 'info'; + } + + if (value === 'trace') { + return 'debug'; + } + + return DEFAULT_LOG_LEVEL; +}; + // Debounce configuration const DEBOUNCE_INTERVAL = 200; // milliseconds const DEBOUNCE_MAP = new Map(); @@ -53,22 +118,18 @@ setInterval(() => { const now = Date.now(); for (const [key, value] of DEBOUNCE_MAP.entries()) { if (now - value.lastLog >= DEBOUNCE_INTERVAL) { - const [level, message, category, meta] = JSON.parse(key); - const timestampStr = `${dayjs().format('HH:mm:ss')}`; - const levelStr = `${colors[level as keyof typeof colors]}[${String(level).toUpperCase().padEnd(5, ' ')}]\x1B[0m`; - const countStr = value.count > 1 ? ` (x${value.count})` : ''; - const categoryStr = category - ? String(`[${category.padEnd(9, ' ')}]`).toUpperCase() - : ''; + const [level, message, meta] = JSON.parse(key) as [LogSeverity, string, any]; const messageStr = message ? String(message) : ''; - const logStr = `[${timestampStr}] ${levelStr} ${categoryStr} ${messageStr}${countStr}`; + const logStr = formatLogLine(level, messageStr, value.count); - if (meta) { + if (meta !== undefined && meta !== null) { console.log(logStr, meta); } else { console.log(logStr); } + forwardToElectronLog(level, messageStr, meta, value.count); + DEBOUNCE_MAP.delete(key); } } @@ -82,19 +143,24 @@ class ConsoleLogger implements Logger { warn: LogFn = NO_OP; constructor() { - const level = (localStorage.getItem('log_level') || DEFAULT_LOG_LEVEL) as LogLevel; + const level = normalizeLogLevel(localStorage.getItem('log_level')); + if (localStorage.getItem('log_level') !== level) { + localStorage.setItem('log_level', level); + } + this.initializeLoggers(level); + syncLogLevelToMain(level); + this.updateLogLevel = (newLevel: LogLevel) => { this.initializeLoggers(newLevel); + syncLogLevelToMain(newLevel); }; } private initializeLoggers(level: LogLevel) { - // Create timestamp wrapper function with colors and debouncing - const withTimestamp = (logLevel: string): LogFn => { - return (message?: any, options?: { category?: string; meta?: any }) => { - const { category, meta } = options || {}; - const key = JSON.stringify([logLevel, message, category, meta]); + const withDebounce = (logLevel: LogSeverity): LogFn => { + return (message?: any, meta?: any) => { + const key = JSON.stringify([logLevel, message, meta]); const now = Date.now(); const existing = DEBOUNCE_MAP.get(key); @@ -107,32 +173,11 @@ class ConsoleLogger implements Logger { }; }; - this.error = withTimestamp('error'); - - if (level === 'error') { - this.warn = NO_OP; - this.info = NO_OP; - this.debug = NO_OP; - return; - } - - this.warn = withTimestamp('warn'); - - if (level === 'warn') { - this.info = NO_OP; - this.debug = NO_OP; - return; - } - - this.info = withTimestamp('info'); - - if (level === 'info') { - this.debug = NO_OP; - return; - } - - this.debug = withTimestamp('debug'); + this.error = withDebounce('error'); + this.warn = withDebounce('warn'); + this.info = withDebounce('info'); + this.debug = level === 'debug' ? withDebounce('debug') : NO_OP; } } -export const logFn = new ConsoleLogger(); +export const logger = new ConsoleLogger(); diff --git a/src/shared/logger/types.ts b/src/shared/logger/types.ts new file mode 100644 index 000000000..49c4dc0b5 --- /dev/null +++ b/src/shared/logger/types.ts @@ -0,0 +1,5 @@ +// Minimum verbosity configured in settings (and applied to main transports). +export type LogLevel = 'debug' | 'info'; + +// Severity of an individual log message. +export type LogSeverity = 'debug' | 'error' | 'info' | 'warn'; diff --git a/src/shared/utils/is-local-url.ts b/src/shared/utils/is-local-url.ts new file mode 100644 index 000000000..3e036b6ea --- /dev/null +++ b/src/shared/utils/is-local-url.ts @@ -0,0 +1,43 @@ +/** + * Returns true when the URL host is loopback or a private/LAN address. + */ +export const isLocalUrl = (value: string): boolean => { + try { + const { hostname } = new URL(value); + + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '[::1]' || + hostname === '0.0.0.0' || + hostname.endsWith('.local') + ) { + return true; + } + + // IPv4 private / link-local ranges + const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4) { + const [a, b] = [Number(ipv4[1]), Number(ipv4[2])]; + if (a === 10) return true; + if (a === 127) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + } + + // IPv6 unique local / link-local (fc00::/7, fe80::/10) + const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (normalized.startsWith('fc') || normalized.startsWith('fd')) { + return true; + } + if (/^fe[89ab]/.test(normalized)) { + return true; + } + + return false; + } catch { + return false; + } +}; diff --git a/src/shared/utils/sanitize-for-diagnostics.ts b/src/shared/utils/sanitize-for-diagnostics.ts new file mode 100644 index 000000000..26463c922 --- /dev/null +++ b/src/shared/utils/sanitize-for-diagnostics.ts @@ -0,0 +1,50 @@ +const SENSITIVE_KEY_PATTERN = /password|token|credential|authorization|secret|cookie/i; +const MAX_DEPTH = 20; + +/** + * Deep-clone a value for diagnostics exports, redacting sensitive keys. + * Does not truncate arrays/strings — intended for config dumps, not log lines. + */ +export const sanitizeForDiagnostics = (value: unknown, depth = 0): unknown => { + if (value === null || value === undefined) { + return value; + } + + if (typeof value !== 'object') { + return value; + } + + if (depth >= MAX_DEPTH) { + return '[Truncated]'; + } + + if (Array.isArray(value)) { + return value.map((item) => sanitizeForDiagnostics(item, depth + 1)); + } + + const result: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + if (SENSITIVE_KEY_PATTERN.test(key)) { + result[key] = '[Redacted]'; + continue; + } + + // Main-process electron-store password map: { [serverId]: encryptedHex } + if ( + key === 'server' && + nested && + typeof nested === 'object' && + !Array.isArray(nested) && + Object.values(nested as Record).every((v) => typeof v === 'string') + ) { + result[key] = Object.fromEntries( + Object.keys(nested as Record).map((id) => [id, '[Redacted]']), + ); + continue; + } + + result[key] = sanitizeForDiagnostics(nested, depth + 1); + } + + return result; +}; diff --git a/tsconfig.web.json b/tsconfig.web.json index 5dfa700cb..967a719c6 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -25,6 +25,9 @@ "/@/shared/*": [ "src/shared/*" ], + "/@/main/*": [ + "src/main/*" + ], "/@/i18n/*": [ "src/i18n/*" ],