mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Projektstart
- Phase 1 "Twitch Login" abgeschlossen
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
// Orchestriert den kompletten Twitch-Login-Lifecycle und hält den aktuellen Token-Status.
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
pollForToken,
|
||||
refreshAccessToken,
|
||||
requestDeviceCode,
|
||||
type DeviceCodeResponse,
|
||||
type TokenResponse
|
||||
} from './deviceCodeFlow'
|
||||
import { clearTokens, loadTokens, saveTokens, type StoredTokens } from './tokenStore'
|
||||
|
||||
const SCOPES = ['user:read:follows'] as const
|
||||
const REFRESH_SAFETY_MS = 60_000
|
||||
|
||||
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||
|
||||
export interface DeviceFlowStartInfo {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresInSec: number
|
||||
}
|
||||
|
||||
export type AuthEvent =
|
||||
| { kind: 'authorized' }
|
||||
| { kind: 'denied' }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export class AuthService extends EventEmitter {
|
||||
private tokens: StoredTokens | null = null
|
||||
private currentFlow: AbortController | null = null
|
||||
|
||||
constructor(private readonly clientId: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.tokens = await loadTokens()
|
||||
}
|
||||
|
||||
getStatus(): AuthStatus {
|
||||
return this.tokens ? 'logged-in' : 'logged-out'
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.clientId.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Device Code Flow. Liefert die Anzeigedaten sofort zurück
|
||||
* und pollt im Hintergrund. Statuswechsel werden als 'auth-event' emittiert.
|
||||
*/
|
||||
async startDeviceFlow(): Promise<DeviceFlowStartInfo> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error(
|
||||
'TWITCH_CLIENT_ID nicht gesetzt. Trage MAIN_VITE_TWITCH_CLIENT_ID in .env ein.'
|
||||
)
|
||||
}
|
||||
this.cancelFlow()
|
||||
|
||||
const device: DeviceCodeResponse = await requestDeviceCode(this.clientId, SCOPES)
|
||||
const ctrl = new AbortController()
|
||||
this.currentFlow = ctrl
|
||||
|
||||
void this.runPolling(device, ctrl.signal)
|
||||
|
||||
return {
|
||||
userCode: device.user_code,
|
||||
verificationUri: device.verification_uri,
|
||||
expiresInSec: device.expires_in
|
||||
}
|
||||
}
|
||||
|
||||
cancelFlow(): void {
|
||||
this.currentFlow?.abort()
|
||||
this.currentFlow = null
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
this.cancelFlow()
|
||||
this.tokens = null
|
||||
await clearTokens()
|
||||
this.emit('auth-event', { kind: 'cancelled' } satisfies AuthEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert ein gültiges Access-Token, refresht falls nötig.
|
||||
* Wird vom Helix-Client benutzt.
|
||||
*/
|
||||
async getValidAccessToken(): Promise<string | null> {
|
||||
if (!this.tokens) return null
|
||||
if (this.tokens.expiresAt - REFRESH_SAFETY_MS > Date.now()) {
|
||||
return this.tokens.accessToken
|
||||
}
|
||||
try {
|
||||
const refreshed = await refreshAccessToken(this.clientId, this.tokens.refreshToken)
|
||||
this.tokens = tokenResponseToStored(refreshed)
|
||||
await saveTokens(this.tokens)
|
||||
return this.tokens.accessToken
|
||||
} catch {
|
||||
// Refresh fehlgeschlagen → User muss sich neu einloggen.
|
||||
this.tokens = null
|
||||
await clearTokens()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async runPolling(device: DeviceCodeResponse, signal: AbortSignal): Promise<void> {
|
||||
const outcome = await pollForToken({
|
||||
clientId: this.clientId,
|
||||
scopes: SCOPES,
|
||||
deviceCode: device.device_code,
|
||||
intervalSec: device.interval,
|
||||
expiresInSec: device.expires_in,
|
||||
signal
|
||||
})
|
||||
|
||||
if (outcome.kind === 'authorized') {
|
||||
this.tokens = tokenResponseToStored(outcome.token)
|
||||
await saveTokens(this.tokens)
|
||||
this.emit('auth-event', { kind: 'authorized' } satisfies AuthEvent)
|
||||
} else {
|
||||
this.emit('auth-event', outcome satisfies AuthEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tokenResponseToStored(t: TokenResponse): StoredTokens {
|
||||
return {
|
||||
accessToken: t.access_token,
|
||||
refreshToken: t.refresh_token,
|
||||
expiresAt: Date.now() + t.expires_in * 1000,
|
||||
scopes: Array.isArray(t.scope) ? t.scope : []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Twitch OAuth 2.0 Device Code Flow.
|
||||
// Doku: https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow
|
||||
|
||||
const DEVICE_ENDPOINT = 'https://id.twitch.tv/oauth2/device'
|
||||
const TOKEN_ENDPOINT = 'https://id.twitch.tv/oauth2/token'
|
||||
|
||||
export interface DeviceCodeResponse {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
scope: string[]
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export async function requestDeviceCode(
|
||||
clientId: string,
|
||||
scopes: readonly string[]
|
||||
): Promise<DeviceCodeResponse> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
scopes: scopes.join(' ')
|
||||
})
|
||||
const res = await fetch(DEVICE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Device-Code-Request fehlgeschlagen (${res.status}): ${await res.text()}`)
|
||||
}
|
||||
return (await res.json()) as DeviceCodeResponse
|
||||
}
|
||||
|
||||
export type PollOutcome =
|
||||
| { kind: 'authorized'; token: TokenResponse }
|
||||
| { kind: 'denied' }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
interface PollOptions {
|
||||
clientId: string
|
||||
scopes: readonly string[]
|
||||
deviceCode: string
|
||||
intervalSec: number
|
||||
expiresInSec: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export async function pollForToken(opts: PollOptions): Promise<PollOutcome> {
|
||||
const deadline = Date.now() + opts.expiresInSec * 1000
|
||||
let intervalMs = Math.max(1, opts.intervalSec) * 1000
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (opts.signal?.aborted) return { kind: 'cancelled' }
|
||||
await sleep(intervalMs, opts.signal)
|
||||
if (opts.signal?.aborted) return { kind: 'cancelled' }
|
||||
|
||||
const body = new URLSearchParams({
|
||||
client_id: opts.clientId,
|
||||
scopes: opts.scopes.join(' '),
|
||||
device_code: opts.deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
})
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
})
|
||||
} catch (err) {
|
||||
return { kind: 'error', message: (err as Error).message }
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const token = (await res.json()) as TokenResponse
|
||||
return { kind: 'authorized', token }
|
||||
}
|
||||
|
||||
// Twitch liefert bei Pending einen 400 mit message-Feld.
|
||||
let payload: { message?: string; status?: number } = {}
|
||||
try {
|
||||
payload = await res.json()
|
||||
} catch {
|
||||
/* nicht-JSON Antwort, ignorieren */
|
||||
}
|
||||
const msg = (payload.message ?? '').toLowerCase()
|
||||
|
||||
if (msg.includes('authorization_pending') || msg.includes('pending')) {
|
||||
continue
|
||||
}
|
||||
if (msg.includes('slow_down') || msg.includes('slow down')) {
|
||||
intervalMs += 5000
|
||||
continue
|
||||
}
|
||||
if (msg.includes('expired')) return { kind: 'expired' }
|
||||
if (msg.includes('denied') || msg.includes('access_denied')) return { kind: 'denied' }
|
||||
|
||||
return { kind: 'error', message: payload.message ?? `HTTP ${res.status}` }
|
||||
}
|
||||
return { kind: 'expired' }
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
clientId: string,
|
||||
refreshToken: string
|
||||
): Promise<TokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
const res = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Token-Refresh fehlgeschlagen (${res.status}): ${await res.text()}`)
|
||||
}
|
||||
return (await res.json()) as TokenResponse
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Verschlüsselte Persistenz für Twitch-Tokens.
|
||||
// Nutzt Electron safeStorage (OS-Keystore unter der Haube) — keine native Module nötig.
|
||||
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface StoredTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
/** Unix ms */
|
||||
expiresAt: number
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
const FILE_NAME = 'twitch-tokens.bin'
|
||||
|
||||
function filePath(): string {
|
||||
return join(app.getPath('userData'), FILE_NAME)
|
||||
}
|
||||
|
||||
export async function saveTokens(tokens: StoredTokens): Promise<void> {
|
||||
const json = JSON.stringify(tokens)
|
||||
const path = filePath()
|
||||
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const encrypted = safeStorage.encryptString(json)
|
||||
await fs.writeFile(path, encrypted)
|
||||
} else {
|
||||
// Fallback: unverschlüsselt (z. B. headless Linux ohne Keyring).
|
||||
// Datei nur für aktuellen User lesbar machen.
|
||||
await fs.writeFile(path, json, { mode: 0o600 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadTokens(): Promise<StoredTokens | null> {
|
||||
const path = filePath()
|
||||
let raw: Buffer
|
||||
try {
|
||||
raw = await fs.readFile(path)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw err
|
||||
}
|
||||
|
||||
let json: string
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
json = safeStorage.decryptString(raw)
|
||||
} catch {
|
||||
// Datei evtl. mit anderem Key geschrieben — als ungültig behandeln.
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
json = raw.toString('utf8')
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(json) as StoredTokens
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearTokens(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(filePath())
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { AuthService } from './auth/authService'
|
||||
import { registerIpcHandlers } from './ipc/handlers'
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 1024,
|
||||
minHeight: 640,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#0e0e10',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
win.once('ready-to-show', () => win.show())
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (isDev && process.env.ELECTRON_RENDERER_URL) {
|
||||
win.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
|
||||
const auth = new AuthService(clientId)
|
||||
await auth.init()
|
||||
registerIpcHandlers(auth)
|
||||
|
||||
if (!clientId) {
|
||||
console.warn(
|
||||
'[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID nicht gesetzt — Login wird im UI als nicht konfiguriert angezeigt.'
|
||||
)
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import type { AuthEvent, AuthService } from '../auth/authService'
|
||||
|
||||
export const IPC = {
|
||||
authStatus: 'auth:get-status',
|
||||
authStart: 'auth:start-device-flow',
|
||||
authCancel: 'auth:cancel',
|
||||
authLogout: 'auth:logout',
|
||||
authConfigured: 'auth:is-configured',
|
||||
/** main → renderer */
|
||||
authEvent: 'auth:event'
|
||||
} as const
|
||||
|
||||
export function registerIpcHandlers(auth: AuthService): void {
|
||||
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||
ipcMain.handle(IPC.authCancel, () => {
|
||||
auth.cancelFlow()
|
||||
})
|
||||
ipcMain.handle(IPC.authLogout, () => auth.logout())
|
||||
|
||||
auth.on('auth-event', (event: AuthEvent) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(IPC.authEvent, event)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
|
||||
const IPC = {
|
||||
authStatus: 'auth:get-status',
|
||||
authStart: 'auth:start-device-flow',
|
||||
authCancel: 'auth:cancel',
|
||||
authLogout: 'auth:logout',
|
||||
authConfigured: 'auth:is-configured',
|
||||
authEvent: 'auth:event'
|
||||
} as const
|
||||
|
||||
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||
|
||||
export interface DeviceFlowStartInfo {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresInSec: number
|
||||
}
|
||||
|
||||
export type AuthEvent =
|
||||
| { kind: 'authorized' }
|
||||
| { kind: 'denied' }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
const api = {
|
||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||
auth: {
|
||||
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
|
||||
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
|
||||
startDeviceFlow: (): Promise<DeviceFlowStartInfo> => ipcRenderer.invoke(IPC.authStart),
|
||||
cancel: (): Promise<void> => ipcRenderer.invoke(IPC.authCancel),
|
||||
logout: (): Promise<void> => ipcRenderer.invoke(IPC.authLogout),
|
||||
onEvent: (cb: (event: AuthEvent) => void): (() => void) => {
|
||||
const listener = (_e: IpcRendererEvent, event: AuthEvent): void => cb(event)
|
||||
ipcRenderer.on(IPC.authEvent, listener)
|
||||
return () => ipcRenderer.removeListener(IPC.authEvent, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('t4sd', api)
|
||||
|
||||
export type T4sdApi = typeof api
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1280, initial-scale=1" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:;" />
|
||||
<title>Twitch4SteamDeck</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import LoginScreen from './screens/LoginScreen'
|
||||
import type { AuthStatus } from './types/t4sd'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const [status, setStatus] = useState<AuthStatus | 'loading'>('loading')
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const s = await window.t4sd.auth.getStatus()
|
||||
setStatus(s)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
const off = window.t4sd.auth.onEvent((event) => {
|
||||
if (event.kind === 'authorized' || event.kind === 'cancelled') void refresh()
|
||||
})
|
||||
return off
|
||||
}, [refresh])
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<header className="shell__header">
|
||||
<h1>Twitch4SteamDeck</h1>
|
||||
<span className="shell__version">v{window.t4sd.appVersion}</span>
|
||||
</header>
|
||||
<section className="shell__body">
|
||||
{status === 'loading' && <p>Lade…</p>}
|
||||
{status === 'logged-out' && <LoginScreen onAuthorized={refresh} />}
|
||||
{status === 'logged-in' && <LoggedInPlaceholder onLogout={refresh} />}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function LoggedInPlaceholder({ onLogout }: { onLogout: () => void }): JSX.Element {
|
||||
async function handleLogout(): Promise<void> {
|
||||
await window.t4sd.auth.logout()
|
||||
onLogout()
|
||||
}
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Eingeloggt</h2>
|
||||
<p>Followed-Liste, VODs und Player folgen in den nächsten Schritten.</p>
|
||||
<button className="btn" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import type { DeviceFlowStartInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
onAuthorized: () => void
|
||||
}
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'starting' }
|
||||
| { kind: 'awaiting'; info: DeviceFlowStartInfo; qrDataUrl: string; remainingSec: number }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'not-configured' }
|
||||
|
||||
export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
|
||||
const startBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Initial: prüfen ob Client-ID konfiguriert ist.
|
||||
useEffect(() => {
|
||||
void window.t4sd.auth.isConfigured().then((ok) => {
|
||||
if (!ok) setPhase({ kind: 'not-configured' })
|
||||
})
|
||||
startBtnRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Auth-Events aus Main abonnieren.
|
||||
useEffect(() => {
|
||||
const off = window.t4sd.auth.onEvent((event) => {
|
||||
switch (event.kind) {
|
||||
case 'authorized':
|
||||
onAuthorized()
|
||||
break
|
||||
case 'denied':
|
||||
setPhase({ kind: 'error', message: 'Zugriff abgelehnt.' })
|
||||
break
|
||||
case 'expired':
|
||||
setPhase({ kind: 'error', message: 'Code abgelaufen — bitte erneut versuchen.' })
|
||||
break
|
||||
case 'error':
|
||||
setPhase({ kind: 'error', message: event.message })
|
||||
break
|
||||
case 'cancelled':
|
||||
setPhase({ kind: 'idle' })
|
||||
break
|
||||
}
|
||||
})
|
||||
return off
|
||||
}, [onAuthorized])
|
||||
|
||||
// Countdown für die aktive Phase.
|
||||
useEffect(() => {
|
||||
if (phase.kind !== 'awaiting') return
|
||||
if (phase.remainingSec <= 0) return
|
||||
const t = setTimeout(() => {
|
||||
setPhase((p) =>
|
||||
p.kind === 'awaiting' ? { ...p, remainingSec: p.remainingSec - 1 } : p
|
||||
)
|
||||
}, 1000)
|
||||
return () => clearTimeout(t)
|
||||
}, [phase])
|
||||
|
||||
async function start(): Promise<void> {
|
||||
setPhase({ kind: 'starting' })
|
||||
try {
|
||||
const info = await window.t4sd.auth.startDeviceFlow()
|
||||
const qrDataUrl = await QRCode.toDataURL(info.verificationUri, {
|
||||
margin: 1,
|
||||
width: 280,
|
||||
color: { dark: '#0e0e10', light: '#ffffff' }
|
||||
})
|
||||
setPhase({ kind: 'awaiting', info, qrDataUrl, remainingSec: info.expiresInSec })
|
||||
} catch (err) {
|
||||
setPhase({ kind: 'error', message: (err as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(): Promise<void> {
|
||||
await window.t4sd.auth.cancel()
|
||||
setPhase({ kind: 'idle' })
|
||||
}
|
||||
|
||||
if (phase.kind === 'not-configured') {
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Konfiguration fehlt</h2>
|
||||
<p>
|
||||
Es ist keine Twitch <code>Client ID</code> hinterlegt. Lege im Projektverzeichnis eine
|
||||
Datei <code>.env</code> an (siehe <code>.env.example</code>) und setze
|
||||
<br />
|
||||
<code>MAIN_VITE_TWITCH_CLIENT_ID=…</code>
|
||||
<br />
|
||||
Danach <code>npm run dev</code> neu starten.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.kind === 'awaiting') {
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Mit Twitch verbinden</h2>
|
||||
<p>
|
||||
Öffne auf einem anderen Gerät{' '}
|
||||
<strong>{phase.info.verificationUri}</strong> und gib den Code ein:
|
||||
</p>
|
||||
<div className="login__code">{phase.info.userCode}</div>
|
||||
<img className="login__qr" src={phase.qrDataUrl} alt="QR Code" />
|
||||
<p className="login__hint">Code läuft in {formatTime(phase.remainingSec)} ab.</p>
|
||||
<button className="btn" onClick={cancel}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Willkommen</h2>
|
||||
<p>Verbinde deinen Twitch-Account, um gefolgte Kanäle und VODs zu sehen.</p>
|
||||
{phase.kind === 'error' && <p className="login__error">{phase.message}</p>}
|
||||
<button
|
||||
ref={startBtnRef}
|
||||
className="btn btn--primary"
|
||||
onClick={start}
|
||||
disabled={phase.kind === 'starting'}
|
||||
>
|
||||
{phase.kind === 'starting' ? 'Starte…' : 'Mit Twitch verbinden'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* Big-Screen-Baseline: 1280x800 (Steam Deck), große Schrift, dunkle Oberfläche. */
|
||||
:root {
|
||||
--color-bg: #0e0e10;
|
||||
--color-bg-elev: #18181b;
|
||||
--color-fg: #efeff1;
|
||||
--color-fg-dim: #adadb8;
|
||||
--color-accent: #9147ff;
|
||||
--color-focus: #bf94ff;
|
||||
--radius: 12px;
|
||||
--font-size-body: 22px;
|
||||
--font-size-h1: 44px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: var(--font-size-body);
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--color-focus);
|
||||
outline-offset: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 32px 48px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.shell__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.shell__header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-h1);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.shell__version {
|
||||
color: var(--color-fg-dim);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.shell__body {
|
||||
flex: 1;
|
||||
background: var(--color-bg-elev);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--color-fg);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.login {
|
||||
max-width: 640px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login h2 {
|
||||
margin: 0;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.login p {
|
||||
margin: 0;
|
||||
color: var(--color-fg-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.login__code {
|
||||
font-size: 56px;
|
||||
letter-spacing: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(145, 71, 255, 0.12);
|
||||
padding: 16px 28px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.login__qr {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.login__hint {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.login__error {
|
||||
color: #ff6868;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font: inherit;
|
||||
padding: 16px 32px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-fg);
|
||||
cursor: pointer;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background: #a970ff;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
// Spiegel der Preload-API. Manuell synchron halten zu src/preload/index.ts.
|
||||
|
||||
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||
|
||||
export interface DeviceFlowStartInfo {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresInSec: number
|
||||
}
|
||||
|
||||
export type AuthEvent =
|
||||
| { kind: 'authorized' }
|
||||
| { kind: 'denied' }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export interface T4sdApi {
|
||||
appVersion: string
|
||||
auth: {
|
||||
isConfigured: () => Promise<boolean>
|
||||
getStatus: () => Promise<AuthStatus>
|
||||
startDeviceFlow: () => Promise<DeviceFlowStartInfo>
|
||||
cancel: () => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
onEvent: (cb: (event: AuthEvent) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
t4sd: T4sdApi
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user