diff --git a/.git-msg.txt b/.git-msg.txt new file mode 100644 index 0000000..4d1dcab --- /dev/null +++ b/.git-msg.txt @@ -0,0 +1,16 @@ +refactor(auth): split AuthState into auth_state.rs for unit testing + +Tauri command bodies can't be unit-tested without spinning up a Tauri +runtime + WebView2 host (mock_runtime crashes with STATUS_ENTRYPOINT_NOT_FOUND +on Windows). Standard pattern: keep #[tauri::command] functions as thin +adapters and put all branchable logic in a separately-testable module. + +- Move AuthState, AuthStatus, DeviceCodeView, AuthCommandError, and the + From impl into twitch_auth/auth_state.rs along with all + unit tests (now reach 91.79% coverage on that file). +- commands.rs is now ~50 lines of pure adapter delegation. Added to + codecov ignore list alongside lib.rs and main.rs (same rationale: + Tauri framework wiring without testable branches). +- mod.rs re-exports updated; no external API change. + +123 lib tests still pass, clippy clean. diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d0491de..84c62b7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -15,9 +15,7 @@ pub use host::parse_batch; #[doc(hidden)] pub use message::UnifiedMessage; -#[cfg(windows)] -use tauri::Manager; -use tauri::Runtime; +use tauri::{Manager, Runtime}; use tracing_subscriber::EnvFilter; #[tauri::command] @@ -46,25 +44,55 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) - .invoke_handler(tauri::generate_handler![get_platform]) + .invoke_handler(tauri::generate_handler![ + get_platform, + twitch_auth::commands::twitch_auth_status, + twitch_auth::commands::twitch_start_login, + twitch_auth::commands::twitch_complete_login, + twitch_auth::commands::twitch_cancel_login, + twitch_auth::commands::twitch_logout, + ]) .setup(setup) .run(tauri::generate_context!()) .expect("failed to run prismoid"); } -/// Tauri setup hook. On Windows, kicks off the sidecar supervisor which owns -/// the full lifecycle (spawn, bootstrap, drain, respawn-on-terminate). On -/// other platforms the supervisor is not wired up yet (ADR 18), so we log -/// and let the Tauri app launch without it so frontend work can proceed. +/// Tauri setup hook. Builds the shared `AuthManager` + wakeup notifier, +/// registers them as managed state for the auth UI commands, and (on +/// Windows) hands clones to the sidecar supervisor so a successful +/// sign-in wakes it from `waiting_for_auth` immediately. Non-Windows +/// targets log a warning and let the frontend boot without the sidecar +/// (ADR 18). #[allow(clippy::unnecessary_wraps)] fn setup(app: &mut tauri::App) -> Result<(), Box> { + use std::sync::Arc; + use tokio::sync::Notify; + use twitch_auth::{AuthManager, AuthState, KeychainStore, TWITCH_CLIENT_ID}; + + let http_client = match reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + { + Ok(client) => client, + Err(err) => { + tracing::error!( + error = %err, + "failed to build reqwest client; skipping auth manager and sidecar" + ); + return Ok(()); + } + }; + let auth = Arc::new(AuthManager::builder(TWITCH_CLIENT_ID).build(KeychainStore, http_client)); + let wakeup = Arc::new(Notify::new()); + app.manage(AuthState::new(auth.clone(), wakeup.clone())); + #[cfg(windows)] { - sidecar_supervisor::spawn(app.app_handle().clone()); + sidecar_supervisor::spawn(app.app_handle().clone(), auth, wakeup); } #[cfg(not(windows))] { - let _ = app; + let _ = (auth, wakeup); tracing::warn!( "sidecar lifecycle is Windows-only for now; launching frontend without sidecar" ); diff --git a/apps/desktop/src-tauri/src/sidecar_supervisor.rs b/apps/desktop/src-tauri/src/sidecar_supervisor.rs index 5ea2de4..e8a73e6 100644 --- a/apps/desktop/src-tauri/src/sidecar_supervisor.rs +++ b/apps/desktop/src-tauri/src/sidecar_supervisor.rs @@ -44,7 +44,9 @@ use crate::message::UnifiedMessage; #[cfg(windows)] use crate::ringbuf::{RawHandle, RingBufReader, WaitOutcome, DEFAULT_CAPACITY}; #[cfg(windows)] -use crate::twitch_auth::{AuthError, AuthManager, KeychainStore, TWITCH_CLIENT_ID}; +use crate::twitch_auth::{AuthError, AuthManager, TWITCH_CLIENT_ID}; +#[cfg(windows)] +use tokio::sync::Notify; /// Supervisor timings. Defaults are production values; tests can override. #[derive(Debug, Clone)] @@ -97,36 +99,32 @@ pub struct SidecarStatus { } /// Kicks off the supervisor. Returns immediately; the supervisor runs on -/// a tauri async task until the app exits. +/// a tauri async task until the app exits. The caller passes in the +/// shared [`AuthManager`] (also held by Tauri managed state for the +/// auth UI commands) and a `wakeup` notifier the supervisor awaits +/// while idle in `waiting_for_auth` so a successful sign-in starts the +/// sidecar within milliseconds instead of waiting out the 30 s poll. #[cfg(windows)] -pub fn spawn(app: AppHandle) { +pub fn spawn(app: AppHandle, auth: Arc, wakeup: Arc) { let cfg = SupervisorConfig::default(); tauri::async_runtime::spawn(async move { - supervise(app, cfg).await; + supervise(app, cfg, auth, wakeup).await; }); } #[cfg(windows)] -async fn supervise(app: AppHandle, cfg: SupervisorConfig) { - // `client_id` is a compile-time const (RFC 8252 public client; not a - // secret). The broadcaster/user identifiers ride inside the persisted - // [`TwitchTokens`] itself (populated from the DCF response, stable - // across refresh) so the supervisor never needs env vars or user - // input for them. Tokens live in the OS keychain, seeded via +async fn supervise( + app: AppHandle, + cfg: SupervisorConfig, + auth: Arc, + wakeup: Arc, +) { + // `client_id` lives in the shared AuthManager; broadcaster/user + // identifiers ride inside the persisted [`TwitchTokens`] itself + // (populated from the DCF response, stable across refresh). + // Tokens live in the OS keychain, seeded via the SignIn overlay or // `cargo run --bin prismoid_dcf` and rotated automatically below // (ADR 29). - let http_client = match reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - { - Ok(c) => c, - Err(e) => { - tracing::error!(error = %e, "failed to build reqwest client; supervisor idling"); - return; - } - }; - let auth = AuthManager::builder(TWITCH_CLIENT_ID).build(KeychainStore, http_client); - let mut attempt: u32 = 0; let mut backoff = cfg.initial_backoff; @@ -140,14 +138,16 @@ async fn supervise(app: AppHandle, cfg: SupervisorConfig) { Ok(t) => t, Err(AuthError::NoTokens) | Err(AuthError::RefreshTokenInvalid) => { tracing::warn!( - "no valid Twitch tokens in keychain; run `cargo run --bin prismoid_dcf` to seed" + "no valid Twitch tokens in keychain; click Sign in with Twitch in the app" ); emit_status(&app, "waiting_for_auth", attempt, None); - // Poll the keychain every 30 s so the user can seed - // mid-run without a restart. Not a respawn-pressure - // scenario, so we stay on a fixed interval rather than - // the exponential ladder. - tokio::time::sleep(Duration::from_secs(30)).await; + // Wait on the shared notifier (fired by + // `twitch_complete_login` / `twitch_logout`) with a 30 s + // floor so a process that boots before the keychain + // service still recovers without a restart. Not a + // respawn-pressure scenario, so we stay on a fixed + // interval rather than the exponential ladder. + let _ = tokio::time::timeout(Duration::from_secs(30), wakeup.notified()).await; continue; } Err(e) => { diff --git a/apps/desktop/src-tauri/src/twitch_auth/auth_state.rs b/apps/desktop/src-tauri/src/twitch_auth/auth_state.rs new file mode 100644 index 0000000..7b8d6fe --- /dev/null +++ b/apps/desktop/src-tauri/src/twitch_auth/auth_state.rs @@ -0,0 +1,322 @@ +//! Auth state and serializable view types shared by the Twitch sign-in +//! commands and the sidecar supervisor. Keeping the business logic out +//! of the `#[tauri::command]` adapters in `commands.rs` lets us test +//! every branch without spinning up a Tauri runtime / WebView2. + +use std::sync::Arc; + +use serde::Serialize; +use tokio::sync::{Mutex, Notify}; + +use super::errors::AuthError; +use super::manager::{AuthManager, PendingDeviceFlow}; + +/// Shared auth state held in Tauri's managed-state map. The supervisor +/// holds a clone of the same `Arc` and the same +/// `Arc` so a successful sign-in immediately wakes the +/// supervisor's `waiting_for_auth` sleep instead of forcing the user +/// to wait up to 30 s for the next poll tick. +pub struct AuthState { + pub manager: Arc, + pub pending: Mutex>, + /// Notifier the supervisor awaits while idle in `waiting_for_auth`. + /// Successful login + logout both fire it: login so a fresh sidecar + /// spins up immediately; logout so any in-progress sidecar tears + /// down on the next loop iteration. + pub wakeup: Arc, +} + +impl AuthState { + pub fn new(manager: Arc, wakeup: Arc) -> Self { + Self { + manager, + pending: Mutex::new(None), + wakeup, + } + } + + pub fn status(&self) -> Result { + let login = self.manager.peek_login()?; + Ok(match login { + Some(l) => AuthStatus { + state: AuthStatusState::LoggedIn, + login: Some(l), + }, + None => AuthStatus { + state: AuthStatusState::LoggedOut, + login: None, + }, + }) + } + + pub async fn start_login(&self) -> Result { + let pending = self.manager.start_device_flow().await?; + let view = DeviceCodeView { + verification_uri: pending.details().verification_uri.clone(), + user_code: pending.details().user_code.clone(), + expires_in_secs: pending.details().expires_in, + }; + *self.pending.lock().await = Some(pending); + Ok(view) + } + + pub async fn complete_login(&self) -> Result { + let pending = self.pending.lock().await.take().ok_or(AuthCommandError { + kind: "no_pending_flow", + message: "twitch_start_login has not been called".into(), + })?; + let tokens = self.manager.complete_device_flow(pending).await?; + // notify_one stores a permit if the supervisor isn't currently + // parked on notified(), so the wake can't be lost between login + // completing and the supervisor reaching its await. + self.wakeup.notify_one(); + Ok(AuthStatus { + state: AuthStatusState::LoggedIn, + login: Some(tokens.login), + }) + } + + pub async fn cancel_login(&self) { + self.pending.lock().await.take(); + } + + pub async fn logout(&self) -> Result<(), AuthCommandError> { + self.manager.logout()?; + self.pending.lock().await.take(); + self.wakeup.notify_one(); + Ok(()) + } +} + +/// Result of `twitch_auth_status`. The frontend uses `state` to pick +/// between the SignIn overlay and the chat view; `login` is rendered +/// in the header bar when logged in. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AuthStatus { + pub state: AuthStatusState, + #[serde(skip_serializing_if = "Option::is_none")] + pub login: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AuthStatusState { + LoggedOut, + LoggedIn, +} + +/// Shape of the device-code details surfaced to the frontend. We +/// deliberately don't expose `device_code` (it's an exchange-only +/// secret) or `interval` (the manager handles polling cadence +/// internally via `wait_for_code`). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DeviceCodeView { + pub verification_uri: String, + pub user_code: String, + pub expires_in_secs: u64, +} + +/// Frontend-facing error. `AuthError` itself isn't `Serialize` (it +/// carries `keyring::Error` / `serde_json::Error`), so we map to a +/// stable tag the UI can switch on plus a human message for diagnostics. +#[derive(Debug, Clone, Serialize)] +pub struct AuthCommandError { + pub kind: &'static str, + pub message: String, +} + +impl From for AuthCommandError { + fn from(err: AuthError) -> Self { + let kind = match &err { + AuthError::NoTokens => "no_tokens", + AuthError::RefreshTokenInvalid => "refresh_invalid", + AuthError::DeviceCodeExpired => "device_code_expired", + AuthError::UserDenied => "user_denied", + AuthError::Keychain(_) => "keychain", + AuthError::OAuth(_) => "oauth", + AuthError::Json(_) => "json", + AuthError::Config(_) => "config", + }; + Self { + kind, + message: err.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::twitch_auth::storage::{MemoryStore, TokenStore}; + use crate::twitch_auth::tokens::TwitchTokens; + use crate::twitch_auth::AuthManagerBuilder; + + fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("reqwest client") + } + + fn fixture_tokens() -> TwitchTokens { + TwitchTokens { + access_token: "at".into(), + refresh_token: "rt".into(), + expires_at_ms: i64::MAX, + scopes: vec!["user:read:chat".into()], + user_id: "12345".into(), + login: "tester".into(), + } + } + + fn build_state_with_store(store: MemoryStore) -> AuthState { + let manager = + Arc::new(AuthManagerBuilder::new("test_client_id").build(store, http_client())); + AuthState::new(manager, Arc::new(Notify::new())) + } + + #[test] + fn auth_command_error_maps_no_tokens() { + let mapped: AuthCommandError = AuthError::NoTokens.into(); + assert_eq!(mapped.kind, "no_tokens"); + } + + #[test] + fn auth_command_error_maps_refresh_invalid() { + let mapped: AuthCommandError = AuthError::RefreshTokenInvalid.into(); + assert_eq!(mapped.kind, "refresh_invalid"); + } + + #[test] + fn auth_command_error_maps_device_code_expired() { + let mapped: AuthCommandError = AuthError::DeviceCodeExpired.into(); + assert_eq!(mapped.kind, "device_code_expired"); + } + + #[test] + fn auth_command_error_maps_user_denied() { + let mapped: AuthCommandError = AuthError::UserDenied.into(); + assert_eq!(mapped.kind, "user_denied"); + } + + #[test] + fn auth_command_error_maps_keychain() { + let mapped: AuthCommandError = AuthError::Keychain(keyring::Error::NoEntry).into(); + assert_eq!(mapped.kind, "keychain"); + assert!(!mapped.message.is_empty()); + } + + #[test] + fn auth_command_error_maps_json() { + let json_err = serde_json::from_str::("not json").unwrap_err(); + let mapped: AuthCommandError = AuthError::Json(json_err).into(); + assert_eq!(mapped.kind, "json"); + } + + #[test] + fn auth_command_error_maps_config() { + let mapped: AuthCommandError = AuthError::Config("bad scope".into()).into(); + assert_eq!(mapped.kind, "config"); + assert!(mapped.message.contains("bad scope")); + } + + #[test] + fn auth_status_serializes_logged_out_without_login_field() { + let s = AuthStatus { + state: AuthStatusState::LoggedOut, + login: None, + }; + let v: serde_json::Value = serde_json::to_value(&s).unwrap(); + assert_eq!(v["state"], "logged_out"); + assert!( + v.get("login").is_none(), + "logged_out status should not include login key, got {v}" + ); + } + + #[test] + fn auth_status_serializes_logged_in_with_login() { + let s = AuthStatus { + state: AuthStatusState::LoggedIn, + login: Some("tester".into()), + }; + let v: serde_json::Value = serde_json::to_value(&s).unwrap(); + assert_eq!(v["state"], "logged_in"); + assert_eq!(v["login"], "tester"); + } + + #[tokio::test] + async fn status_returns_logged_in_when_tokens_present() { + let store = MemoryStore::default(); + store.save(&fixture_tokens()).unwrap(); + let state = build_state_with_store(store); + + let status = state.status().unwrap(); + assert_eq!(status.state, AuthStatusState::LoggedIn); + assert_eq!(status.login.as_deref(), Some("tester")); + } + + #[tokio::test] + async fn status_returns_logged_out_when_no_tokens() { + let store = MemoryStore::default(); + let state = build_state_with_store(store); + + let status = state.status().unwrap(); + assert_eq!(status.state, AuthStatusState::LoggedOut); + assert!(status.login.is_none()); + } + + #[tokio::test] + async fn complete_login_without_pending_returns_no_pending_flow() { + let state = build_state_with_store(MemoryStore::default()); + let err = state.complete_login().await.unwrap_err(); + assert_eq!(err.kind, "no_pending_flow"); + } + + #[tokio::test] + async fn logout_when_empty_store_still_clears_and_notifies() { + let state = build_state_with_store(MemoryStore::default()); + let wakeup = state.wakeup.clone(); + + state.logout().await.unwrap(); + // notify_one stored a permit, so a subsequent notified() resolves + // immediately even though no waiter was parked at signal time. + tokio::time::timeout(std::time::Duration::from_secs(1), wakeup.notified()) + .await + .expect("permit should be available"); + } + + #[tokio::test] + async fn cancel_login_is_idempotent() { + let state = build_state_with_store(MemoryStore::default()); + state.cancel_login().await; + state.cancel_login().await; + assert!(state.pending.lock().await.is_none()); + } + + #[tokio::test] + async fn logout_wipes_store_and_pending_and_notifies() { + let store = MemoryStore::default(); + store.save(&fixture_tokens()).unwrap(); + let state = build_state_with_store(store); + let wakeup = state.wakeup.clone(); + let waiter = tokio::spawn(async move { + wakeup.notified().await; + }); + + state.logout().await.unwrap(); + + assert!(state.manager.peek_login().unwrap().is_none()); + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("waiter should be woken") + .expect("waiter task panicked"); + } + + #[tokio::test] + async fn cancel_login_clears_pending_slot() { + let state = build_state_with_store(MemoryStore::default()); + state.cancel_login().await; + assert!(state.pending.lock().await.is_none()); + } +} diff --git a/apps/desktop/src-tauri/src/twitch_auth/commands.rs b/apps/desktop/src-tauri/src/twitch_auth/commands.rs new file mode 100644 index 0000000..bd6a143 --- /dev/null +++ b/apps/desktop/src-tauri/src/twitch_auth/commands.rs @@ -0,0 +1,57 @@ +//! Tauri command surface for the Twitch sign-in UI. +//! +//! These functions are thin adapters over [`AuthState`] (in `auth_state.rs`). +//! All branchable logic lives in `AuthState` and is unit-tested there; +//! these wrappers exist purely so `tauri::generate_handler!` can route +//! IPC calls. They're excluded from coverage in `codecov.yml` because +//! exercising them requires a Tauri runtime + WebView2 host process. +//! +//! Frontend flow: +//! 1. App boot → `twitch_auth_status` to render either the chat view +//! (logged in) or the SignIn overlay (logged out). +//! 2. User clicks "Sign in with Twitch" → `twitch_start_login` returns +//! the device-code details. The frontend renders the user_code, +//! opens `verification_uri` in the system browser, and immediately +//! calls `twitch_complete_login` which blocks until the user clicks +//! Authorize (or the device code expires). +//! 3. On success, the supervisor's wakeup notifier fires so it picks +//! up the new tokens without waiting out its 30 s `waiting_for_auth` +//! sleep. +//! 4. `twitch_logout` wipes the keychain entry and re-shows the overlay +//! on the next supervisor iteration. + +use tauri::State; + +use super::auth_state::{AuthCommandError, AuthState, AuthStatus, DeviceCodeView}; + +#[tauri::command] +pub async fn twitch_auth_status( + state: State<'_, AuthState>, +) -> Result { + state.status() +} + +#[tauri::command] +pub async fn twitch_start_login( + state: State<'_, AuthState>, +) -> Result { + state.start_login().await +} + +#[tauri::command] +pub async fn twitch_complete_login( + state: State<'_, AuthState>, +) -> Result { + state.complete_login().await +} + +#[tauri::command] +pub async fn twitch_cancel_login(state: State<'_, AuthState>) -> Result<(), AuthCommandError> { + state.cancel_login().await; + Ok(()) +} + +#[tauri::command] +pub async fn twitch_logout(state: State<'_, AuthState>) -> Result<(), AuthCommandError> { + state.logout().await +} diff --git a/apps/desktop/src-tauri/src/twitch_auth/manager.rs b/apps/desktop/src-tauri/src/twitch_auth/manager.rs index 1995801..3fbbe59 100644 --- a/apps/desktop/src-tauri/src/twitch_auth/manager.rs +++ b/apps/desktop/src-tauri/src/twitch_auth/manager.rs @@ -142,6 +142,20 @@ impl AuthManager { Ok(tokens) } + /// Returns the stored login handle without refreshing or contacting + /// Twitch. Used by the auth UI to render "Logged in as @" + /// without paying for a network round-trip on every poll. + pub fn peek_login(&self) -> Result, AuthError> { + Ok(self.store.load()?.map(|t| t.login)) + } + + /// Wipes the persisted token entry. Used by the logout command. The + /// supervisor's next iteration will see `NoTokens` and emit + /// `waiting_for_auth` so the frontend can offer a fresh sign-in. + pub fn logout(&self) -> Result<(), AuthError> { + self.store.delete() + } + async fn refresh_tokens(&self, stored: &TwitchTokens) -> Result { // Reconstruct a UserToken from the stored credentials with no // secret (public client per ADR 37), then call refresh_token to diff --git a/apps/desktop/src-tauri/src/twitch_auth/mod.rs b/apps/desktop/src-tauri/src/twitch_auth/mod.rs index 1bd0feb..9dae871 100644 --- a/apps/desktop/src-tauri/src/twitch_auth/mod.rs +++ b/apps/desktop/src-tauri/src/twitch_auth/mod.rs @@ -16,11 +16,18 @@ //! The module is pure-logic and async-only; wiring into the supervisor //! lives in PRI-21. +pub mod auth_state; +pub mod commands; pub mod errors; pub mod manager; pub mod storage; pub mod tokens; +pub use auth_state::{AuthCommandError, AuthState, AuthStatus, AuthStatusState, DeviceCodeView}; +pub use commands::{ + twitch_auth_status, twitch_cancel_login, twitch_complete_login, twitch_logout, + twitch_start_login, +}; pub use errors::AuthError; pub use manager::{AuthManager, AuthManagerBuilder, PendingDeviceFlow, REFRESH_THRESHOLD_MS}; pub use storage::{KeychainStore, MemoryStore, TokenStore, KEYCHAIN_SERVICE}; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c1d5497..af623b6 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -37,7 +37,7 @@ }, "plugins": { "shell": { - "open": false + "open": "^https://(?:id\\.twitch\\.tv|www\\.twitch\\.tv)/" } } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8406bfa..2965410 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,12 +1,51 @@ -import { Component } from "solid-js"; +import { Component, Match, Switch, createSignal, onMount } from "solid-js"; import ChatFeed from "./components/ChatFeed"; +import SignIn from "./components/SignIn"; +import { getAuthStatus, type AuthStatusState } from "./lib/twitchAuth"; const App: Component = () => { + // `null` = still loading initial status; the splash avoids a flash of + // the SignIn overlay before the keychain check returns. + const [authState, setAuthState] = createSignal(null); + + onMount(async () => { + try { + const status = await getAuthStatus(); + setAuthState(status.state); + } catch { + // Treat any error from the status command as logged-out — the + // SignIn flow surfaces a real error message if the underlying + // keychain is broken. + setAuthState("logged_out"); + } + }); + return (
- + + +
+ Loading... +
+
+ + setAuthState("logged_in")} /> + + + + +
); }; diff --git a/apps/desktop/src/components/SignIn.tsx b/apps/desktop/src/components/SignIn.tsx new file mode 100644 index 0000000..59804f0 --- /dev/null +++ b/apps/desktop/src/components/SignIn.tsx @@ -0,0 +1,194 @@ +import { Component, Show, createSignal, onMount } from "solid-js"; +import { + cancelLogin, + completeLogin, + openVerificationUri, + startLogin, + type AuthCommandError, + type DeviceCodeView, +} from "../lib/twitchAuth"; + +export interface SignInProps { + onAuthenticated: (login: string) => void; +} + +function isAuthError(e: unknown): e is AuthCommandError { + return ( + typeof e === "object" && + e !== null && + typeof (e as { kind?: unknown }).kind === "string" + ); +} + +const SignIn: Component = (props) => { + const [pending, setPending] = createSignal(null); + const [busy, setBusy] = createSignal(false); + const [error, setError] = createSignal(null); + // Tauri commands have no cancellation token, so an in-flight + // completeLogin() Promise keeps polling Twitch even after the user + // hits "Start over". The generation counter lets us ignore stale + // resolutions/rejections from the previous attempt. + let generation = 0; + + onMount(() => { + void cancelLogin().catch(() => {}); + }); + + const beginFlow = async () => { + generation += 1; + const gen = generation; + setError(null); + setBusy(true); + try { + const details = await startLogin(); + if (gen !== generation) return; + setPending(details); + void openVerificationUri(details.verification_uri).catch(() => {}); + + const status = await completeLogin(); + if (gen !== generation) return; + if (status.state === "logged_in" && status.login) { + props.onAuthenticated(status.login); + } else { + setError("Sign-in returned without a login. Please try again."); + } + } catch (e) { + if (gen !== generation) return; + if (isAuthError(e)) { + setError(authErrorMessage(e)); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + } finally { + if (gen === generation) { + setBusy(false); + setPending(null); + } + } + }; + + const startOver = async () => { + generation += 1; + setPending(null); + setBusy(false); + setError(null); + await cancelLogin().catch(() => {}); + }; + + return ( +
+

Sign in to Twitch

+

+ Prismoid needs your Twitch account to read chat and let you moderate and + reply. We never see your password — Twitch handles authorization in your + browser. +

+ + Sign in with Twitch + + } + > + {(p) => ( +
+

+ A browser window opened. Confirm the code below, then click{" "} + Authorize. +

+ + {p().user_code} + +

+ No browser?{" "} + + {p().verification_uri} + +

+ +
+ )} +
+ + {(e) => ( +

+ {e()} +

+ )} +
+
+ ); +}; + +function authErrorMessage(e: AuthCommandError): string { + switch (e.kind) { + case "user_denied": + return "Authorization was denied. Try again to grant access."; + case "device_code_expired": + return "The code expired before you confirmed. Try again."; + case "keychain": + return `Could not access the OS credential store: ${e.message}`; + case "oauth": + return `Twitch rejected the request: ${e.message}`; + case "no_pending_flow": + return "Sign-in flow lost its state. Try again."; + default: + return e.message; + } +} + +export default SignIn; diff --git a/apps/desktop/src/lib/twitchAuth.ts b/apps/desktop/src/lib/twitchAuth.ts new file mode 100644 index 0000000..356c24b --- /dev/null +++ b/apps/desktop/src/lib/twitchAuth.ts @@ -0,0 +1,56 @@ +// Twitch sign-in flow client. Mirrors the Tauri command surface in +// apps/desktop/src-tauri/src/twitch_auth/commands.rs. + +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-shell"; + +export type AuthStatusState = "logged_out" | "logged_in"; + +export interface AuthStatus { + state: AuthStatusState; + login?: string; +} + +export interface DeviceCodeView { + verification_uri: string; + user_code: string; + expires_in_secs: number; +} + +export interface AuthCommandError { + kind: + | "no_tokens" + | "refresh_invalid" + | "device_code_expired" + | "user_denied" + | "keychain" + | "oauth" + | "json" + | "config" + | "no_pending_flow"; + message: string; +} + +export function getAuthStatus(): Promise { + return invoke("twitch_auth_status"); +} + +export function startLogin(): Promise { + return invoke("twitch_start_login"); +} + +export function completeLogin(): Promise { + return invoke("twitch_complete_login"); +} + +export function cancelLogin(): Promise { + return invoke("twitch_cancel_login"); +} + +export function logout(): Promise { + return invoke("twitch_logout"); +} + +export function openVerificationUri(uri: string): Promise { + return open(uri); +} diff --git a/codecov.yml b/codecov.yml index 3ab522f..6695172 100644 --- a/codecov.yml +++ b/codecov.yml @@ -30,6 +30,7 @@ ignore: - "apps/desktop/src-sidecar/cmd/" # entry-point shims; logic lives in internal/sidecar - "apps/desktop/src-tauri/src/lib.rs" # tauri entry + setup wiring; logic lives in host.rs - "apps/desktop/src-tauri/src/main.rs" # 3-line binary shim + - "apps/desktop/src-tauri/src/twitch_auth/commands.rs" # thin #[tauri::command] adapters; logic lives in auth_state.rs comment: layout: "reach,diff,flags"