Skip to content

Commit ac8f47a

Browse files
authored
Merge pull request #18 from bitwarden/pair
Better CLI experience, /pair
2 parents 6b2b010 + 1e96ce0 commit ac8f47a

File tree

8 files changed

+180
-48
lines changed

8 files changed

+180
-48
lines changed

crates/bw-rat-client/src/clients/user_client.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ pub struct UserClient {
172172
incoming_rx: Option<mpsc::UnboundedReceiver<IncomingMessage>>,
173173
/// Pending handshake awaiting fingerprint verification
174174
pending_verification: Option<PendingHandshakeVerification>,
175+
/// Name to assign to the next newly-paired session
176+
pending_session_name: Option<String>,
175177
}
176178

177179
impl UserClient {
@@ -197,6 +199,7 @@ impl UserClient {
197199
psk: None,
198200
incoming_rx: Some(incoming_rx),
199201
pending_verification: None,
202+
pending_session_name: None,
200203
})
201204
}
202205

@@ -410,6 +413,11 @@ impl UserClient {
410413
// and save the new transport state from the fresh handshake.
411414
self.transports.insert(source, transport.clone());
412415
self.session_store.cache_session(source)?;
416+
// Apply pending name if user explicitly re-paired (e.g. `/pair MyName`).
417+
// During passive reconnections, pending_session_name is None so this is a no-op.
418+
if let Some(name) = self.pending_session_name.take() {
419+
self.session_store.set_session_name(&source, name)?;
420+
}
413421
self.session_store
414422
.save_transport_state(&source, transport)?;
415423

@@ -423,6 +431,9 @@ impl UserClient {
423431
// PSK connection: trust established via pre-shared key, no verification needed
424432
self.transports.insert(source, transport.clone());
425433
self.session_store.cache_session(source)?;
434+
if let Some(name) = self.pending_session_name.take() {
435+
self.session_store.set_session_name(&source, name)?;
436+
}
426437
self.session_store
427438
.save_transport_state(&source, transport)?;
428439

@@ -456,6 +467,9 @@ impl UserClient {
456467
self.transports
457468
.insert(pending.source, pending.transport.clone());
458469
self.session_store.cache_session(pending.source)?;
470+
if let Some(name) = self.pending_session_name.take() {
471+
self.session_store.set_session_name(&pending.source, name)?;
472+
}
459473
self.session_store
460474
.save_transport_state(&pending.source, pending.transport)?;
461475

@@ -683,4 +697,9 @@ impl UserClient {
683697
pub fn rendezvous_code(&self) -> Option<&RendevouzCode> {
684698
self.rendezvous_code.as_ref()
685699
}
700+
701+
/// Set a friendly name to assign to the next newly-paired session
702+
pub fn set_pending_session_name(&mut self, name: String) {
703+
self.pending_session_name = Some(name);
704+
}
686705
}

crates/bw-rat-client/src/traits.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ pub trait SessionStore: Send + Sync {
3030
/// Returns tuples of (fingerprint, optional_name, created_timestamp, last_connected_timestamp)
3131
fn list_sessions(&self) -> Vec<(IdentityFingerprint, Option<String>, u64, u64)>;
3232

33+
/// Set a friendly name for a cached session
34+
fn set_session_name(
35+
&mut self,
36+
fingerprint: &IdentityFingerprint,
37+
name: String,
38+
) -> Result<(), RemoteClientError>;
39+
3340
/// Update the last_connected_at timestamp for a session
3441
fn update_last_connected(
3542
&mut self,

crates/bw-rat-client/tests/pairing.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ impl SessionStore for MockSessionStore {
129129
.collect()
130130
}
131131

132+
fn set_session_name(
133+
&mut self,
134+
_fingerprint: &IdentityFingerprint,
135+
_name: String,
136+
) -> Result<(), bw_rat_client::RemoteClientError> {
137+
Ok(())
138+
}
139+
132140
fn update_last_connected(
133141
&mut self,
134142
fingerprint: &IdentityFingerprint,

crates/bw-rat-client/tests/websocket_proxy.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ impl SessionStore for MockSessionStore {
135135
.collect()
136136
}
137137

138+
fn set_session_name(
139+
&mut self,
140+
_fingerprint: &IdentityFingerprint,
141+
_name: String,
142+
) -> Result<(), bw_rat_client::RemoteClientError> {
143+
Ok(())
144+
}
145+
138146
fn update_last_connected(
139147
&mut self,
140148
fingerprint: &IdentityFingerprint,
@@ -216,6 +224,17 @@ impl SessionStore for SharedSessionStore {
216224
.list_sessions()
217225
}
218226

227+
fn set_session_name(
228+
&mut self,
229+
fingerprint: &IdentityFingerprint,
230+
name: String,
231+
) -> Result<(), bw_rat_client::RemoteClientError> {
232+
self.0
233+
.lock()
234+
.expect("Lock should not be poisoned")
235+
.set_session_name(fingerprint, name)
236+
}
237+
219238
fn update_last_connected(
220239
&mut self,
221240
fingerprint: &IdentityFingerprint,
@@ -1042,6 +1061,17 @@ async fn test_e2e_transport_state_persistence() {
10421061
.list_sessions()
10431062
}
10441063

1064+
fn set_session_name(
1065+
&mut self,
1066+
fingerprint: &IdentityFingerprint,
1067+
name: String,
1068+
) -> Result<(), bw_rat_client::RemoteClientError> {
1069+
self.0
1070+
.lock()
1071+
.expect("Lock should not be poisoned")
1072+
.set_session_name(fingerprint, name)
1073+
}
1074+
10451075
fn update_last_connected(
10461076
&mut self,
10471077
fingerprint: &IdentityFingerprint,

crates/bw-remote/src/command/cache.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,14 @@ impl ListCacheArgs {
4343
// Sort by last_connected descending (most recent first)
4444
sessions.sort_by(|a, b| b.3.cmp(&a.3));
4545

46-
for (fingerprint, _name, _cached_at, last_connected) in &sessions {
46+
for (fingerprint, name, _cached_at, last_connected) in &sessions {
4747
let hex = hex::encode(fingerprint.0);
4848
let relative = format_relative_time(*last_connected);
49-
println!("{hex} (last used: {relative})");
49+
if let Some(name) = name {
50+
println!("{name} {hex} (last used: {relative})");
51+
} else {
52+
println!("{hex} (last used: {relative})");
53+
}
5054
}
5155

5256
Ok(())

crates/bw-remote/src/command/listen.rs

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ enum Phase {
6060
},
6161
}
6262

63-
/// Whether the event loop exited normally or because `/new` was requested.
63+
/// Whether the event loop exited normally or because `/pair` was requested.
6464
enum EventLoopExit {
6565
Quit,
66-
NewSession,
66+
NewSession { name: Option<String> },
6767
}
6868

6969
/// Bitwarden CLI login item structure
@@ -219,6 +219,13 @@ fn check_bw_status() -> BwStatus {
219219

220220
type SessionInfo = (IdentityFingerprint, Option<String>, u64, u64);
221221

222+
/// Reload the session list from disk (the client may have updated it).
223+
fn reload_sessions() -> Vec<SessionInfo> {
224+
FileSessionCache::load_or_create("user_client")
225+
.map(|cache| cache.list_sessions())
226+
.unwrap_or_default()
227+
}
228+
222229
/// Build session info messages for display in the TUI.
223230
fn session_info_messages(sessions: &[SessionInfo], pending_label: Option<&str>) -> Vec<Message> {
224231
let mut sorted = sessions.to_vec();
@@ -233,29 +240,38 @@ fn session_info_messages(sessions: &[SessionInfo], pending_label: Option<&str>)
233240
.add_modifier(Modifier::BOLD),
234241
)],
235242
)];
236-
for (fingerprint, _, cached_at, last_connected_at) in &sorted {
243+
for (fingerprint, name, cached_at, last_connected_at) in &sorted {
237244
let short_hex = hex::encode(fingerprint.0)
238245
.chars()
239246
.take(12)
240247
.collect::<String>();
241248
let created = format_relative_time(*cached_at);
242249
let last_used = format_relative_time(*last_connected_at);
243-
msgs.push(Message::rich(
244-
MessageKind::Info,
245-
vec![
246-
Span::raw(" "),
247-
Span::styled(
248-
short_hex,
249-
Style::default()
250-
.fg(Color::Cyan)
251-
.add_modifier(Modifier::BOLD),
252-
),
253-
Span::styled(
254-
format!(" created {created}, last used {last_used}"),
255-
Style::default().fg(Color::DarkGray),
256-
),
257-
],
250+
let mut spans = vec![Span::raw(" ")];
251+
if let Some(name) = name {
252+
spans.push(Span::styled(
253+
name.clone(),
254+
Style::default()
255+
.fg(Color::Yellow)
256+
.add_modifier(Modifier::BOLD),
257+
));
258+
spans.push(Span::styled(
259+
format!(" ({short_hex})"),
260+
Style::default().fg(Color::DarkGray),
261+
));
262+
} else {
263+
spans.push(Span::styled(
264+
short_hex,
265+
Style::default()
266+
.fg(Color::Cyan)
267+
.add_modifier(Modifier::BOLD),
268+
));
269+
}
270+
spans.push(Span::styled(
271+
format!(" created {created}, last used {last_used}"),
272+
Style::default().fg(Color::DarkGray),
258273
));
274+
msgs.push(Message::rich(MessageKind::Info, spans));
259275
}
260276
if let Some(label) = pending_label {
261277
msgs.push(Message::new(MessageKind::Info, format!(" {label}")));
@@ -266,7 +282,8 @@ fn session_info_messages(sessions: &[SessionInfo], pending_label: Option<&str>)
266282
/// Set up the idle-mode footer for the TUI.
267283
fn idle_footer() -> Line<'static> {
268284
Line::from(vec![
269-
Span::styled(" /new", Style::default().fg(Color::Cyan)),
285+
Span::styled(" /pair", Style::default().fg(Color::Cyan)),
286+
Span::styled(" [name]", Style::default().fg(Color::DarkGray)),
270287
Span::raw(" create session "),
271288
Span::styled("/exit", Style::default().fg(Color::Cyan)),
272289
Span::raw(" quit "),
@@ -280,7 +297,7 @@ fn idle_footer() -> Line<'static> {
280297
/// in a single `select!` loop. The `client_handle` is aborted on exit.
281298
///
282299
/// The TUI state (`app`, `term`, `reader`) is owned by the caller so that
283-
/// it survives across `/new` session restarts without flickering.
300+
/// it survives across `/pair` session restarts without flickering.
284301
async fn run_event_loop(
285302
app: &mut App,
286303
term: &mut ratatui::DefaultTerminal,
@@ -296,7 +313,7 @@ async fn run_event_loop(
296313
app.set_mode(Mode::TextInput);
297314
app.set_session_panel(session_info_messages(sessions, None));
298315
app.footer = idle_footer();
299-
app.commands = &["/new", "/exit"];
316+
app.commands = &["/pair [name]", "/exit"];
300317

301318
let mut tick_interval = tokio::time::interval(std::time::Duration::from_millis(150));
302319

@@ -315,8 +332,11 @@ async fn run_event_loop(
315332
if let Some(action) = app.handle_key(key) {
316333
match (&phase, &action) {
317334
// Idle commands
318-
(Phase::Idle, AppAction::Submit(s)) if s == "/new" => {
319-
break EventLoopExit::NewSession;
335+
(Phase::Idle, AppAction::Submit(s)) if s.starts_with("/pair") => {
336+
let name = s.strip_prefix("/pair ")
337+
.map(|n| n.trim().to_string())
338+
.filter(|n| !n.is_empty());
339+
break EventLoopExit::NewSession { name };
320340
}
321341
(Phase::Idle, AppAction::Submit(s)) if s == "/exit" => {
322342
break EventLoopExit::Quit;
@@ -337,7 +357,7 @@ async fn run_event_loop(
337357
phase = Phase::Idle;
338358
app.set_mode(Mode::TextInput);
339359
app.footer = idle_footer();
340-
app.commands = &["/new", "/exit"];
360+
app.commands = &["/pair [name]", "/exit"];
341361
}
342362

343363
// Credential approval
@@ -371,7 +391,7 @@ async fn run_event_loop(
371391
}
372392
app.set_mode(Mode::TextInput);
373393
app.footer = idle_footer();
374-
app.commands = &["/new", "/exit"];
394+
app.commands = &["/pair [name]", "/exit"];
375395
}
376396

377397
(_, AppAction::Quit) => break EventLoopExit::Quit,
@@ -462,9 +482,11 @@ async fn run_event_loop(
462482
// Update session panel with pending label
463483
app.set_session_panel(session_info_messages(sessions, Some("New session (awaiting connection)")));
464484
}
465-
UserClientEvent::SessionRefreshed { .. } => {
466-
// Known device reconnected — clear pending label
467-
app.set_session_panel(session_info_messages(sessions, None));
485+
UserClientEvent::SessionRefreshed { .. }
486+
| UserClientEvent::FingerprintVerified { .. } => {
487+
// Session store was updated — reload from disk
488+
let fresh = reload_sessions();
489+
app.set_session_panel(session_info_messages(&fresh, None));
468490
}
469491
_ => {}
470492
}
@@ -491,10 +513,11 @@ async fn run_user_client_session(proxy_url: String, psk_mode: bool) -> Result<()
491513
local
492514
.run_until(async move {
493515
// First iteration: if cached sessions exist, listen on those immediately.
494-
// On `/new`, we loop back and start a fresh rendezvous/psk session.
516+
// On `/pair`, we loop back and start a fresh rendezvous/psk session.
495517
let mut force_new_session = false;
518+
let mut pending_session_name: Option<String> = None;
496519

497-
// Create TUI state once — it survives across `/new` restarts.
520+
// Create TUI state once — it survives across `/pair` restarts.
498521
let mut app = App::new();
499522
app.client_label = "User client";
500523
let bw_status = check_bw_status();
@@ -506,7 +529,8 @@ async fn run_user_client_session(proxy_url: String, psk_mode: bool) -> Result<()
506529
loop {
507530
let identity_provider =
508531
Box::new(FileIdentityStorage::load_or_generate("user_client")?);
509-
let session_store = Box::new(FileSessionCache::load_or_create("user_client")?);
532+
let session_cache = FileSessionCache::load_or_create("user_client")?;
533+
let session_store = Box::new(session_cache);
510534
let cached_sessions = session_store.list_sessions();
511535

512536
let has_cached = !cached_sessions.is_empty() && !force_new_session;
@@ -521,6 +545,7 @@ async fn run_user_client_session(proxy_url: String, psk_mode: bool) -> Result<()
521545

522546
let sessions = session_store.list_sessions();
523547

548+
let session_name = pending_session_name.take();
524549
let client_handle = tokio::task::spawn_local(async move {
525550
let mut client = UserClient::listen(
526551
identity_provider as Box<dyn IdentityProvider>,
@@ -529,6 +554,10 @@ async fn run_user_client_session(proxy_url: String, psk_mode: bool) -> Result<()
529554
)
530555
.await?;
531556

557+
if let Some(name) = session_name {
558+
client.set_pending_session_name(name);
559+
}
560+
532561
if has_cached {
533562
client.listen_cached_only(event_tx, response_rx).await
534563
} else if psk_mode {
@@ -549,8 +578,9 @@ async fn run_user_client_session(proxy_url: String, psk_mode: bool) -> Result<()
549578
)
550579
.await?
551580
{
552-
EventLoopExit::NewSession => {
581+
EventLoopExit::NewSession { name } => {
553582
force_new_session = true;
583+
pending_session_name = name;
554584
continue;
555585
}
556586
EventLoopExit::Quit => break,

0 commit comments

Comments
 (0)