Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 71 additions & 5 deletions app/src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! leaves device authorization behind an explicit welcome-screen action. The
//! authentication gate remains visible until the browser flow completes.
mod mcp;
mod telemetry;
mod user_info;
use std::env;

Expand All @@ -15,9 +16,13 @@ pub use mcp::{
TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpSyncedTemplateProvenance, TuiMcpTemplateVariable,
TuiMcpTransport, TuiMcpVariableValue,
};
use telemetry::{
AbandonmentPhase, AuthenticationEntrypoint, TuiOnboardingTelemetry, TuiOnboardingTelemetryEvent,
};
use url::Url;
pub use user_info::{TuiUserInfoManager, TuiUserInfoManagerEvent, TuiUserInfoSnapshot};
use warp_core::channel::ChannelState;
use warp_core::telemetry::TelemetryEvent as _;
use warpui::{AppContext, Entity, SingletonEntity};

use crate::TuiMountFn;
Expand Down Expand Up @@ -66,6 +71,7 @@ enum TuiAuthBrowserFlow {
pub struct TuiLoginModel {
phase: TuiLoginPhase,
browser_flow: TuiAuthBrowserFlow,
telemetry: TuiOnboardingTelemetry,
}

impl TuiLoginModel {
Expand All @@ -78,6 +84,33 @@ impl TuiLoginModel {
start_tui_device_login(ctx);
}

/// Starts device authorization and records that the generated URL should be copied.
pub fn start_device_login_and_copy_url(ctx: &mut AppContext) {
start_tui_device_login_with_entrypoint(AuthenticationEntrypoint::CopyUrl, ctx);
}

/// Records the outcome of copying the current authentication URL.
pub fn record_login_url_copied(succeeded: bool, ctx: &mut AppContext) {
let event =
Self::handle(ctx).update(ctx, |model, _| model.telemetry.login_url_copied(succeeded));
send_tui_onboarding_event(event, ctx);
}

/// Records that the user exited while the authentication UI was visible.
pub fn record_authentication_abandoned(ctx: &mut AppContext) {
let event = Self::handle(ctx).update(ctx, |model, _| {
let phase = AbandonmentPhase::from_login_phase(&model.phase)?;
model.telemetry.abandoned(phase)
});
send_tui_onboarding_event(event, ctx);
}

/// Records that the terminal became usable after interactive authentication.
pub fn record_terminal_shown(ctx: &mut AppContext) {
let event = Self::handle(ctx).update(ctx, |model, _| model.telemetry.completed());
send_tui_onboarding_event(event, ctx);
}

/// Opens the current device-authorization URL.
pub fn open_login_url(browser_url: &str, ctx: &mut AppContext) {
let is_current_url = matches!(
Expand All @@ -99,7 +132,12 @@ impl TuiLoginModel {
TuiLoginModel::as_ref(ctx).phase(),
TuiLoginPhase::BrowserOpenFailed { .. }
);
if !ctx.try_open_url(browser_url) {
let browser_opened = ctx.try_open_url(browser_url);
let event = TuiLoginModel::handle(ctx).update(ctx, |model, _| {
model.telemetry.browser_launch(browser_opened)
});
send_tui_onboarding_event(event, ctx);
if !browser_opened {
TuiLoginModel::handle(ctx).update(ctx, |model, _| {
if model.browser_flow == TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationOpened {
model.browser_flow = TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending;
Expand Down Expand Up @@ -135,6 +173,7 @@ impl TuiLoginModel {
Self {
phase: TuiLoginPhase::SignedOutWelcome,
browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization,
telemetry: TuiOnboardingTelemetry::new(false),
}
}

Expand All @@ -145,6 +184,7 @@ impl TuiLoginModel {
message: message.into(),
},
browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization,
telemetry: TuiOnboardingTelemetry::new(false),
}
}

Expand All @@ -153,6 +193,7 @@ impl TuiLoginModel {
Self {
phase: TuiLoginPhase::AwaitingLogin { browser_url },
browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization,
telemetry: TuiOnboardingTelemetry::new(false),
}
}
}
Expand All @@ -178,6 +219,7 @@ pub(crate) fn init(mount: TuiMountFn, ctx: &mut AppContext) {
ctx.add_singleton_model(move |_| TuiLoginModel {
phase: initial_phase,
browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization,
telemetry: TuiOnboardingTelemetry::new(logged_in),
});
ctx.add_singleton_model(TuiMcpManager::new);
ctx.add_singleton_model(TuiUserInfoManager::new);
Expand Down Expand Up @@ -209,6 +251,9 @@ fn handle_auth_manager_event(event: &AuthManagerEvent, ctx: &mut AppContext) {
verification_url_complete,
user_code,
} => {
let event = TuiLoginModel::handle(ctx)
.update(ctx, |model, _| model.telemetry.device_authorization_ready());
send_tui_onboarding_event(event, ctx);
// Prefer the "complete" URL (device code pre-filled) for opening.
let url_to_open = verification_url_complete
.as_deref()
Expand Down Expand Up @@ -246,6 +291,9 @@ fn handle_auth_manager_event(event: &AuthManagerEvent, ctx: &mut AppContext) {
activate_global_mcp_servers(ctx);
}
AuthManagerEvent::AuthFailed(err) => {
let event = TuiLoginModel::handle(ctx)
.update(ctx, |model, _| model.telemetry.authentication_failed(err));
send_tui_onboarding_event(event, ctx);
let should_finish_web_logout = matches!(
TuiLoginModel::as_ref(ctx).browser_flow,
TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending
Expand Down Expand Up @@ -346,21 +394,30 @@ fn activate_global_mcp_servers(ctx: &mut AppContext) {

/// Starts device authorization from a signed-out screen, preserving any required web logout.
pub fn start_tui_device_login(ctx: &mut AppContext) {
let should_authorize = TuiLoginModel::handle(ctx).update(ctx, |model, ctx| {
start_tui_device_login_with_entrypoint(AuthenticationEntrypoint::OpenBrowser, ctx);
}

fn start_tui_device_login_with_entrypoint(
entrypoint: AuthenticationEntrypoint,
ctx: &mut AppContext,
) {
let (should_authorize, event) = TuiLoginModel::handle(ctx).update(ctx, |model, ctx| {
match model.phase {
TuiLoginPhase::SignedOutWelcome => {
model.browser_flow = TuiAuthBrowserFlow::DirectDeviceAuthorization;
}
TuiLoginPhase::Failed { .. } => {}
TuiLoginPhase::AwaitingLogin { .. }
| TuiLoginPhase::BrowserOpenFailed { .. }
| TuiLoginPhase::LoggedIn => return false,
| TuiLoginPhase::LoggedIn => return (false, None),
}
model.phase = TuiLoginPhase::AwaitingLogin { browser_url: None };
let event = model.telemetry.authentication_started(entrypoint);
ctx.notify();
ctx.emit(TuiLoginEvent::PhaseChanged);
true
(true, Some(event))
});
send_tui_onboarding_event(event, ctx);
if should_authorize {
authorize_device(ctx);
}
Expand All @@ -376,13 +433,16 @@ pub fn log_out_tui(ctx: &mut AppContext) {
}

fn set_logged_out_phase(ctx: &mut AppContext) {
TuiLoginModel::handle(ctx).update(ctx, |model, ctx| {
let event = TuiLoginModel::handle(ctx).update(ctx, |model, ctx| {
model.phase = TuiLoginPhase::AwaitingLogin { browser_url: None };
model.browser_flow = TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending;
let event = model.telemetry.post_logout_authentication_started();
ctx.notify();
ctx.emit(TuiLoginEvent::PhaseChanged);
ctx.emit(TuiLoginEvent::LoggedOut);
event
});
send_tui_onboarding_event(Some(event), ctx);
}

/// Updates the shared [`TuiLoginModel`] phase and notifies observers, so the
Expand All @@ -403,6 +463,12 @@ fn set_login_phase(ctx: &mut AppContext, phase: TuiLoginPhase) {
});
}

fn send_tui_onboarding_event(event: Option<TuiOnboardingTelemetryEvent>, ctx: &mut AppContext) {
if let Some(event) = event {
warp_core::send_telemetry_from_app_ctx!(event, ctx);
}
}

#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
8 changes: 8 additions & 0 deletions app/src/tui/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ use std::cell::Cell;
use std::rc::Rc;

use warp_core::channel::ChannelState;
use warp_core::telemetry::testing::MockTelemetryContextProvider;
use warpui::{App, SingletonEntity};

use super::telemetry::TuiOnboardingTelemetry;
use super::{
TuiAuthBrowserFlow, TuiLoginEvent, TuiLoginModel, TuiLoginPhase, handle_auth_manager_event,
set_logged_out_phase, set_login_phase, start_tui_device_login,
Expand All @@ -14,9 +16,11 @@ use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::server::server_api::ServerApiProvider;
use crate::server::server_api::auth::UserAuthenticationError;
fn login_model(phase: TuiLoginPhase) -> TuiLoginModel {
let logged_in = matches!(phase, TuiLoginPhase::LoggedIn);
TuiLoginModel {
phase,
browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization,
telemetry: TuiOnboardingTelemetry::new(logged_in),
}
}

Expand Down Expand Up @@ -106,6 +110,7 @@ fn explicit_start_device_login_preserves_pending_logout_on_retry() {
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AuthManager::new_for_test);
app.add_singleton_model(|_| TuiLoginModel::signed_out_for_test());
app.update(MockTelemetryContextProvider::register);

let phase_changed_events = Rc::new(Cell::new(0));
let phase_changed_events_for_subscription = phase_changed_events.clone();
Expand Down Expand Up @@ -232,6 +237,7 @@ fn post_logout_device_auth_opens_logout_with_device_continuation() {
app.add_singleton_model(|_| TuiLoginModel {
phase: TuiLoginPhase::AwaitingLogin { browser_url: None },
browser_flow: TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending,
telemetry: TuiOnboardingTelemetry::new(true),
});

app.update(|ctx| {
Expand Down Expand Up @@ -305,6 +311,7 @@ fn post_logout_device_code_failure_still_opens_web_logout() {
app.add_singleton_model(|_| TuiLoginModel {
phase: TuiLoginPhase::AwaitingLogin { browser_url: None },
browser_flow: TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending,
telemetry: TuiOnboardingTelemetry::new(true),
});
let browser_opened = Rc::new(Cell::new(false));
let browser_opened_for_callback = browser_opened.clone();
Expand Down Expand Up @@ -380,6 +387,7 @@ fn emits_logged_in_event_when_login_completes() {
fn emits_logged_out_event_and_resets_login_details() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| login_model(TuiLoginPhase::LoggedIn));
app.update(MockTelemetryContextProvider::register);

let logged_out_events = Rc::new(Cell::new(0));
let logged_out_events_for_subscription = logged_out_events.clone();
Expand Down
Loading
Loading