mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
feat: unify logging via electron-log and add IPC error boundary
Bug reports from Steam Deck (Flatpak) arrived without any useful context: no terminal, no log file, no trace of what failed. Silent catches in helixClient hid quota and token errors entirely. IPC handler exceptions reached the renderer as rejected promises with no server-side record. electron-log now writes to a rotating log file (~1 MB, 5 files) that users can attach to bug reports. All IPC handlers are wrapped in safeHandle, which logs and re-throws at the boundary. Optional-feature failures (chapters, profile images, viewer counts) emit log.warn with enough context to diagnose. A React ErrorBoundary catches render crashes app-wide and logs them before showing a reload prompt.
This commit is contained in:
+6
-3
@@ -6,6 +6,11 @@ import { PlaybackService } from './playback/playbackService'
|
||||
import { registerIpcHandlers } from './ipc/handlers'
|
||||
import { startGamepadReader } from './input/gamepadReader'
|
||||
import { getHlsCacheEnabled } from './prefs/hlsCachePref'
|
||||
import log from 'electron-log/main'
|
||||
|
||||
log.initialize()
|
||||
log.transports.file.level = 'info'
|
||||
log.transports.console.level = 'debug'
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
@@ -130,9 +135,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
if (!clientId) {
|
||||
console.warn(
|
||||
'[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID nicht gesetzt — Login wird im UI als nicht konfiguriert angezeigt.'
|
||||
)
|
||||
log.warn('[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID nicht gesetzt — Login wird im UI als nicht konfiguriert angezeigt.')
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
@@ -33,6 +33,7 @@ import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { DEDUP_WINDOW_MS, HOTPLUG_SCAN_INTERVAL_MS, TRIGGER_THRESHOLD } from '../constants/input'
|
||||
import log from 'electron-log/main'
|
||||
|
||||
// evdev input_event auf 64-bit Linux:
|
||||
// { u64 tv_sec (8 Bytes), u64 tv_usec (8 Bytes), u16 type (2), u16 code (2), s32 value (4) } = 24 Bytes
|
||||
@@ -397,21 +398,21 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
|
||||
reader = new EvdevReader(eventPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(jsPath, reader)
|
||||
console.log(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`)
|
||||
log.info(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`)
|
||||
}
|
||||
} else {
|
||||
// Fallback to legacy joystick API when no /sys mapping is found
|
||||
reader = new JoystickFallbackReader(jsPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(jsPath, reader)
|
||||
console.log(`[gamepad] geöffnet (js-fallback): ${jsPath}`)
|
||||
log.info(`[gamepad] geöffnet (js-fallback): ${jsPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [jsPath, reader] of readers) {
|
||||
if (!seen.has(jsPath)) {
|
||||
console.log(`[gamepad] getrennt: ${jsPath}`)
|
||||
log.info(`[gamepad] getrennt: ${jsPath}`)
|
||||
reader.stop()
|
||||
readers.delete(jsPath)
|
||||
}
|
||||
|
||||
+42
-31
@@ -9,31 +9,42 @@ import type { HlsUrlPayload } from '../playback/playbackService'
|
||||
import type { PlaybackEvent } from '../playback/types'
|
||||
import { getProgressMap } from '../store/historyRepo'
|
||||
import { IPC } from './channels'
|
||||
import log from 'electron-log/main'
|
||||
|
||||
export { IPC }
|
||||
|
||||
function safeHandle<A extends unknown[], R>(
|
||||
channel: string,
|
||||
fn: (...args: A) => R | Promise<R>,
|
||||
): void {
|
||||
ipcMain.handle(channel, async (_e, ...args) => {
|
||||
try { return await fn(...(args as A)) }
|
||||
catch (err) { log.error(`[ipc:${channel}]`, err); throw err }
|
||||
})
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
auth: AuthService,
|
||||
helix: HelixClient,
|
||||
playback: PlaybackService
|
||||
): void {
|
||||
ipcMain.handle(IPC.appQuit, () => {
|
||||
safeHandle(IPC.appQuit, () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.appGetCacheSize, () => session.defaultSession.getCacheSize())
|
||||
ipcMain.handle(IPC.appClearCache, () => session.defaultSession.clearCache())
|
||||
ipcMain.handle(IPC.appSetHlsCacheEnabled, (_e, enabled: boolean) => {
|
||||
safeHandle(IPC.appGetCacheSize, () => session.defaultSession.getCacheSize())
|
||||
safeHandle(IPC.appClearCache, () => session.defaultSession.clearCache())
|
||||
safeHandle(IPC.appSetHlsCacheEnabled, (enabled: boolean) => {
|
||||
setHlsCacheEnabled(Boolean(enabled))
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||
ipcMain.handle(IPC.authCancel, () => {
|
||||
safeHandle(IPC.authStatus, () => auth.getStatus())
|
||||
safeHandle(IPC.authConfigured, () => auth.isConfigured())
|
||||
safeHandle(IPC.authStart, () => auth.startDeviceFlow())
|
||||
safeHandle(IPC.authCancel, () => {
|
||||
auth.cancelFlow()
|
||||
})
|
||||
ipcMain.handle(IPC.authLogout, () => auth.logout())
|
||||
safeHandle(IPC.authLogout, () => auth.logout())
|
||||
|
||||
auth.on('auth-event', (event: AuthEvent) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -41,15 +52,15 @@ export function registerIpcHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
|
||||
ipcMain.handle(IPC.twitchGetOwnUser, () => helix.getOwnUserInfo())
|
||||
ipcMain.handle(
|
||||
safeHandle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
|
||||
safeHandle(IPC.twitchGetOwnUser, () => helix.getOwnUserInfo())
|
||||
safeHandle(
|
||||
IPC.twitchGetTopGames,
|
||||
(_e, cursor?: string) => helix.getTopGames(40, cursor)
|
||||
(cursor?: string) => helix.getTopGames(40, cursor)
|
||||
)
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.twitchGetTopStreams,
|
||||
(_e, options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) =>
|
||||
(options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) =>
|
||||
helix.getTopStreams({
|
||||
gameId: options?.gameId,
|
||||
language: options?.language,
|
||||
@@ -57,40 +68,40 @@ export function registerIpcHandlers(
|
||||
limit: options?.limit ?? (options?.gameId ? 40 : 100)
|
||||
})
|
||||
)
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.twitchGetVideos,
|
||||
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
|
||||
(broadcasterId: string) => helix.getVideos(broadcasterId)
|
||||
)
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.twitchGetVodChapters,
|
||||
(_e, videoId: string) => helix.getVodChapters(videoId)
|
||||
(videoId: string) => helix.getVodChapters(videoId)
|
||||
)
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.historyGetProgress,
|
||||
(_e, vodIds: string[]) => getProgressMap(vodIds)
|
||||
(vodIds: string[]) => getProgressMap(vodIds)
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.playbackStartLive,
|
||||
(_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality)
|
||||
(channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality)
|
||||
)
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.playbackStartVod,
|
||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) =>
|
||||
(vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) =>
|
||||
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds, quality)
|
||||
)
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.playbackGetQualities,
|
||||
(_e, twitchUrl: string) => playback.getAvailableQualities(twitchUrl)
|
||||
(twitchUrl: string) => playback.getAvailableQualities(twitchUrl)
|
||||
)
|
||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||
ipcMain.handle(IPC.playbackPause, () => {
|
||||
safeHandle(IPC.playbackStop, () => playback.stop())
|
||||
safeHandle(IPC.playbackPause, () => {
|
||||
// Bring Electron window back into focus (Windows: Gamepad API requires window focus)
|
||||
for (const win of BrowserWindow.getAllWindows()) win.focus()
|
||||
})
|
||||
ipcMain.handle(
|
||||
safeHandle(
|
||||
IPC.playbackReportPosition,
|
||||
(_e, vodId: string, positionSeconds: number, durationSeconds: number) => {
|
||||
(vodId: string, positionSeconds: number, durationSeconds: number) => {
|
||||
playback.updateVodPosition(vodId, positionSeconds, durationSeconds)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
|
||||
if (code === 0 && hlsUrl) {
|
||||
resolve(hlsUrl)
|
||||
} else {
|
||||
reject(new Error(`streamlink --stream-url exit ${code}, stdout: ${out.trim().slice(0, 200)}`))
|
||||
reject(new Error(`streamlink --stream-url ${url} ${quality} exit ${code}, stdout: ${out.trim().slice(0, 200)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
VodChapter,
|
||||
VodInfo
|
||||
} from './types'
|
||||
import log from 'electron-log/main'
|
||||
|
||||
function parseDuration(d: string): number {
|
||||
let s = 0
|
||||
@@ -103,7 +104,7 @@ export class HelixClient {
|
||||
for (const id of chunk) url.searchParams.append('user_id', id)
|
||||
url.searchParams.set('first', '100')
|
||||
const res = await fetch(url.toString(), { headers: await this.authHeaders() })
|
||||
if (!res.ok) continue
|
||||
if (!res.ok) { log.warn('[helixClient] getLiveStreams batch failed, partial result:', res.status); continue }
|
||||
const data = (await res.json()) as { data: HelixStream[] }
|
||||
for (const stream of data.data) map.set(stream.user_id, stream)
|
||||
}
|
||||
@@ -117,7 +118,7 @@ export class HelixClient {
|
||||
const url = new URL(`${BASE}/users`)
|
||||
for (const id of chunk) url.searchParams.append('id', id)
|
||||
const res = await fetch(url.toString(), { headers: await this.authHeaders() })
|
||||
if (!res.ok) continue
|
||||
if (!res.ok) { log.warn('[helixClient] getProfileImages batch failed, partial result:', res.status); continue }
|
||||
const data = (await res.json()) as { data: HelixUser[] }
|
||||
for (const user of data.data) map.set(user.id, user.profile_image_url)
|
||||
}
|
||||
@@ -165,10 +166,11 @@ export class HelixClient {
|
||||
url.searchParams.set('game_id', g.id)
|
||||
url.searchParams.set('first', '100')
|
||||
const res = await fetch(url.toString(), { headers })
|
||||
if (!res.ok) return undefined
|
||||
if (!res.ok) { log.warn('[helixClient] viewer-count fetch failed for game', g.id, res.status); return undefined }
|
||||
const d = (await res.json()) as { data: HelixStream[] }
|
||||
return d.data.reduce((sum, s) => sum + s.viewer_count, 0)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
log.warn('[helixClient] viewer-count fetch failed for game', g.id, err)
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
@@ -252,7 +254,7 @@ export class HelixClient {
|
||||
query: HelixClient.CHAPTERS_QUERY
|
||||
}])
|
||||
})
|
||||
if (!res.ok) return []
|
||||
if (!res.ok) { log.warn('[helixClient] getVodChapters non-ok status:', res.status); return [] }
|
||||
const json = await res.json() as Array<{
|
||||
data?: { video?: { moments?: { edges: Array<{ node: {
|
||||
positionMilliseconds: number
|
||||
@@ -270,7 +272,8 @@ export class HelixClient {
|
||||
gameName: node.details?.game?.name ?? node.description,
|
||||
gameId: node.details?.game?.id ?? null
|
||||
}))
|
||||
} catch {
|
||||
} catch (err) {
|
||||
log.warn('[helixClient] getVodChapters failed, returning [] (chapters optional):', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user