From 524cb4141c515fd74d116cfb10d5ceb1453e1e55 Mon Sep 17 00:00:00 2001
From: ImpulseB23
Date: Fri, 17 Apr 2026 22:12:04 +0200
Subject: [PATCH 1/4] feat(auth): in-app Twitch sign-in flow
Adds Tauri command surface and SignIn overlay so non-developers can
sign in without running the prismoid_dcf CLI tool. Closes the OAuth
UX gap in Phase 1.
---
apps/desktop/src-tauri/src/lib.rs | 39 ++-
.../src-tauri/src/sidecar_supervisor.rs | 56 ++--
.../src-tauri/src/twitch_auth/commands.rs | 306 ++++++++++++++++++
.../src-tauri/src/twitch_auth/manager.rs | 14 +
apps/desktop/src-tauri/src/twitch_auth/mod.rs | 5 +
apps/desktop/src-tauri/tauri.conf.json | 2 +-
apps/desktop/src/App.tsx | 43 ++-
apps/desktop/src/components/SignIn.tsx | 186 +++++++++++
apps/desktop/src/lib/twitchAuth.ts | 56 ++++
9 files changed, 666 insertions(+), 41 deletions(-)
create mode 100644 apps/desktop/src-tauri/src/twitch_auth/commands.rs
create mode 100644 apps/desktop/src/components/SignIn.tsx
create mode 100644 apps/desktop/src/lib/twitchAuth.ts
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index d0491de..b4f77d6 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,46 @@ 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 = reqwest::Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .build()
+ .expect("failed to build reqwest client");
+ 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/commands.rs b/apps/desktop/src-tauri/src/twitch_auth/commands.rs
new file mode 100644
index 0000000..bbe3a0b
--- /dev/null
+++ b/apps/desktop/src-tauri/src/twitch_auth/commands.rs
@@ -0,0 +1,306 @@
+//! Tauri command surface for the Twitch sign-in UI.
+//!
+//! 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.
+//!
+//! The pending DCF builder is stored in `tokio::sync::Mutex
)}
From 829e0e11cbc64acb28b62c004366dfb055c17ef4 Mon Sep 17 00:00:00 2001
From: ImpulseB23
Date: Fri, 17 Apr 2026 23:44:41 +0200
Subject: [PATCH 3/4] test(auth): cover AuthState command helpers
Refactors the twitch_auth commands to delegate to inherent AuthState
methods so they're directly testable without a Tauri AppHandle, then
adds tests covering: status helper logged_in/logged_out paths,
complete_login no_pending_flow path, logout permit-storing notify,
cancel_login idempotency, and the remaining AuthError variants
(Keychain, Json, Config).
---
.git-msg.txt | 16 +-
.../src-tauri/src/twitch_auth/commands.rs | 161 +++++++++++++-----
2 files changed, 121 insertions(+), 56 deletions(-)
diff --git a/.git-msg.txt b/.git-msg.txt
index 22306a0..c3e2454 100644
--- a/.git-msg.txt
+++ b/.git-msg.txt
@@ -1,10 +1,8 @@
-fix(auth): address copilot review on PR #78
+test(auth): cover AuthState command helpers
-- Switch wakeup notifier from notify_waiters() to notify_one() so the
- permit is stored if the supervisor isn't currently parked, preventing
- dropped login/logout signals.
-- Degrade gracefully when reqwest::Client::builder() fails in setup
- instead of panicking the app (per docs/stability.md).
-- Rename SignIn cancel button to "Start over" and add a generation
- counter so stale completeLogin() resolutions from a previous attempt
- can't accidentally authenticate the user.
+Refactors the twitch_auth commands to delegate to inherent AuthState
+methods so they're directly testable without a Tauri AppHandle, then
+adds tests covering: status helper logged_in/logged_out paths,
+complete_login no_pending_flow path, logout permit-storing notify,
+cancel_login idempotency, and the remaining AuthError variants
+(Keychain, Json, Config).
diff --git a/apps/desktop/src-tauri/src/twitch_auth/commands.rs b/apps/desktop/src-tauri/src/twitch_auth/commands.rs
index 6caa2db..a76f247 100644
--- a/apps/desktop/src-tauri/src/twitch_auth/commands.rs
+++ b/apps/desktop/src-tauri/src/twitch_auth/commands.rs
@@ -52,6 +52,58 @@ impl AuthState {
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
@@ -114,64 +166,32 @@ impl From for AuthCommandError {
pub async fn twitch_auth_status(
state: State<'_, AuthState>,
) -> Result {
- let login = state.manager.peek_login()?;
- Ok(match login {
- Some(l) => AuthStatus {
- state: AuthStatusState::LoggedIn,
- login: Some(l),
- },
- None => AuthStatus {
- state: AuthStatusState::LoggedOut,
- login: None,
- },
- })
+ state.status()
}
#[tauri::command]
pub async fn twitch_start_login(
state: State<'_, AuthState>,
) -> Result {
- let pending = state.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,
- };
- *state.pending.lock().await = Some(pending);
- Ok(view)
+ state.start_login().await
}
#[tauri::command]
pub async fn twitch_complete_login(
state: State<'_, AuthState>,
) -> Result {
- let pending = state.pending.lock().await.take().ok_or(AuthCommandError {
- kind: "no_pending_flow",
- message: "twitch_start_login has not been called".into(),
- })?;
- let tokens = state.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.
- state.wakeup.notify_one();
- Ok(AuthStatus {
- state: AuthStatusState::LoggedIn,
- login: Some(tokens.login),
- })
+ state.complete_login().await
}
#[tauri::command]
pub async fn twitch_cancel_login(state: State<'_, AuthState>) -> Result<(), AuthCommandError> {
- state.pending.lock().await.take();
+ state.cancel_login().await;
Ok(())
}
#[tauri::command]
pub async fn twitch_logout(state: State<'_, AuthState>) -> Result<(), AuthCommandError> {
- state.manager.logout()?;
- state.pending.lock().await.take();
- state.wakeup.notify_one();
- Ok(())
+ state.logout().await
}
#[cfg(test)]
@@ -223,6 +243,27 @@ mod tests {
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 {
@@ -260,8 +301,9 @@ mod tests {
store.save(&fixture_tokens()).unwrap();
let state = build_state_with_store(store);
- let login = state.manager.peek_login().unwrap();
- assert_eq!(login.as_deref(), Some("tester"));
+ let status = state.status().unwrap();
+ assert_eq!(status.state, AuthStatusState::LoggedIn);
+ assert_eq!(status.login.as_deref(), Some("tester"));
}
#[tokio::test]
@@ -269,7 +311,37 @@ mod tests {
let store = MemoryStore::default();
let state = build_state_with_store(store);
- assert!(state.manager.peek_login().unwrap().is_none());
+ 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]
@@ -282,8 +354,7 @@ mod tests {
wakeup.notified().await;
});
- state.manager.logout().unwrap();
- state.wakeup.notify_one();
+ state.logout().await.unwrap();
assert!(state.manager.peek_login().unwrap().is_none());
tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
@@ -295,11 +366,7 @@ mod tests {
#[tokio::test]
async fn cancel_login_clears_pending_slot() {
let state = build_state_with_store(MemoryStore::default());
- // Can't construct a real PendingDeviceFlow in unit tests (it
- // requires a Twitch round-trip), so verify the slot ops only.
- assert!(state.pending.lock().await.is_none());
- // After explicit clear it remains None and no error is raised.
- state.pending.lock().await.take();
+ state.cancel_login().await;
assert!(state.pending.lock().await.is_none());
}
}
From 47b88d7b9cd99a7b16c2de810f18297827f04544 Mon Sep 17 00:00:00 2001
From: ImpulseB23
Date: Fri, 17 Apr 2026 23:56:12 +0200
Subject: [PATCH 4/4] 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.
---
.git-msg.txt | 22 +-
.../src-tauri/src/twitch_auth/auth_state.rs | 322 +++++++++++++++++
.../src-tauri/src/twitch_auth/commands.rs | 329 +-----------------
apps/desktop/src-tauri/src/twitch_auth/mod.rs | 4 +-
codecov.yml | 1 +
5 files changed, 348 insertions(+), 330 deletions(-)
create mode 100644 apps/desktop/src-tauri/src/twitch_auth/auth_state.rs
diff --git a/.git-msg.txt b/.git-msg.txt
index c3e2454..4d1dcab 100644
--- a/.git-msg.txt
+++ b/.git-msg.txt
@@ -1,8 +1,16 @@
-test(auth): cover AuthState command helpers
+refactor(auth): split AuthState into auth_state.rs for unit testing
-Refactors the twitch_auth commands to delegate to inherent AuthState
-methods so they're directly testable without a Tauri AppHandle, then
-adds tests covering: status helper logged_in/logged_out paths,
-complete_login no_pending_flow path, logout permit-storing notify,
-cancel_login idempotency, and the remaining AuthError variants
-(Keychain, Json, Config).
+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/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
index a76f247..bd6a143 100644
--- a/apps/desktop/src-tauri/src/twitch_auth/commands.rs
+++ b/apps/desktop/src-tauri/src/twitch_auth/commands.rs
@@ -1,5 +1,11 @@
//! 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).
@@ -13,154 +19,10 @@
//! sleep.
//! 4. `twitch_logout` wipes the keychain entry and re-shows the overlay
//! on the next supervisor iteration.
-//!
-//! The pending DCF builder is stored in `tokio::sync::Mutex
>`
-//! managed state so `start_login` and `complete_login` can hand it
-//! between two separate command invocations. Only one device flow can
-//! be in flight at a time per app instance, which matches the UX
-//! (single overlay, single button).
-
-use std::sync::Arc;
-use serde::Serialize;
use tauri::State;
-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 string. `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(),
- }
- }
-}
+use super::auth_state::{AuthCommandError, AuthState, AuthStatus, DeviceCodeView};
#[tauri::command]
pub async fn twitch_auth_status(
@@ -193,180 +55,3 @@ pub async fn twitch_cancel_login(state: State<'_, AuthState>) -> Result<(), Auth
pub async fn twitch_logout(state: State<'_, AuthState>) -> Result<(), AuthCommandError> {
state.logout().await
}
-
-#[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(),
- }
- }
-
- #[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");
- }
-
- 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()))
- }
-
- #[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/mod.rs b/apps/desktop/src-tauri/src/twitch_auth/mod.rs
index f50b6cc..9dae871 100644
--- a/apps/desktop/src-tauri/src/twitch_auth/mod.rs
+++ b/apps/desktop/src-tauri/src/twitch_auth/mod.rs
@@ -16,15 +16,17 @@
//! 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, AuthState, AuthStatus, AuthStatusState, DeviceCodeView,
+ twitch_start_login,
};
pub use errors::AuthError;
pub use manager::{AuthManager, AuthManagerBuilder, PendingDeviceFlow, REFRESH_THRESHOLD_MS};
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"