feat: add file logging toggle and logout confirmation

File logging defaulted to always-on meant the log grew silently on every
run with no way to turn it off. Changing the default to error-only and
exposing the toggle in Settings lets users opt in only when debugging.

The logout button previously triggered immediately without warning, making
accidental logouts unrecoverable without re-running the device code flow.
A confirmation step (Ja/Nein, gamepad-navigable) prevents that.
This commit is contained in:
Andre
2026-05-15 10:15:30 +02:00
parent a5eaafbed3
commit c7bc1a23c0
9 changed files with 134 additions and 17 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import { getHlsCacheEnabled } from './prefs/hlsCachePref'
import log from 'electron-log/main' import log from 'electron-log/main'
log.initialize() log.initialize()
log.transports.file.level = 'info' log.transports.file.level = 'error'
log.transports.console.level = 'debug' log.transports.console.level = 'debug'
const isDev = !app.isPackaged const isDev = !app.isPackaged
+1
View File
@@ -30,6 +30,7 @@ export const IPC = {
appGetCacheSize: 'app:get-cache-size', appGetCacheSize: 'app:get-cache-size',
appClearCache: 'app:clear-cache', appClearCache: 'app:clear-cache',
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled', appSetHlsCacheEnabled: 'app:set-hls-cache-enabled',
appSetFileLoggingEnabled: 'app:set-file-logging-enabled',
/** main → renderer */ /** main → renderer */
gamepadInput: 'gamepad-input' gamepadInput: 'gamepad-input'
+3
View File
@@ -37,6 +37,9 @@ export function registerIpcHandlers(
safeHandle(IPC.appSetHlsCacheEnabled, (enabled: boolean) => { safeHandle(IPC.appSetHlsCacheEnabled, (enabled: boolean) => {
setHlsCacheEnabled(Boolean(enabled)) setHlsCacheEnabled(Boolean(enabled))
}) })
safeHandle(IPC.appSetFileLoggingEnabled, (enabled: boolean) => {
log.transports.file.level = Boolean(enabled) ? 'info' : 'error'
})
safeHandle(IPC.authStatus, () => auth.getStatus()) safeHandle(IPC.authStatus, () => auth.getStatus())
safeHandle(IPC.authConfigured, () => auth.isConfigured()) safeHandle(IPC.authConfigured, () => auth.isConfigured())
+2 -1
View File
@@ -37,7 +37,8 @@ const api = {
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit), quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit),
getCacheSize: (): Promise<number> => ipcRenderer.invoke(IPC.appGetCacheSize), getCacheSize: (): Promise<number> => ipcRenderer.invoke(IPC.appGetCacheSize),
clearCache: (): Promise<void> => ipcRenderer.invoke(IPC.appClearCache), clearCache: (): Promise<void> => ipcRenderer.invoke(IPC.appClearCache),
setHlsCacheEnabled: (enabled: boolean): Promise<void> => ipcRenderer.invoke(IPC.appSetHlsCacheEnabled, enabled) setHlsCacheEnabled: (enabled: boolean): Promise<void> => ipcRenderer.invoke(IPC.appSetHlsCacheEnabled, enabled),
setFileLoggingEnabled: (enabled: boolean): Promise<void> => ipcRenderer.invoke(IPC.appSetFileLoggingEnabled, enabled)
}, },
auth: { auth: {
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured), isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
+17 -3
View File
@@ -7,6 +7,7 @@ export interface AppSettings {
sidebarWidth: number sidebarWidth: number
badgeGap: number badgeGap: number
hlsCacheEnabled: boolean hlsCacheEnabled: boolean
fileLoggingEnabled: boolean
} }
const STORAGE_KEY = 't4sd:settings' const STORAGE_KEY = 't4sd:settings'
@@ -20,7 +21,8 @@ const DEFAULTS: AppSettings = {
streamBadgeMode: 'both', streamBadgeMode: 'both',
sidebarWidth: SIDEBAR_DEFAULT, sidebarWidth: SIDEBAR_DEFAULT,
badgeGap: BADGE_GAP_DEFAULT, badgeGap: BADGE_GAP_DEFAULT,
hlsCacheEnabled: false hlsCacheEnabled: false,
fileLoggingEnabled: false
} }
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT } export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
@@ -39,7 +41,8 @@ function loadSettings(): AppSettings {
: DEFAULTS.streamBadgeMode, : DEFAULTS.streamBadgeMode,
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT, sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT, badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled,
fileLoggingEnabled: typeof parsed.fileLoggingEnabled === 'boolean' ? parsed.fileLoggingEnabled : DEFAULTS.fileLoggingEnabled
} }
} catch { } catch {
return DEFAULTS return DEFAULTS
@@ -60,6 +63,7 @@ interface SettingsCtx {
setSidebarWidth: (width: number) => void setSidebarWidth: (width: number) => void
setBadgeGap: (gap: number) => void setBadgeGap: (gap: number) => void
setHlsCacheEnabled: (enabled: boolean) => void setHlsCacheEnabled: (enabled: boolean) => void
setFileLoggingEnabled: (enabled: boolean) => void
} }
const SettingsContext = createContext<SettingsCtx | null>(null) const SettingsContext = createContext<SettingsCtx | null>(null)
@@ -71,6 +75,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
document.documentElement.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`) document.documentElement.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`)
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`) document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
void window.t4sd.app.setHlsCacheEnabled(settings.hlsCacheEnabled) void window.t4sd.app.setHlsCacheEnabled(settings.hlsCacheEnabled)
void window.t4sd.app.setFileLoggingEnabled(settings.fileLoggingEnabled)
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => { const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
@@ -110,8 +115,17 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
void window.t4sd.app.setHlsCacheEnabled(enabled) void window.t4sd.app.setHlsCacheEnabled(enabled)
}, []) }, [])
const setFileLoggingEnabled = useCallback((enabled: boolean) => {
setSettings((prev) => {
const next = { ...prev, fileLoggingEnabled: enabled }
saveSettings(next)
return next
})
void window.t4sd.app.setFileLoggingEnabled(enabled)
}, [])
return ( return (
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled }}> <SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled }}>
{children} {children}
</SettingsContext.Provider> </SettingsContext.Provider>
) )
+54 -8
View File
@@ -13,10 +13,12 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
const [user, setUser] = useState<OwnUserInfo | null>(null) const [user, setUser] = useState<OwnUserInfo | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [confirmVisible, setConfirmVisible] = useState(false)
const [confirmIndex, setConfirmIndex] = useState(0) // 0 = Nein, 1 = Ja
useEffect(() => { useEffect(() => {
if (hasFocus) logoutBtnRef.current?.focus({ preventScroll: true }) if (hasFocus && !confirmVisible) logoutBtnRef.current?.focus({ preventScroll: true })
}, [hasFocus]) }, [hasFocus, confirmVisible])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -47,16 +49,40 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
useEffect(() => { useEffect(() => {
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === 'ArrowLeft') { if (confirmVisible) {
if (e.key === 'ArrowLeft') {
e.preventDefault()
setConfirmIndex(0)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
setConfirmIndex(1)
} else if (e.key === 'Escape') {
e.preventDefault()
setConfirmVisible(false)
setConfirmIndex(0)
} else if (e.key === 'Enter') {
e.preventDefault()
if (confirmIndex === 1) {
void handleLogout()
} else {
setConfirmVisible(false)
setConfirmIndex(0)
}
}
return
}
if (e.key === 'ArrowLeft' || e.key === 'Escape') {
e.preventDefault() e.preventDefault()
onRequestSidebar() onRequestSidebar()
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
void handleLogout() e.preventDefault()
setConfirmVisible(true)
setConfirmIndex(0)
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, onRequestSidebar]) }, [hasFocus, confirmVisible, confirmIndex, onRequestSidebar])
async function handleLogout(): Promise<void> { async function handleLogout(): Promise<void> {
await window.t4sd.auth.logout() await window.t4sd.auth.logout()
@@ -92,9 +118,29 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
)} )}
</div> </div>
<p className="account__version">App-Version {window.t4sd.appVersion}</p> <p className="account__version">App-Version {window.t4sd.appVersion}</p>
<button ref={logoutBtnRef} className="btn" onClick={handleLogout}> {confirmVisible ? (
Abmelden <div className="account__confirm">
</button> <p className="account__confirm-text">Wirklich abmelden?</p>
<div className="account__confirm-buttons">
<button
className={['btn', confirmIndex === 0 ? 'btn--focused' : ''].filter(Boolean).join(' ')}
onClick={() => { setConfirmVisible(false); setConfirmIndex(0) }}
>
Nein
</button>
<button
className={['btn', confirmIndex === 1 ? 'btn--focused' : ''].filter(Boolean).join(' ')}
onClick={() => { void handleLogout() }}
>
Ja
</button>
</div>
</div>
) : (
<button ref={logoutBtnRef} className="btn" onClick={() => { setConfirmVisible(true); setConfirmIndex(0) }}>
Abmelden
</button>
)}
</div> </div>
</div> </div>
) )
+32 -4
View File
@@ -42,15 +42,16 @@ function formatCacheSize(bytes: number): string {
return `${(mb / 1024).toFixed(2)} GB` return `${(mb / 1024).toFixed(2)} GB`
} }
/** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + cache action */ /** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + file logging toggle + cache action */
const SIDEBAR_SLIDER_ROW = OPTIONS.length const SIDEBAR_SLIDER_ROW = OPTIONS.length
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1 const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2 const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2
const CACHE_ACTION_ROW = OPTIONS.length + 3 const CACHE_ACTION_ROW = OPTIONS.length + 3
const TOTAL_ROWS = OPTIONS.length + 4 const FILE_LOGGING_TOGGLE_ROW = OPTIONS.length + 4
const TOTAL_ROWS = OPTIONS.length + 5
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled } = useSettings() const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled } = useSettings()
const [focusedIndex, setFocusedIndex] = useState(() => const [focusedIndex, setFocusedIndex] = useState(() =>
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode)) Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
) )
@@ -128,6 +129,8 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
setBadgeGap(BADGE_GAP_DEFAULT) setBadgeGap(BADGE_GAP_DEFAULT)
} else if (focusedIndex === HLS_CACHE_TOGGLE_ROW) { } else if (focusedIndex === HLS_CACHE_TOGGLE_ROW) {
setHlsCacheEnabled(!settings.hlsCacheEnabled) setHlsCacheEnabled(!settings.hlsCacheEnabled)
} else if (focusedIndex === FILE_LOGGING_TOGGLE_ROW) {
setFileLoggingEnabled(!settings.fileLoggingEnabled)
} else if (focusedIndex === CACHE_ACTION_ROW) { } else if (focusedIndex === CACHE_ACTION_ROW) {
void handleClearCache() void handleClearCache()
} }
@@ -137,7 +140,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, handleClearCache]) }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, settings.fileLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, handleClearCache])
const rowRefs = useRef<Array<HTMLElement | null>>([]) const rowRefs = useRef<Array<HTMLElement | null>>([])
@@ -150,6 +153,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
const hlsCacheToggleFocused = hasFocus && focusedIndex === HLS_CACHE_TOGGLE_ROW const hlsCacheToggleFocused = hasFocus && focusedIndex === HLS_CACHE_TOGGLE_ROW
const fileLoggingToggleFocused = hasFocus && focusedIndex === FILE_LOGGING_TOGGLE_ROW
const cacheActionFocused = hasFocus && focusedIndex === CACHE_ACTION_ROW const cacheActionFocused = hasFocus && focusedIndex === CACHE_ACTION_ROW
return ( return (
@@ -325,6 +329,30 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
{clearing ? 'Wird geleert …' : 'Cache leeren'} {clearing ? 'Wird geleert …' : 'Cache leeren'}
</span> </span>
</button> </button>
<button
ref={(el) => { rowRefs.current[FILE_LOGGING_TOGGLE_ROW] = el }}
className={[
'settings-option',
settings.fileLoggingEnabled ? 'settings-option--active' : '',
fileLoggingToggleFocused ? 'settings-option--focused' : ''
].filter(Boolean).join(' ')}
onClick={() => {
setFocusedIndex(FILE_LOGGING_TOGGLE_ROW)
setFileLoggingEnabled(!settings.fileLoggingEnabled)
}}
tabIndex={fileLoggingToggleFocused ? 0 : -1}
>
<span className="settings-option__radio" aria-hidden="true">
{settings.fileLoggingEnabled ? '●' : '○'}
</span>
<span className="settings-option__label">Datei-Logging</span>
<span className="settings-option__preview">
{settings.fileLoggingEnabled
? 'Aktiv — Info-Logs in main.log'
: 'Inaktiv — nur Fehler werden geloggt (empfohlen)'}
</span>
</button>
</div> </div>
{!hasFocus && ( {!hasFocus && (
+23
View File
@@ -64,6 +64,12 @@ body,
background: var(--color-accent); background: var(--color-accent);
} }
.btn--focused {
outline: 3px solid var(--color-focus);
outline-offset: 4px;
background: rgba(255, 255, 255, 0.14);
}
.btn--primary:hover { .btn--primary:hover {
background: #a970ff; background: #a970ff;
} }
@@ -1439,6 +1445,23 @@ body,
text-align: center; text-align: center;
} }
.account__confirm {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.account__confirm-text {
margin: 0;
font-size: 20px;
}
.account__confirm-buttons {
display: flex;
gap: 16px;
}
/* ── Chapter Overlay (Modal) ──────────────────────────── */ /* ── Chapter Overlay (Modal) ──────────────────────────── */
.chapter-overlay { .chapter-overlay {
position: fixed; position: fixed;
+1
View File
@@ -40,6 +40,7 @@ export interface T4sdApi {
getCacheSize: () => Promise<number> getCacheSize: () => Promise<number>
clearCache: () => Promise<void> clearCache: () => Promise<void>
setHlsCacheEnabled: (enabled: boolean) => Promise<void> setHlsCacheEnabled: (enabled: boolean) => Promise<void>
setFileLoggingEnabled: (enabled: boolean) => Promise<void>
} }
auth: { auth: {
isConfigured: () => Promise<boolean> isConfigured: () => Promise<boolean>