Sign in on a phone with no console work, in the browser's own session

Mobile OAuth reused the desktop client all along; what stopped it was the
browser. Sending the user out to Safari or Chrome backgrounds the app, iOS
suspends it, and the redirect carrying the code arrives at a socket nobody
is accepting on. The consent page now opens in front of the app instead, in
SFSafariViewController or a Chrome Custom Tab, so the loopback listener
stays live and the existing `installed` client is enough. Verified against
Google's real consent screen on a simulator and an emulator.

A per-platform client is still supported and is now an upgrade rather than a
prerequisite. On iOS it buys ASWebAuthenticationSession, which shares
Safari's session so nobody is asked to sign in to Google twice. Android
needs nothing: Custom Tabs share Chrome's cookies, measured rather than
assumed. iOS session sharing could not be confirmed on the simulator and
wants a real device.

Never an app-owned WebView: Google blocks it, and rightly, since a webview
the app controls can read the password typed into it.

Cancelling is no longer reported as a failure. AuthEvent carries a
`cancelled` flag, set by comparing against the constant every back-out path
returns, and Google's `access_denied` on desktop counts too.

Five frontend bugs found by driving the real UI, not by reading it: the
details card slid under the tab bar leaving its buttons unhittable; the
ghost click after a touch pressed a button in the card that tap had just
opened, opening the editor by itself; the swipe that pages the day was dead
over every read-only block; 84px of macOS traffic-light lane was reserved on
platforms with no traffic lights; and the desktop header ignored the top
safe area on an iPad. A first launch now says what to do next rather than
showing an empty grid, and accounts are named as Google accounts throughout.
This commit is contained in:
pj committed 2026-08-12 18:32:45 +05:30
1 parent 661100dfdc
commit d4c3a304b5
31 files changed
+1319 -114

No files matched your search

+4
View File
@@ -174,4 +174,8 @@ pub struct AuthEvent {
pub error: Option<String>,
pub account_id: Option<String>,
pub email: Option<String>,
/// The user closed the consent browser rather than anything going wrong. Still `ok: false`,
/// because no account arrived, but changing your mind is not a failure and must not be
/// reported as one.
pub cancelled: bool,
}
+156 -39
View File
@@ -7,12 +7,9 @@
// a different account cannot write against stale remote ids.
use std::collections::HashMap;
#[cfg(desktop)]
use std::io::{Read, Write};
#[cfg(desktop)]
use std::net::{TcpListener, TcpStream};
use std::sync::LazyLock;
#[cfg(desktop)]
use std::time::Instant;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -29,13 +26,20 @@ use crate::dto::{Account, AuthEvent};
use crate::google::secrets;
pub const SCOPES: &str = "openid email https://www.googleapis.com/auth/calendar";
/// How long the listener waits for Google to come back. Two minutes is generous on a desktop, where
/// the browser is a window away and a keyboard is a keyboard.
#[cfg(desktop)]
pub const AUTH_TIMEOUT_SECS: u64 = 120;
/// How long a mobile consent round trip may take. Far longer than the desktop listener's two
/// minutes, because on a phone the browser is a separate app: signing in, a password manager and
/// a 2FA prompt in a third app can all happen between leaving and coming back, and this process
/// is doing nothing but holding a verifier in the meantime.
/// The same wait on a phone, which has to cover an email and a password typed on glass, a password
/// manager round trip, and very often a 2FA prompt in a third app.
#[cfg(mobile)]
pub const AUTH_TIMEOUT_SECS: u64 = 900;
/// The same again for the deep link flow, where the wait is even less this app's to control: the
/// browser is a separate app there, so this process may be backgrounded for all of it while it does
/// nothing but hold a verifier.
#[cfg(mobile)]
pub const PENDING_TIMEOUT_SECS: u64 = 900;
@@ -53,10 +57,14 @@ pub static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
.expect("could not build the HTTP client")
});
/// Google issues a different OAuth client per platform and will not accept one in another's place.
/// A desktop client is confidential and redirects to loopback; an Android or iOS client is public,
/// has no secret at all, and redirects to a custom URI scheme. So the credentials file carries up
/// to three clients and the build picks the one for the platform it is being compiled for.
/// Up to three OAuth clients, of which a build uses exactly one.
///
/// `installed` is a Desktop client: confidential, so it has a secret, and allowed to redirect to
/// loopback on any port without registering it first. `android` and `ios` are public clients with
/// no secret at all, and they redirect to a custom URI scheme instead.
///
/// A phone uses `installed` unless its own block is present, which is a deliberate choice and the
/// reason mobile sign-in needs no console work. `connect` says what the two flows cost.
#[derive(Deserialize)]
struct CredentialsFile {
installed: Credentials,
@@ -81,6 +89,12 @@ pub struct Credentials {
/// Mobile only, and only when Google's console shows something other than the default below.
#[serde(default)]
pub redirect_uri: Option<String>,
/// Set at load time rather than read from the file: true when this client came out of the
/// `android` or `ios` block. It is the only thing that decides which of the two mobile flows
/// runs, so it travels with the client that forces the choice rather than being worked out
/// again wherever the answer is needed.
#[serde(skip)]
pub platform_client: bool,
}
fn default_auth_uri() -> String {
@@ -109,20 +123,27 @@ fn reversed_client_id(client_id: &str) -> String {
const NOT_SET_UP: &str = "Google Calendar is not set up yet. Add a real OAuth client to google-credentials.json and rebuild.";
/// The one client this build signs in with. A missing platform block is not an error: it means the
/// desktop client and the loopback flow, which is what a phone gets until somebody decides
/// otherwise. Every caller has to agree on the answer, because a refresh token belongs to the
/// client that obtained it.
pub fn load_credentials() -> Result<Credentials, String> {
let parsed: CredentialsFile = serde_json::from_str(CREDENTIALS_JSON)
.map_err(|e| format!("invalid google-credentials.json: {e}"))?;
#[cfg(target_os = "android")]
let creds = parsed.android.ok_or(
"google-credentials.json has no \"android\" client. Create an OAuth client of type Android in the Google Cloud console and add it. See docs/mobile.md.",
)?;
let (mut creds, platform_client) = match parsed.android {
Some(creds) => (creds, true),
None => (parsed.installed, false),
};
#[cfg(target_os = "ios")]
let creds = parsed.ios.ok_or(
"google-credentials.json has no \"ios\" client. Create an OAuth client of type iOS in the Google Cloud console and add it. See docs/mobile.md.",
)?;
let (mut creds, platform_client) = match parsed.ios {
Some(creds) => (creds, true),
None => (parsed.installed, false),
};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let creds = parsed.installed;
let (mut creds, platform_client) = (parsed.installed, false);
creds.platform_client = platform_client;
if creds.client_id.starts_with("YOUR_CLIENT_ID")
|| creds
@@ -135,8 +156,9 @@ pub fn load_credentials() -> Result<Credentials, String> {
Ok(creds)
}
/// Where Google sends the browser back to. Desktop binds a loopback port per attempt, so it is
/// decided in `connect` rather than here and this is mobile only.
/// Where Google sends the browser back to on the custom scheme flow. The loopback flow binds a port
/// per attempt and works its own redirect out in `connect_by_loopback`, so this is only for the
/// platforms that have been given their own client.
#[cfg(mobile)]
pub fn redirect_uri(creds: &Credentials) -> String {
if let Some(explicit) = &creds.redirect_uri {
@@ -219,7 +241,6 @@ fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
#[cfg(desktop)]
fn write_http_message(stream: &mut TcpStream, message: &str) {
let body = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Margin Calendar</title></head>\
@@ -235,7 +256,6 @@ fn write_http_message(stream: &mut TcpStream, message: &str) {
let _ = stream.flush();
}
#[cfg(desktop)]
#[derive(Debug, PartialEq, Eq)]
enum Redirect {
Code(String),
@@ -245,7 +265,6 @@ enum Redirect {
Waiting,
}
#[cfg(desktop)]
fn request_path(request: &str) -> &str {
request
.lines()
@@ -254,7 +273,6 @@ fn request_path(request: &str) -> &str {
.unwrap_or("")
}
#[cfg(desktop)]
fn parse_redirect(path: &str, expected_state: &str) -> Redirect {
if path == "/favicon.ico" {
return Redirect::Waiting;
@@ -284,7 +302,14 @@ fn parse_redirect(path: &str, expected_state: &str) -> Redirect {
}
}
#[cfg(desktop)]
/// What the frontend is told when the user backed out rather than finishing: closing the consent
/// browser on a phone, or pressing Cancel on Google's own screen anywhere.
///
/// `emit_auth` compares against this exact string to set `AuthEvent.cancelled`, which is what stops
/// the frontend reporting a change of mind as a failure. So it is a constant on every platform, and
/// every path that means "the user chose not to" must return this rather than wording its own.
const CANCELLED: &str = "Sign-in was cancelled.";
fn await_code(
listener: TcpListener,
expected_state: &str,
@@ -295,6 +320,13 @@ fn await_code(
if Instant::now() > deadline {
return Err("Timed out waiting for Google authorization.".to_string());
}
// Closing the consent page is the mobile equivalent of closing the browser tab, and the
// one abandonment the OS actually tells us about. Polled here rather than interrupting the
// loop, so this stays the only place that decides an attempt is over.
#[cfg(mobile)]
if crate::google::browser::cancelled() {
return Err(CANCELLED.to_string());
}
match listener.accept() {
Ok((mut stream, _)) => {
stream.set_nonblocking(false).ok();
@@ -315,6 +347,12 @@ fn await_code(
&mut stream,
"Authorization was cancelled. You can close this tab.",
);
// `access_denied` is Google's word for the user pressing Cancel on the
// consent screen, which is the same decision as closing the browser and
// deserves the same quiet handling. Anything else really did go wrong.
if error == "access_denied" {
return Err(CANCELLED.to_string());
}
return Err(format!("Google authorization failed: {error}"));
}
Redirect::Mismatch => {
@@ -491,9 +529,12 @@ fn auth_url(creds: &Credentials, redirect: &str, challenge: &str, csrf: &str) ->
)
}
/// The system browser, never an in-app webview. Google blocks the embedded-webview flow outright,
/// and it deserves to be blocked: a webview the app controls can read what the user types into it.
fn open_in_browser(app: &tauri::AppHandle, url: &str) {
/// Hands the URL to whichever browser the OS considers the user's, in its own process. Never an
/// in-app webview: Google blocks the embedded-webview flow outright, and it deserves to be blocked,
/// because a webview the app controls can read what the user types into it. `browser.rs` covers the
/// mobile surfaces, which are the system's browser too and are only in front of the app rather than
/// inside it.
pub(super) fn open_in_browser(app: &tauri::AppHandle, url: &str) {
use tauri_plugin_opener::OpenerExt;
let _ = app.opener().open_url(url.to_string(), None::<&str>);
}
@@ -507,10 +548,14 @@ fn emit_auth(app: &tauri::AppHandle, outcome: Result<(String, String), String>)
error: None,
account_id: Some(account_id),
email: Some(email),
cancelled: false,
}
}
// Compared against the constant the cancel paths raise, rather than matched on its text,
// so rewording it cannot quietly turn a cancel back into an error on screen.
Err(error) => AuthEvent {
ok: false,
cancelled: error == CANCELLED,
error: Some(error),
account_id: None,
email: None,
@@ -519,9 +564,34 @@ fn emit_auth(app: &tauri::AppHandle, outcome: Result<(String, String), String>)
let _ = app.emit("auth", event);
}
#[cfg(desktop)]
/// One consent request, on all five platforms.
///
/// The loopback flow is the default everywhere, phones included. Google lets a Desktop client
/// redirect to loopback on any port with nothing registered in advance, and the token endpoint
/// checks the client id, the secret and the redirect and has no idea which OS asked. What used to
/// make this impossible on a phone was not the protocol but the browser: sending the user out to
/// Safari suspends this process, and a suspended process is not accepting on its socket. An in-app
/// browser does not leave, which is why `browser.rs` exists and why this branch is now the common
/// one.
///
/// The custom scheme flow runs instead when the credentials file has a client for this platform.
/// That is Google's stated guidance, and the thing to reach for if they ever start enforcing it,
/// but on iOS it is worth having for its own sake: it is the only way to reach
/// `ASWebAuthenticationSession`, which is the only iOS browser that shares Safari's cookies.
/// docs/mobile.md has the trade in full.
pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
let creds = load_credentials()?;
#[cfg(mobile)]
if creds.platform_client {
return connect_by_deep_link(app, creds).await;
}
connect_by_loopback(app, creds).await
}
async fn connect_by_loopback(
app: tauri::AppHandle,
creds: Credentials,
) -> Result<String, String> {
let verifier = random_b64(64);
let challenge = pkce_challenge(&verifier);
let csrf = random_b64(24);
@@ -531,7 +601,10 @@ pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
let redirect = format!("http://127.0.0.1:{port}");
let url = auth_url(&creds, &redirect, &challenge, &csrf);
#[cfg(desktop)]
open_in_browser(&app, &url);
#[cfg(mobile)]
crate::google::browser::open(&app, &url);
let app_bg = app.clone();
tauri::async_runtime::spawn(async move {
@@ -542,13 +615,26 @@ pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
Ok(url)
}
/// Mobile has no loopback listener to wait on, so `connect` ends the moment the browser opens and
/// the flow resumes in `handle_redirect` whenever the deep link arrives. What is stashed here is
/// the PKCE verifier and the CSRF state, which is the only thing tying the code that comes back to
/// the request that went out.
/// The custom scheme flow. What is stashed here is the PKCE verifier and the CSRF state, which is
/// the only thing tying the code that comes back to the request that went out, since there is no
/// listener holding either on its own stack.
///
/// Where the answer comes back from differs by platform, and so does what it is worth.
///
/// iOS hands the URL to `ASWebAuthenticationSession`, which reports the callback straight to a
/// completion handler. It is the only iOS surface that shares Safari's cookies, so an account
/// already signed in on the phone is offered by name rather than asking for a password again. That
/// is the reason this flow is worth the console visit on iOS, and `browser.rs` says the rest.
///
/// Android opens the external browser and waits for the deep link, which is a genuinely separate
/// app: this process may be backgrounded, or killed outright, for the whole of it. Android needs
/// none of this, because a Custom Tab already shares Chrome's cookies and the loopback flow above
/// already works, so this is only ever reached when somebody has gone and made an Android client.
#[cfg(mobile)]
pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
let creds = load_credentials()?;
async fn connect_by_deep_link(
app: tauri::AppHandle,
creds: Credentials,
) -> Result<String, String> {
let verifier = random_b64(64);
let challenge = pkce_challenge(&verifier);
let csrf = random_b64(24);
@@ -561,15 +647,40 @@ pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
*pending = Some(Pending {
state: csrf,
verifier,
redirect,
redirect: redirect.clone(),
expires: now() + PENDING_TIMEOUT_SECS,
});
}
#[cfg(target_os = "ios")]
crate::google::browser::authenticate(&app, &url, callback_scheme(&redirect));
#[cfg(not(target_os = "ios"))]
open_in_browser(&app, &url);
Ok(url)
}
/// `ASWebAuthenticationSession` matches on the scheme alone and wants it bare, so the path and the
/// colon that `redirect_uri` builds have to come back off.
#[cfg(target_os = "ios")]
fn callback_scheme(redirect: &str) -> &str {
redirect.split(':').next().unwrap_or(redirect)
}
/// The consent surface ended with no callback URL at all: the user closed it, or it could not be
/// shown. Either way the pending verifier is spent, and the panel is waiting on an answer that is
/// never coming, so it is told. A cancel says so plainly rather than reporting a failure.
#[cfg(mobile)]
pub async fn abandon_pending(app: tauri::AppHandle, reason: Option<String>) {
let auth = app.state::<AuthState>();
let pending = {
let mut slot = auth.pending.lock().await;
slot.take()
};
if pending.is_some() {
emit_auth(&app, Err(reason.unwrap_or_else(|| CANCELLED.to_string())));
}
}
/// Every URL the OS hands the app on a registered scheme, including ones that have nothing to do
/// with consent. A link that carries no `state` we are waiting for is ignored in silence rather
/// than reported, because another feature may want that scheme later and a stray link is not an
@@ -635,7 +746,6 @@ pub async fn handle_redirect(app: tauri::AppHandle, incoming: &url::Url) {
emit_auth(&app, outcome);
}
#[cfg(desktop)]
async fn complete_auth(
app: &tauri::AppHandle,
listener: TcpListener,
@@ -649,9 +759,16 @@ async fn complete_auth(
await_code(listener, &csrf, deadline)
})
.await
.map_err(|e| e.to_string())??;
.map_err(|e| e.to_string())?;
finish(app, &creds, &code, &redirect, &verifier).await
// Nothing takes the consent page down on its own once the listener has its answer, and what it
// is showing by then is the listener's own "you can close this" page. Taken away here rather
// than after the exchange, and whatever the outcome was, so the app comes back the moment the
// browser has nothing left to do. Desktop has no such surface and compiles this out.
#[cfg(mobile)]
crate::google::browser::close(app);
finish(app, &creds, &code?, &redirect, &verifier).await
}
/// Code to stored account. Everything past the point where the two flows stop differing.
+420
View File
@@ -0,0 +1,420 @@
// Where the consent page is shown on a phone, and how it is taken away again.
//
// Desktop hands the URL to the system browser and forgets about it. The loopback listener is the
// only thing that has to survive, and a desktop process keeps running perfectly well while another
// window has focus. A phone does not work that way: sending the user out to Safari or Chrome
// backgrounds this process, iOS suspends it, and a suspended process accepts nothing on its socket,
// so the redirect carrying the authorization code arrives at nobody.
//
// So the consent page is put in front of the app instead, by the system's own browser component.
// Never a WebView this app owns: Google blocks that outright with `disallowed_useragent`, and it
// deserves to be blocked, because a webview the app controls can read what is typed into it. What
// this app gets in return for not owning it is to stay foreground, with its listener still
// accepting, for as long as consent takes.
//
// Three surfaces, because the platforms do not offer the same thing.
//
// Chrome Custom Tab, Android. Chrome itself, so it reads Chrome's cookie jar and an account
// already signed in there is offered by name. Nothing else is needed on Android.
//
// SFSafariViewController, iOS, when there is no `ios` OAuth client. Safari's engine, but with
// storage of this app's own since iOS 11, so the user signs in from scratch inside it. It works
// with no console setup at all, which is the only reason to accept that.
//
// ASWebAuthenticationSession, iOS, when there is one. The only iOS surface that shares Safari's
// session, and the one to prefer, but it intercepts a custom scheme and never an http loopback
// redirect, so it is reachable only where an `ios` client has bought a scheme.
//
// The first two are `open`/`close`: they know nothing about the answer, so the loopback listener
// stays the thing that decides an attempt is over, and the two flags below carry the one fact it
// cannot see for itself. `authenticate` is the third and reports its own outcome.
use std::sync::atomic::{AtomicBool, Ordering};
/// Set while the consent page is up and the listener is still waiting on it. Only a dismissal
/// during that window means anything: the same signals fire when this app takes the browser down
/// itself, a moment after the code has already arrived.
static WAITING: AtomicBool = AtomicBool::new(false);
/// Set when the user closed the browser without finishing. `await_code` polls it rather than being
/// interrupted, which costs up to one poll interval and keeps the listener loop the only thing that
/// decides when a consent attempt is over.
static DISMISSED: AtomicBool = AtomicBool::new(false);
pub fn open(app: &tauri::AppHandle, url: &str) {
DISMISSED.store(false, Ordering::SeqCst);
WAITING.store(true, Ordering::SeqCst);
present(app, url);
}
pub fn close(app: &tauri::AppHandle) {
WAITING.store(false, Ordering::SeqCst);
dismiss(app);
}
pub fn cancelled() -> bool {
DISMISSED.load(Ordering::SeqCst)
}
/// The user closed the consent page: the Done button on iOS, Back or a swipe on Android.
pub fn note_dismissed() {
if WAITING.load(Ordering::SeqCst) {
DISMISSED.store(true, Ordering::SeqCst);
}
}
/// iOS presents the consent page inside this app, so this process never leaves the foreground and
/// there is nothing to hook. Android's Custom Tab is a Chrome activity on top of ours, so this app
/// really does go to the background and coming back is the signal: the tab is in this app's own
/// task, and the only way out of it is dismissal. `close` clears `WAITING` before it brings the
/// activity forward, so the resume this app asks for itself is not mistaken for the user's.
#[cfg(target_os = "android")]
pub fn note_resumed() {
note_dismissed();
}
/// The other iOS surface, and the one to prefer where a custom scheme exists to make it possible.
/// Nothing to do with the two flags above: it reports its own cancel, because it has a completion
/// handler to report it through and no listener to interrupt.
#[cfg(target_os = "ios")]
pub fn authenticate(app: &tauri::AppHandle, url: &str, scheme: &str) {
ios::authenticate(app, url, scheme);
}
#[cfg(target_os = "ios")]
fn present(app: &tauri::AppHandle, url: &str) {
ios::present(app, url);
}
#[cfg(target_os = "ios")]
fn dismiss(app: &tauri::AppHandle) {
ios::dismiss(app);
}
#[cfg(target_os = "ios")]
mod ios {
use block2::{DynBlock, RcBlock};
use objc2::rc::{Allocated, Retained};
use objc2::runtime::{AnyClass, AnyObject, ClassBuilder, NSObject, Sel};
use objc2::{msg_send, sel, ClassType};
use objc2_foundation::{NSError, NSString, NSURL};
use tauri::Manager;
use std::cell::RefCell;
// SFSafariViewController has no objc2 binding: objc2-safari-services covers the macOS extension
// API and nothing else. Linking the framework by hand is what puts the class in the process at
// all, after which it can be looked up by name.
#[link(name = "SafariServices", kind = "framework")]
extern "C" {}
thread_local! {
/// The presented controller and the delegate it reports to, kept only so they can be found
/// again: UIKit holds a delegate weakly, and a controller nothing retains is a controller
/// that deallocates mid-flow. Both slots are main thread only, which is where every line in
/// this module runs.
static PRESENTED: RefCell<Option<Retained<AnyObject>>> = const { RefCell::new(None) };
static DELEGATE: RefCell<Option<Retained<AnyObject>>> = const { RefCell::new(None) };
}
/// SFSafariViewController calls this when the user taps Done, and never when the dismissal came
/// from `dismiss` below. UIKit takes the controller off screen on its own here, so there is
/// nothing to do but let go of it and say what happened.
extern "C-unwind" fn did_finish(_this: &AnyObject, _cmd: Sel, _controller: *mut AnyObject) {
PRESENTED.with(|slot| slot.borrow_mut().take());
DELEGATE.with(|slot| slot.borrow_mut().take());
super::note_dismissed();
}
/// Registered once, lazily, because a class pair can only be registered under a given name
/// once per process. SFSafariViewControllerDelegate is checked with `respondsToSelector:`
/// rather than `conformsToProtocol:`, so declaring the one method is enough and there is no
/// protocol to adopt.
fn delegate_class() -> &'static AnyClass {
thread_local! {
static CLASS: &'static AnyClass = {
let mut builder = ClassBuilder::new(c"MarginConsentDelegate", NSObject::class())
.expect("MarginConsentDelegate is registered once and by nobody else");
unsafe {
builder.add_method(
sel!(safariViewControllerDidFinish:),
did_finish as extern "C-unwind" fn(_, _, _),
);
}
builder.register()
};
}
CLASS.with(|class| *class)
}
pub fn present(app: &tauri::AppHandle, url: &str) {
let Some(window) = app.get_webview_window("main") else {
return crate::google::auth::open_in_browser(app, url);
};
let inner_app = app.clone();
let inner_url = url.to_string();
let posted = window.with_webview(move |webview| {
// `with_webview` hands the WKWebView over on the main thread, which is both where UIKit
// needs this and where the two slots above live. Its window is the app's own, so the
// sheet comes up over the calendar rather than over whatever else UIKit would have
// picked.
let presented = unsafe {
let view = webview.inner().cast::<AnyObject>();
let ui_window: Option<Retained<AnyObject>> = msg_send![view, window];
let root: Option<Retained<AnyObject>> = match &ui_window {
Some(ui_window) => msg_send![&**ui_window, rootViewController],
None => None,
};
match root {
Some(root) => show(&root, &inner_url),
None => false,
}
};
if !presented {
crate::google::auth::open_in_browser(&inner_app, &inner_url);
}
});
if posted.is_err() {
crate::google::auth::open_in_browser(app, url);
}
}
/// Everything that can be missing here is missing for the same reason: an iOS that does not
/// have the class, or a URL the OS will not parse. Both answer false and the caller falls back
/// to the external browser rather than leaving the user looking at nothing.
unsafe fn show(root: &AnyObject, url: &str) -> bool {
let Some(class) = AnyClass::get(c"SFSafariViewController") else {
return false;
};
let Some(url) = NSURL::URLWithString(&NSString::from_str(url)) else {
return false;
};
let allocated: Allocated<AnyObject> = msg_send![class, alloc];
let controller: Option<Retained<AnyObject>> = msg_send![allocated, initWithURL: &*url];
let Some(controller) = controller else {
return false;
};
let delegate: Retained<AnyObject> = msg_send![delegate_class(), new];
let () = msg_send![&*controller, setDelegate: &*delegate];
DELEGATE.with(|slot| *slot.borrow_mut() = Some(delegate));
let completion: Option<&DynBlock<dyn Fn()>> = None;
let () = msg_send![root, presentViewController: &*controller, animated: true, completion: completion];
PRESENTED.with(|slot| *slot.borrow_mut() = Some(controller));
true
}
pub fn dismiss(app: &tauri::AppHandle) {
let _ = app.run_on_main_thread(|| {
let Some(controller) = PRESENTED.with(|slot| slot.borrow_mut().take()) else {
return;
};
DELEGATE.with(|slot| slot.borrow_mut().take());
unsafe {
let completion: Option<&DynBlock<dyn Fn()>> = None;
// Sent to the presented controller rather than the presenting one, which UIKit
// forwards. Letting go of the last reference afterwards is safe: the presenting
// controller holds it for the length of the animation.
let () = msg_send![&*controller, dismissViewControllerAnimated: true, completion: completion];
}
});
}
// The second iOS surface, and the better one. See `authenticate` for what it buys.
#[link(name = "AuthenticationServices", kind = "framework")]
extern "C" {}
/// `ASWebAuthenticationSessionErrorCodeCanceledLogin`. The user closed the sheet, which is not
/// a failure worth dressing up as one.
const CANCELED_LOGIN: isize = 1;
thread_local! {
/// The session, the object that tells it which window to present over, and that window.
///
/// A session nothing retains deallocates and then simply never calls back, which is the
/// classic way to lose an afternoon to this API, and the presentation context provider is
/// held weakly so it goes the same way. Replaced on the next attempt rather than cleared in
/// the completion handler, because the session owns the block that handler lives in and
/// releasing it from inside its own invocation is asking for a use after free.
static SESSION: RefCell<Option<Retained<AnyObject>>> = const { RefCell::new(None) };
static ANCHOR: RefCell<Option<Retained<AnyObject>>> = const { RefCell::new(None) };
static ANCHOR_WINDOW: RefCell<Option<Retained<AnyObject>>> = const { RefCell::new(None) };
}
/// Required from iOS 13 on: with no anchor the session refuses to start and reports
/// `presentationContextNotProvided` instead. Returned unretained, which is the convention for
/// a getter like this one, and safe because ANCHOR_WINDOW holds it for the flow.
extern "C-unwind" fn presentation_anchor(
_this: &AnyObject,
_cmd: Sel,
_session: *mut AnyObject,
) -> *mut AnyObject {
ANCHOR_WINDOW.with(|slot| match slot.borrow().as_ref() {
Some(window) => Retained::as_ptr(window).cast_mut(),
None => std::ptr::null_mut(),
})
}
fn anchor_class() -> &'static AnyClass {
thread_local! {
static CLASS: &'static AnyClass = {
let mut builder = ClassBuilder::new(c"MarginConsentAnchor", NSObject::class())
.expect("MarginConsentAnchor is registered once and by nobody else");
unsafe {
builder.add_method(
sel!(presentationAnchorForWebAuthenticationSession:),
presentation_anchor as extern "C-unwind" fn(_, _, _) -> _,
);
}
builder.register()
};
}
CLASS.with(|class| *class)
}
/// The consent page in an `ASWebAuthenticationSession`, which is the same Safari view underneath
/// but with Safari's own cookies rather than a jar of this app's alone. That is the whole point
/// of it: an account already signed in on this phone is offered by name instead of asking for a
/// password again. It is also why iOS puts up its own "wants to use google.com to sign in"
/// prompt first, since sharing the session is something the user gets to refuse.
///
/// The price is that it only ever intercepts a custom scheme, never an http loopback redirect,
/// so this path exists only where a scheme exists: an `ios` block in the credentials file.
pub fn authenticate(app: &tauri::AppHandle, url: &str, scheme: &str) {
let Some(window) = app.get_webview_window("main") else {
return crate::google::auth::open_in_browser(app, url);
};
let inner_app = app.clone();
let inner_url = url.to_string();
let scheme = scheme.to_string();
let posted = window.with_webview(move |webview| {
let started = unsafe {
let view = webview.inner().cast::<AnyObject>();
let ui_window: Option<Retained<AnyObject>> = msg_send![view, window];
match ui_window {
Some(ui_window) => start(&inner_app, ui_window, &inner_url, &scheme),
None => false,
}
};
if !started {
crate::google::auth::open_in_browser(&inner_app, &inner_url);
}
});
if posted.is_err() {
crate::google::auth::open_in_browser(app, url);
}
}
unsafe fn start(
app: &tauri::AppHandle,
ui_window: Retained<AnyObject>,
url: &str,
scheme: &str,
) -> bool {
let Some(class) = AnyClass::get(c"ASWebAuthenticationSession") else {
return false;
};
let Some(url) = NSURL::URLWithString(&NSString::from_str(url)) else {
return false;
};
let scheme = NSString::from_str(scheme);
let handler_app = app.clone();
let handler = RcBlock::new(move |callback: *mut NSURL, error: *mut NSError| {
finished(&handler_app, callback, error);
});
let allocated: Allocated<AnyObject> = msg_send![class, alloc];
let session: Option<Retained<AnyObject>> = msg_send![
allocated,
initWithURL: &*url,
callbackURLScheme: &*scheme,
completionHandler: &*handler,
];
let Some(session) = session else {
return false;
};
// False, not true: an ephemeral session is a fresh cookie jar, which throws away the one
// reason to be using this API at all.
let () = msg_send![&*session, setPrefersEphemeralWebBrowserSession: false];
let anchor: Retained<AnyObject> = msg_send![anchor_class(), new];
let () = msg_send![&*session, setPresentationContextProvider: &*anchor];
ANCHOR_WINDOW.with(|slot| *slot.borrow_mut() = Some(ui_window));
ANCHOR.with(|slot| *slot.borrow_mut() = Some(anchor));
let started: bool = msg_send![&*session, start];
SESSION.with(|slot| *slot.borrow_mut() = Some(session));
started
}
/// One of three answers: the callback URL, a cancel, or a real failure. The URL goes to the same
/// `handle_redirect` the deep link uses, so the state check and the exchange stay in one place
/// and this knows nothing about either.
fn finished(app: &tauri::AppHandle, callback: *mut NSURL, error: *mut NSError) {
if !callback.is_null() {
let absolute = unsafe {
let string: Retained<NSString> = msg_send![callback, absoluteString];
string.to_string()
};
if let Ok(parsed) = url::Url::parse(&absolute) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
crate::google::auth::handle_redirect(app, &parsed).await;
});
}
return;
}
let code = if error.is_null() {
CANCELED_LOGIN
} else {
unsafe { msg_send![error, code] }
};
let reason = if code == CANCELED_LOGIN {
None
} else {
Some(format!("The sign-in sheet could not be shown (error {code})."))
};
let app = app.clone();
tauri::async_runtime::spawn(async move {
crate::google::auth::abandon_pending(app, reason).await;
});
}
}
/// Android has no equivalent of `with_webview`, and reaching a Custom Tab from here would mean JNI
/// in Rust for the sake of one Intent. It goes through the JavaScript bridge in `MainActivity.kt`
/// instead, which is the same shape as the window insets bridge that is already there.
///
/// That bridge is the one thing this depends on, and `tauri android init` regenerates the file it
/// lives in. Without it the script below is a no-op and nothing opens at all, which is why
/// docs/mobile.md says to check for it after running init.
#[cfg(target_os = "android")]
fn present(app: &tauri::AppHandle, url: &str) {
let literal = match serde_json::to_string(url) {
Ok(literal) => literal,
Err(_) => return crate::google::auth::open_in_browser(app, url),
};
if eval(app, &format!("window.__androidAuthTab?.open({literal})")).is_err() {
crate::google::auth::open_in_browser(app, url);
}
}
#[cfg(target_os = "android")]
fn dismiss(app: &tauri::AppHandle) {
let _ = eval(app, "window.__androidAuthTab?.close()");
}
#[cfg(target_os = "android")]
fn eval(app: &tauri::AppHandle, script: &str) -> Result<(), String> {
use tauri::Manager;
let window = app
.get_webview_window("main")
.ok_or("there is no window to run the bridge from")?;
window.eval(script).map_err(|e| e.to_string())
}
+5 -1
View File
@@ -1,9 +1,13 @@
// auth.rs OAuth, token refresh. Loopback on desktop, a deep link on mobile.
// auth.rs OAuth, token refresh. A loopback listener everywhere, a deep link when a phone has
// been given its own OAuth client.
// browser.rs the consent page in front of the app on a phone, and taking it away again
// api.rs typed Google Calendar REST wrapper
// secrets.rs refresh tokens, encrypted on disk, same on every platform
pub mod api;
pub mod auth;
#[cfg(mobile)]
pub mod browser;
pub mod secrets;
use crate::dto::Account;
+30 -3
View File
@@ -29,7 +29,7 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
let sync_now = MenuItemBuilder::with_id("sync-now", "Sync Now")
.accelerator("CmdOrCtrl+R")
.build(handle)?;
let accounts = MenuItemBuilder::with_id("accounts", "Accounts…").build(handle)?;
let accounts = MenuItemBuilder::with_id("accounts", "Google Accounts…").build(handle)?;
let check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
let settings = MenuItemBuilder::with_id("settings", "Settings…")
@@ -136,8 +136,10 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
Ok(menu)
}
/// Google's answer to a consent request comes back as a link into this app rather than to a
/// loopback port, because a phone has no loopback port to give it.
/// The deep link half of mobile auth: Google's answer arrives as a link into this app rather than
/// on a loopback socket. Only the flow that runs when a phone has its own OAuth client uses it, but
/// it stays registered on both platforms either way, because the OS has to be told about a scheme
/// at install time and cannot be told about one later.
///
/// Both arrival routes are covered. `on_open_url` catches the link when the app was already
/// running, which is the usual case since it is what opened the browser a moment ago;
@@ -196,6 +198,27 @@ fn stop_uikit_shrinking_the_viewport(window: &tauri::WebviewWindow) {
});
}
/// A Chrome Custom Tab is Chrome's activity sitting on top of ours, in our own task, so this
/// process really is backgrounded for the length of a consent round trip and coming back means the
/// tab has gone. That is the only notice Android gives that the user backed out of signing in, and
/// without it the accounts panel waits on an answer that is never coming. `note_resumed` works out
/// whether the tab went because the user closed it or because this app took it down a moment after
/// the code arrived.
///
/// Not `RunEvent::Resumed`, which sounds right and is not: tauri raises that one on every poll of
/// the event loop. tao's real mobile resume arrives here, per window.
///
/// iOS shows the consent page inside the app, never leaves the foreground, and gets the same
/// question answered by a delegate in `google/browser.rs` instead.
#[cfg(target_os = "android")]
fn watch_for_the_consent_tab_closing(window: &tauri::WebviewWindow) {
window.on_window_event(|event| {
if matches!(event, tauri::WindowEvent::Resumed) {
google::browser::note_resumed();
}
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// generate_context! first, so the updater plugin registers only when the merged config
@@ -230,6 +253,10 @@ pub fn run() {
if let Some(window) = handle.get_webview_window("main") {
stop_uikit_shrinking_the_viewport(&window);
}
#[cfg(target_os = "android")]
if let Some(window) = handle.get_webview_window("main") {
watch_for_the_consent_tab_closing(&window);
}
Ok(())
});