//! Library sync with an mtgc sync server: signing in, and running the cycle. //! //! The reconciliation lives in [`mtgc_core::sync::remote`], as a cycle over a //! [`Remote`](mtgc_core::sync::remote::Remote) that is tested without any //! network. This module is the plumbing around it: where the merge base is //! kept, holding the state lock while the working copy is rewritten (and only //! then), and the platform-specific half of signing in. //! //! The library never depends on the server being reachable. The working copy //! is the library on this device; every edit lands there, and a sync is just a //! chance to exchange edits with the other devices. A failed sync loses //! nothing and is retried on the next trigger. //! //! # Signing in //! //! The browser does the signing in, on the server's own pages, and hands back //! a single-use code that this app redeems with its PKCE verifier. Getting the //! code back differs by platform: //! //! - desktop listens on a one-shot loopback port (RFC 8252 §7.3); //! - Android registers the `mtgc://` scheme, and the deep-link plugin delivers //! the callback to [`deliver_callback`]. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use mtgc_core::sync::account::{self, AccountUser, DeviceInfo, Pkce}; use mtgc_core::sync::remote::{ first_sync_plan, sync_cycle, AccountBase, FirstSync, HttpRemote, SyncBase, UNAUTHORIZED, }; use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport}; use serde::Serialize; use tauri::{AppHandle, Manager}; use crate::state::{AppState, SyncAccount}; /// How long to wait for the user to finish signing in in the browser. const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(10 * 60); fn app_state(app: &AppHandle) -> tauri::State<'_, Mutex> { app.state::>() } fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> { // Every holder leaves the state consistent between statements, so a panic // elsewhere is no reason to stop syncing. app_state(app).inner().lock().unwrap_or_else(|e| e.into_inner()) } /// Run blocking work (network, SQLite) off the main thread. Tauri runs /// non-async commands on the main thread, where a slow server would freeze /// the whole UI. async fn blocking( f: impl FnOnce() -> Result + Send + 'static, ) -> Result { tauri::async_runtime::spawn_blocking(f) .await .map_err(|e| e.to_string())? } // --- the merge base ----------------------------------------------------------- /// A new name rather than the folder-sync era's `sync-base.json`, whose /// contents mean nothing to a server: an old file is simply never read. fn base_path(data_dir: &Path) -> PathBuf { data_dir.join("sync-server-base.json") } /// The stored base, if it belongs to this server and account. Missing, /// unreadable, or someone else's all mean the same thing: no ancestor, which /// makes the next sync a first sync. fn read_base(data_dir: &Path, account: &SyncAccount) -> Option { let text = std::fs::read_to_string(base_path(data_dir)).ok()?; serde_json::from_str::(&text) .ok()? .for_account(&account.server_url, &account.user.id) } fn write_base(data_dir: &Path, account: &SyncAccount, base: &SyncBase) { let stored = AccountBase { server_url: account.server_url.clone(), user_id: account.user.id.clone(), base: base.clone(), }; let path = base_path(data_dir); let tmp = path.with_extension("json.tmp"); let result = serde_json::to_string(&stored) .map_err(|e| e.to_string()) .and_then(|t| std::fs::write(&tmp, t).map_err(|e| e.to_string())) .and_then(|_| std::fs::rename(&tmp, &path).map_err(|e| e.to_string())); if let Err(e) = result { // Not fatal: without a base the next sync is a first sync, which can // ask a question but never deletes anything. crate::mlog!("sync: couldn't save the merge base: {e}"); } } /// The working copy as a document. fn local_doc(working: &Path) -> Result { let conn = mtgc_core::db::open_library(working).map_err(|e| e.to_string())?; core_sync::read_doc(&conn).map_err(|e| e.to_string()) } // --- status ------------------------------------------------------------------- #[derive(Serialize, Default)] pub struct SyncStatus { pub signed_in: bool, /// Kept after signing out, to offer signing back in. pub server_url: Option, pub account: Option, pub last_synced_at: Option, pub last_error: Option, /// The first sync is waiting on the user: see [`sync_connect`]. pub needs_choice: bool, /// Edits made here that the server hasn't acknowledged yet. pub pending_changes: bool, /// A sign-in is waiting on the browser. pub signing_in: bool, /// A pre-document `library.db` waiting to be imported, if any. pub legacy_library: Option, } fn status(app: &AppHandle) -> SyncStatus { let s = lock(app); let account = s.sync_account().cloned(); let pending_changes = account .as_ref() .filter(|a| a.token.is_some()) .map(|a| match read_base(s.data_dir(), a) { Some(base) => local_doc(&s.working_db_path).map(|d| d != base.doc).unwrap_or(false), // Never synced with this account: anything here is unsent. None => local_doc(&s.working_db_path).map(|d| !d.is_empty()).unwrap_or(false), }) .unwrap_or(false); SyncStatus { signed_in: account.as_ref().is_some_and(|a| a.token.is_some()), server_url: account.as_ref().map(|a| a.server_url.clone()), last_synced_at: account.as_ref().and_then(|a| a.last_synced_at), account: account.map(|a| a.user), last_error: s.sync_error.clone(), needs_choice: s.sync_needs_choice, pending_changes, signing_in: s.sign_in_cancel.is_some(), legacy_library: s.legacy_library_path().map(|p| p.display().to_string()), } } #[tauri::command] pub async fn sync_status(app: AppHandle) -> Result { blocking(move || Ok(status(&app))).await } // --- signing in ------------------------------------------------------------------- /// A name for this device in the account's device list. fn default_device_name() -> String { #[cfg(target_os = "android")] { "Android".to_string() } #[cfg(not(target_os = "android"))] { let host = std::env::var("COMPUTERNAME") .ok() .or_else(|| std::fs::read_to_string("/etc/hostname").ok()) .map(|h| h.trim().to_string()) .filter(|h| !h.is_empty()); match host { Some(h) => h, None => std::env::consts::OS.to_string(), } } } /// Sign in to a sync server through the browser, then sync. /// /// Resolves once the user finishes (or abandons) signing in. Any first-sync /// question is reported through [`SyncStatus::needs_choice`]. #[tauri::command] pub async fn sync_sign_in( app: AppHandle, server_url: String, device_name: Option, ) -> Result { let server_url = account::normalize_server_url(&server_url); let cancel = Arc::new(AtomicBool::new(false)); { let mut s = lock(&app); if s.sign_in_cancel.is_some() { return Err("A sign-in is already in progress.".into()); } s.sign_in_cancel = Some(cancel.clone()); } let app2 = app.clone(); let result = blocking(move || { let device_name = device_name .map(|n| n.trim().to_string()) .filter(|n| !n.is_empty()) .unwrap_or_else(default_device_name); sign_in_flow(&app2, &server_url, &device_name, &cancel) }) .await; lock(&app).sign_in_cancel = None; let signed_in = crate::logged("sync_sign_in", result)?; { let mut s = lock(&app); let previous = s.sync_account().cloned(); let switching = previous .as_ref() .is_some_and(|p| p.server_url != signed_in.0 || p.user.id != signed_in.1.user.id); s.set_sync_account(Some(SyncAccount { server_url: signed_in.0.clone(), token: Some(signed_in.1.token), user: signed_in.1.user, device_id: signed_in.1.device_id, last_synced_at: if switching { None } else { previous.and_then(|p| p.last_synced_at) }, })); s.sync_error = None; s.sync_needs_choice = false; } // Sync straight away so the library arrives (or the question is asked) // without waiting for the next trigger. A failure here is recorded in the // status rather than failing a sign-in that did succeed. let _ = sync_now(app.clone()).await; blocking(move || Ok(status(&app))).await } /// Abandon a sign-in that's waiting on the browser. #[tauri::command] pub fn sync_cancel_sign_in(app: AppHandle) { if let Some(c) = lock(&app).sign_in_cancel.as_ref() { c.store(true, Ordering::SeqCst); } } /// Returns (server URL, the completed sign-in). fn sign_in_flow( app: &AppHandle, server_url: &str, device_name: &str, cancel: &AtomicBool, ) -> Result<(String, account::SignedIn), String> { let info = account::server_info(server_url).map_err(|e| e.to_string())?; if !info.local_auth && info.oidc.is_none() { return Err("That server has no way to sign in configured.".into()); } let pkce = Pkce::new(); let state = account::random_token(); let callback = Callback::listen()?; let url = account::start_url(server_url, &callback.redirect_uri(), &pkce, &state, device_name); use tauri_plugin_opener::OpenerExt; app.opener() .open_url(&url, None::<&str>) .map_err(|e| format!("Couldn't open the browser: {e}"))?; let params = callback.wait(cancel, Instant::now() + SIGN_IN_TIMEOUT)?; let get = |k: &str| params.iter().find(|(name, _)| name == k).map(|(_, v)| v.clone()); if get("state").as_deref() != Some(state.as_str()) { return Err("The sign-in response didn't match this sign-in. Try again.".into()); } if let Some(err) = get("error") { return Err(match err.as_str() { "access_denied" => "Sign-in was declined.".into(), other => format!("Sign-in failed: {other}"), }); } let code = get("code").ok_or("The sign-in response had no code. Try again.")?; let signed_in = account::redeem_code(server_url, &code, &pkce.verifier).map_err(|e| e.to_string())?; crate::mlog!( "sync_sign_in: signed in to {server_url} as {} ({})", signed_in.user.username, signed_in.user.method ); Ok((server_url.to_string(), signed_in)) } type Params = Vec<(String, String)>; /// Where the browser hands the sign-in back. #[cfg(not(target_os = "android"))] struct Callback { listener: std::net::TcpListener, } #[cfg(not(target_os = "android"))] impl Callback { fn listen() -> Result { // Port 0: any free port. The server accepts any loopback port, and a // fixed one could already be taken. let listener = std::net::TcpListener::bind("127.0.0.1:0") .map_err(|e| format!("Couldn't open a port for the sign-in to return to: {e}"))?; listener.set_nonblocking(true).map_err(|e| e.to_string())?; Ok(Self { listener }) } fn redirect_uri(&self) -> String { let port = self.listener.local_addr().map(|a| a.port()).unwrap_or(0); format!("http://127.0.0.1:{port}/callback") } fn wait(self, cancel: &AtomicBool, deadline: Instant) -> Result { loop { if cancel.load(Ordering::SeqCst) { return Err("Sign-in cancelled.".into()); } if Instant::now() > deadline { return Err("Sign-in timed out. Try again.".into()); } match self.listener.accept() { Ok((stream, _)) => { // Browsers also ask for /favicon.ico and the like; answer // and keep waiting for the real callback. if let Some(params) = loopback::handle(stream) { return Ok(params); } } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(100)); } Err(e) => return Err(format!("Sign-in listener failed: {e}")), } } } } /// The desktop's one-request HTTP server, free-standing so it's testable. #[cfg(not(target_os = "android"))] mod loopback { use std::io::{Read, Write}; use std::net::TcpStream; use std::time::Duration; use super::Params; const DONE_PAGE: &str = "\ mtgc\

You're signed in. You can close this tab and return to mtgc.

"; /// Answer one connection. Returns the query parameters when it was the /// sign-in callback. pub fn handle(mut stream: TcpStream) -> Option { let _ = stream.set_nonblocking(false); let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); let mut buf = Vec::new(); let mut chunk = [0u8; 2048]; // Only the request line matters; stop at the end of the headers. while !buf.windows(4).any(|w| w == b"\r\n\r\n") && buf.len() < 16 * 1024 { match stream.read(&mut chunk) { Ok(0) | Err(_) => break, Ok(n) => buf.extend_from_slice(&chunk[..n]), } } let head = String::from_utf8_lossy(&buf); let params = parse_request_line(head.lines().next().unwrap_or_default()); let (status, body) = match ¶ms { Some(_) => ("200 OK", DONE_PAGE), None => ("404 Not Found", ""), }; let _ = write!( stream, "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\ Cache-Control: no-store\r\nConnection: close\r\n\r\n{body}", body.len() ); params } /// `GET /callback?code=…&state=… HTTP/1.1` → the query parameters. pub fn parse_request_line(line: &str) -> Option { let mut parts = line.split_whitespace(); if parts.next()? != "GET" { return None; } let target = parts.next()?; let (path, query) = target.split_once('?').unwrap_or((target, "")); if path != "/callback" { return None; } Some(url::form_urlencoded::parse(query.as_bytes()).into_owned().collect()) } } /// Android: the server sends the browser to `mtgc://auth/callback`, which the /// deep-link plugin hands to [`deliver_callback`]. #[cfg(target_os = "android")] struct Callback { rx: std::sync::mpsc::Receiver, } #[cfg(target_os = "android")] static WAITING: Mutex>> = Mutex::new(None); /// Called with every `mtgc://` URL the app is opened with. #[cfg(target_os = "android")] pub fn deliver_callback(url: &str) { if !url.starts_with("mtgc://auth/callback") { return; } match WAITING.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { Some(tx) => { let _ = tx.send(url.to_string()); } // The app was restarted while the browser was open, so the verifier // this callback needs is gone. The user just signs in again. None => crate::mlog!("sync: ignoring a sign-in callback nothing is waiting for"), } } #[cfg(target_os = "android")] impl Callback { fn listen() -> Result { let (tx, rx) = std::sync::mpsc::channel(); *WAITING.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx); Ok(Self { rx }) } fn redirect_uri(&self) -> String { "mtgc://auth/callback".into() } fn wait(self, cancel: &AtomicBool, deadline: Instant) -> Result { let result = loop { if cancel.load(Ordering::SeqCst) { break Err("Sign-in cancelled.".into()); } if Instant::now() > deadline { break Err("Sign-in timed out. Try again.".into()); } match self.rx.recv_timeout(Duration::from_millis(250)) { Ok(url) => { let query = url.split_once('?').map(|(_, q)| q).unwrap_or_default(); break Ok(url::form_urlencoded::parse(query.as_bytes()).into_owned().collect()); } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(e) => break Err(e.to_string()), } }; *WAITING.lock().unwrap_or_else(|e| e.into_inner()) = None; result } } /// Sign this device out. The library stays here, and keeps working offline. #[tauri::command] pub async fn sync_sign_out(app: AppHandle) -> Result { blocking(move || { let account = lock(&app).sync_account().cloned(); if let Some(a) = account { if let Some(token) = &a.token { // Best effort: offline, the token stays valid on the server // until revoked from another device, but this one forgets it. if let Err(e) = HttpRemote::new(&a.server_url, token).logout() { crate::mlog!("sync_sign_out: server logout failed: {e}"); } } } let mut s = lock(&app); s.update_sync_account(|a| a.token = None); s.sync_error = None; s.sync_needs_choice = false; drop(s); Ok(status(&app)) }) .await } // --- syncing -------------------------------------------------------------------- /// Outcome of one sync. #[derive(Serialize, Default)] pub struct SyncReport { /// Whether this device's library changed. pub changed: bool, pub published: bool, /// The first sync is waiting on the user: see [`sync_connect`]. pub needs_choice: bool, pub report: MergeReport, } /// Reconcile the working copy with the server. /// /// `Ok(None)` when there is nothing to do — not signed in — or a sync was /// already in flight and this call stood down. Sync runs automatically on /// launch, on reconnecting, and on every foreground/background transition, so /// overlap is routine rather than exceptional. #[tauri::command] pub async fn sync_now(app: AppHandle) -> Result, String> { run_sync(app, None).await } /// Answer the first-sync question: combine this device's library with the /// server's, or replace it with the server's. #[tauri::command] pub async fn sync_connect(app: AppHandle, choice: FirstSync) -> Result, String> { run_sync(app, Some(choice)).await } async fn run_sync(app: AppHandle, choice: Option) -> Result, String> { { let mut s = lock(&app); if s.sync_running || s.sync_account().and_then(|a| a.token.as_ref()).is_none() { return Ok(None); } s.sync_running = true; } let app2 = app.clone(); let result = blocking(move || sync_inner(&app2, choice)).await; let mut s = lock(&app); s.sync_running = false; match &result { Ok(r) => { s.sync_error = None; s.sync_needs_choice = r.needs_choice; if !r.needs_choice { s.update_sync_account(|a| a.last_synced_at = Some(unix_now())); } } Err(e) if e == UNAUTHORIZED => { // Revoked from another device, or the account was removed. // Retrying can't help, so stop trying until the user signs in. s.update_sync_account(|a| a.token = None); s.sync_error = Some(e.clone()); } Err(e) => s.sync_error = Some(e.clone()), } drop(s); crate::logged("sync_now", result).map(Some) } fn sync_inner(app: &AppHandle, choice: Option) -> Result { let (account, data_dir, working) = { let s = lock(app); let account = s.sync_account().cloned().ok_or("Not signed in.")?; (account, s.data_dir().to_path_buf(), s.working_db_path.clone()) }; let token = account.token.as_deref().ok_or("Not signed in.")?; let remote = HttpRemote::new(&account.server_url, token); let mut base = match read_base(&data_dir, &account) { Some(base) => base, None => { // Never synced with this account's library from here, so there is // no ancestor to tell edits from deletions with. use mtgc_core::sync::remote::Remote; let theirs = remote.get().map_err(|e| e.to_string())?; let ours = { let _s = lock(app); local_doc(&working)? }; match first_sync_plan(&ours, &theirs.doc, choice) { None => return Ok(SyncReport { needs_choice: true, ..Default::default() }), // An empty ancestor: nothing on either side reads as deleted. Some(FirstSync::Combine) => SyncBase::default(), Some(FirstSync::UseServer) => { let _s = lock(app); let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?; core_sync::apply_doc(&conn, &theirs.doc).map_err(|e| e.to_string())?; SyncBase { version: theirs.version, doc: theirs.doc } } } } }; // The state lock is held only while the working copy is read, merged and // rewritten — every edit command takes the same lock, so none can land in // between and be overwritten. The network calls happen without it. let result = sync_cycle(&remote, &mut base, |base_doc, theirs| { let _s = lock(app); let conn = mtgc_core::db::open_library(&working)?; core_sync::merge_into(&conn, base_doc, theirs) }); // Saved whether or not the cycle finished: it only ever records what is // known to be on the server, so a partial run's base is still sound. write_base(&data_dir, &account, &base); let cycle = result.map_err(|e| e.to_string())?; crate::mlog!( "sync_now: changed={} published={} version={} conflicts={}", !cycle.report.is_empty(), cycle.published, cycle.version, cycle.report.conflicts.len() ); Ok(SyncReport { changed: !cycle.report.is_empty(), published: cycle.published, needs_choice: false, report: cycle.report, }) } fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } // --- devices ---------------------------------------------------------------------- fn signed_in_remote(app: &AppHandle) -> Result { let s = lock(app); let a = s.sync_account().ok_or("Not signed in.")?; let token = a.token.as_deref().ok_or("Not signed in.")?; Ok(HttpRemote::new(&a.server_url, token)) } /// The account's signed-in devices. #[tauri::command] pub async fn sync_devices(app: AppHandle) -> Result, String> { blocking(move || { let me = signed_in_remote(&app)?.me().map_err(|e| e.to_string())?; Ok(me.devices) }) .await } /// Sign out another of the account's devices — a lost phone, say. #[tauri::command] pub async fn sync_revoke_device(app: AppHandle, id: i64) -> Result, String> { blocking(move || { let remote = signed_in_remote(&app)?; remote.revoke_device(id).map_err(|e| e.to_string())?; Ok(remote.me().map_err(|e| e.to_string())?.devices) }) .await } // --- import and export ---------------------------------------------------------- /// What an import added. #[derive(Serialize)] pub struct ImportReport { pub cards: usize, pub decks: usize, pub report: MergeReport, } /// Merge a library file into this device's library: a JSON library document /// (a backup, or the file a folder-synced build kept), or a `library.db` from /// before documents existed. Nothing already here is lost — it's merged with /// an empty ancestor — and the next sync shares the result. #[tauri::command] pub async fn import_library_file(app: AppHandle, path: String) -> Result { let app2 = app.clone(); let report = blocking(move || { let file = PathBuf::from(&path); if !file.is_file() { return Err(format!("{} isn't there.", file.display())); } let is_json = file.extension().is_some_and(|e| e.eq_ignore_ascii_case("json")); let doc = if is_json { let text = std::fs::read_to_string(&file).map_err(|e| e.to_string())?; LibraryDoc::from_json(&text).map_err(|e| format!("Not a library file: {e}"))? } else { core_sync::convert_legacy_library(&file).map_err(|e| e.to_string())? }; let mut s = lock(&app2); let conn = mtgc_core::db::open_library(&s.working_db_path).map_err(|e| e.to_string())?; let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc); core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?; if s.legacy_library_path() == Some(file.as_path()) { s.clear_legacy_library(); } crate::mlog!( "import_library_file: {} cards, {} decks from {}", doc.cards.len(), doc.decks.len(), file.display() ); Ok(ImportReport { cards: doc.cards.len(), decks: doc.decks.len(), report }) }) .await; let report = crate::logged("import_library_file", report)?; // Share it now rather than at the next trigger. let _ = sync_now(app).await; Ok(report) } /// Save the whole library as a JSON document — a backup that /// [`import_library_file`] reads back. #[tauri::command] pub async fn export_library_document(app: AppHandle, path: String) -> Result<(), String> { blocking(move || { let doc = { let s = lock(&app); local_doc(&s.working_db_path)? }; let text = doc.to_json().map_err(|e| e.to_string())?; std::fs::write(&path, text).map_err(|e| format!("Couldn't save {path}: {e}")) }) .await } #[cfg(test)] mod tests { use super::*; fn account(server: &str, user: &str) -> SyncAccount { SyncAccount { server_url: server.into(), token: Some("t".into()), user: AccountUser { id: user.into(), username: user.into(), display_name: String::new(), email: String::new(), method: "local".into(), }, device_id: 1, last_synced_at: None, } } fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("mtgc-sync-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn a_base_is_only_used_with_the_account_it_came_from() { let dir = scratch("base"); let mine = account("https://a.example", "u1"); let base = SyncBase { version: 4, ..Default::default() }; write_base(&dir, &mine, &base); assert_eq!(read_base(&dir, &mine), Some(base)); assert_eq!(read_base(&dir, &account("https://a.example", "u2")), None); assert_eq!(read_base(&dir, &account("https://b.example", "u1")), None); assert!(!dir.join("sync-server-base.json.tmp").exists()); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn a_folder_sync_era_base_is_never_read() { // That build's file held a bare document under a different name. let dir = scratch("old-base"); std::fs::write(dir.join("sync-base.json"), r#"{"version":1,"cards":{},"decks":{}}"#).unwrap(); assert_eq!(read_base(&dir, &account("https://a.example", "u1")), None); std::fs::remove_dir_all(&dir).unwrap(); } #[cfg(not(target_os = "android"))] #[test] fn the_loopback_listener_takes_only_the_callback() { assert_eq!( loopback::parse_request_line("GET /callback?code=abc%2B1&state=xyz HTTP/1.1"), Some(vec![("code".into(), "abc+1".into()), ("state".into(), "xyz".into())]) ); assert_eq!(loopback::parse_request_line("GET /favicon.ico HTTP/1.1"), None); assert_eq!(loopback::parse_request_line("POST /callback?code=a HTTP/1.1"), None); assert_eq!(loopback::parse_request_line(""), None); } #[cfg(not(target_os = "android"))] #[test] fn the_loopback_listener_answers_a_real_browser_request() { use std::io::{Read, Write}; let cb = Callback::listen().unwrap(); let addr = cb.redirect_uri().replace("http://", "").replace("/callback", ""); let cancel = AtomicBool::new(false); let browser = std::thread::spawn(move || { // A stray request first, as browsers make. let mut s = std::net::TcpStream::connect(&addr).unwrap(); s.write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); let mut out = String::new(); s.read_to_string(&mut out).unwrap(); assert!(out.starts_with("HTTP/1.1 404")); let mut s = std::net::TcpStream::connect(&addr).unwrap(); s.write_all(b"GET /callback?code=c&state=s HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); let mut out = String::new(); s.read_to_string(&mut out).unwrap(); assert!(out.starts_with("HTTP/1.1 200")); assert!(out.contains("signed in")); }); let params = cb.wait(&cancel, Instant::now() + Duration::from_secs(10)).unwrap(); browser.join().unwrap(); assert!(params.contains(&("code".into(), "c".into()))); } #[cfg(not(target_os = "android"))] #[test] fn a_sign_in_can_be_abandoned() { let cb = Callback::listen().unwrap(); let cancel = AtomicBool::new(true); let err = cb.wait(&cancel, Instant::now() + Duration::from_secs(10)).unwrap_err(); assert!(err.contains("cancelled")); } }