diff --git a/app/src-tauri/src/marketplace/gh_login.rs b/app/src-tauri/src/marketplace/gh_login.rs index 22ca482..5ff7de9 100644 --- a/app/src-tauri/src/marketplace/gh_login.rs +++ b/app/src-tauri/src/marketplace/gh_login.rs @@ -72,17 +72,29 @@ pub fn strip_ansi(s: &str) -> String { AnsiStripper::default().push(s.as_bytes()) } -/// gh prints `! First copy your one-time code: XXXX-XXXX`, then either a URL -/// or "Press Enter to open in your browser". Returns (code, url). +/// Read gh's device code and URL. Returns (code, url). +/// +/// Two wordings are known: +/// * older gh: `! First copy your one-time code: XXXX-XXXX`, then either a +/// URL or "Press Enter to open in your browser"; +/// * gh 2.101 (the image's): `! One-time code (XXXX-XXXX) copied to +/// clipboard`, then "Press Enter to open https:///login/device in your +/// browser...". +/// +/// The URL is the first `https://…/login/device` word, else +/// `https:///login/device`. pub fn parse_device_prompt(output: &str, host: &str) -> Option<(String, String)> { - const LABEL: &str = "one-time code:"; - let at = output.find(LABEL)? + LABEL.len(); - let code: String = output[at..] - .trim_start() + const LABEL: &str = "one-time code"; + // ASCII lowercasing keeps byte offsets, so `at` indexes `output` too. + let at = output.to_ascii_lowercase().find(LABEL)? + LABEL.len(); + let rest = output[at..].trim_start_matches(|c: char| c == ':' || c == '(' || c.is_whitespace()); + let code: String = rest .chars() .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') .collect(); - if code.len() < 6 || !code.contains('-') { + // Something must follow the code (`)` or a line break): a code at the + // very end may still be growing in the next frame. + if code.len() < 6 || !code.contains('-') || rest.len() == code.len() { return None; } let url = output @@ -334,6 +346,54 @@ mod tests { ); } + /// gh 2.101.0 (the image's gh, integration report check 6), after ANSI + /// stripping: the code is in parentheses and the URL is on the Enter line. + const GH_2_101_PROMPT: &str = "! One-time code (4F2A-9C1B) copied to clipboard\nPress Enter to open https://github.com/login/device in your browser... "; + + #[test] + fn the_gh_2_101_wording_is_read() { + assert_eq!( + parse_device_prompt(GH_2_101_PROMPT, "github.com"), + Some(( + "4F2A-9C1B".to_string(), + "https://github.com/login/device".to_string() + )) + ); + } + + #[test] + fn the_url_comes_from_the_press_enter_line() { + let out = "! One-time code (AB12-CD34) copied to clipboard\nPress Enter to open https://ghe.example.com/login/device in your browser... "; + assert_eq!( + parse_device_prompt(out, "github.com"), + Some(( + "AB12-CD34".to_string(), + "https://ghe.example.com/login/device".to_string() + )) + ); + } + + #[test] + fn the_gh_2_101_wording_without_a_code_is_no_prompt() { + assert_eq!(parse_device_prompt("! One-time code (", "github.com"), None); + assert_eq!( + parse_device_prompt("! One-time code (4F2A", "github.com"), + None + ); + } + + #[test] + fn a_code_cut_by_a_frame_boundary_is_not_a_code_yet() { + assert_eq!( + parse_device_prompt("! One-time code (4F2A-9C", "github.com"), + None + ); + assert_eq!( + parse_device_prompt("! First copy your one-time code: 4F2A-9C", "github.com"), + None + ); + } + #[test] fn no_code_yet_means_no_prompt() { assert_eq!( @@ -561,6 +621,31 @@ mod tests { } } + /// The raw bytes gh 2.101.0 prints under a tty (integration report + /// check 6), with a fake code: the code event goes out and Enter is + /// pressed, or gh never starts polling. + #[tokio::test(start_paused = true)] + async fn gh_2_101_gets_its_code_event_and_its_enter() { + let keys = Keys::default(); + let (_tx, mut cancel) = oneshot::channel(); + let frames = stream::iter(vec![ + out("\u{1b}]11;?\u{1b}\\\u{1b}[6n"), + out("\r\n"), + out("\u{1b}]52;c;NEYyQS05QzFC\u{7}\u{1b}[0;33m!\u{1b}[0m One-time code (\u{1b}[0;1;39m4F2A-9C1B\u{1b}[0m) copied to clipboard\r\n\u{1b}[0;1;39mPress Enter\u{1b}[0m to open https://github.com/login/device in your browser... "), + ]); + let (r, events) = drive(frames, keys.clone(), &mut cancel, far()).await; + assert!(r.is_ok()); + assert_eq!(*keys.writes.lock().unwrap(), vec![b"\r".to_vec()]); + assert!(events.contains(&( + CODE_EVENT, + serde_json::json!({ + "account_id": "acct-1", + "code": "4F2A-9C1B", + "url": "https://github.com/login/device" + }) + ))); + } + #[tokio::test] async fn a_lost_stream_is_a_failure() { let (_tx, mut cancel) = oneshot::channel();