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

+2
View File
@@ -2279,10 +2279,12 @@ name = "margin-calendar"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"block2",
"chacha20poly1305",
"chrono",
"chrono-tz",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"rand 0.8.7",
"reqwest 0.12.28",
+12 -3
View File
@@ -44,9 +44,10 @@ chacha20poly1305 = "0.10"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
# One UIKit property that wry does not set for us; stop_uikit_shrinking_the_viewport in lib.rs says
# which one and why. These versions are the ones wry already resolves for its own iOS backend, so
# matching them keeps a single copy of objc2 in the build rather than a second incompatible one.
# Two things wry leaves to us: one UIKit property (stop_uikit_shrinking_the_viewport in lib.rs) and
# the SFSafariViewController that shows the consent page (google/browser.rs). These versions are the
# ones wry already resolves for its own iOS backend, so matching them keeps a single copy of objc2 in
# the build rather than a second incompatible one.
[target.'cfg(target_os = "ios")'.dependencies]
objc2 = "0.6"
objc2-ui-kit = { version = "0.3", default-features = false, features = [
@@ -55,6 +56,14 @@ objc2-ui-kit = { version = "0.3", default-features = false, features = [
"UIView",
"UIScrollView",
] }
objc2-foundation = { version = "0.3", default-features = false, features = [
"std",
"NSString",
"NSURL",
] }
# Only for the null completion handler the two present/dismiss calls take. Passing a raw null
# pointer there would encode as an object rather than a block, which objc2 checks and rejects.
block2 = "0.6"
[dev-dependencies]
tempfile = "3"
@@ -58,6 +58,9 @@ rust {
}
dependencies {
// Chrome Custom Tabs, for the OAuth consent page. Added by hand, so `tauri android init` will
// drop it again along with the bridge in MainActivity.kt that uses it.
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
@@ -5,6 +5,17 @@
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<!-- Android 11 and up hide every other package from an app unless it says which it needs to
see. Without this, CustomTabsClient.getPackageName finds no browser at all, the OAuth
consent page falls back to an ordinary browser Intent, and the loopback listener it is
supposed to redirect to is left in a backgrounded process. Added by hand, so
`tauri android init` will drop it. -->
<queries>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -1,18 +1,30 @@
package studio.margin.calendar
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.activity.enableEdgeToEdge
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
/**
* Android's WebView works out env(safe-area-inset-*) from the display cutout and from nothing else,
* so the status and navigation bars overlap the page while env() still reads 0 underneath them. A
* targetSdk of 36 makes edge to edge mandatory, so there is no overlap to opt out of: the only way
* out is to measure the bars and tell the page. That is what this does, and the page turns the
* numbers into --safe-top and --safe-bottom (src/safeArea.ts).
* Two things Rust cannot reach on Android without JNI, both published to the page as JavaScript
* bridges and both driven from Rust with webview.eval.
*
* The first is the window insets. Android's WebView works out env(safe-area-inset-*) from the
* display cutout and from nothing else, so the status and navigation bars overlap the page while
* env() still reads 0 underneath them. A targetSdk of 36 makes edge to edge mandatory, so there is
* no overlap to opt out of: the only way out is to measure the bars and tell the page. The page
* turns the numbers into --safe-top and --safe-bottom (src/safeArea.ts).
*
* The second is the Chrome Custom Tab that shows Google's consent page (src-tauri/src/google/
* browser.rs). It has to be a Custom Tab rather than a WebView, because Google refuses to sign
* anyone in through a WebView the app owns, and rather than an ordinary browser Intent, because
* that would put this app in the background where its loopback listener stops accepting.
*/
class MainActivity : TauriActivity() {
// Device pixels. Written on the UI thread by the insets listener and read on the WebView's
@@ -26,6 +38,48 @@ class MainActivity : TauriActivity() {
@JavascriptInterface fun bottom(): Int = bottomPx
}
inner class AuthTab {
/**
* Runs on the WebView's bridge thread rather than the UI thread, which is fine: starting an
* activity touches no view hierarchy. Every exit lands the user on the consent page somehow,
* because a sign-in that opens nothing at all is the one outcome with no way back.
*/
@JavascriptInterface
fun open(url: String) {
val uri = Uri.parse(url)
// Null when no installed browser implements Custom Tabs, which is rare and still possible on
// a stripped image or an old device.
val browser = CustomTabsClient.getPackageName(this@MainActivity, null)
if (browser != null) {
try {
val tab = CustomTabsIntent.Builder().setShowTitle(true).build()
tab.intent.setPackage(browser)
tab.launchUrl(this@MainActivity, uri)
return
} catch (e: Exception) {
Logger.warn("could not open a custom tab: $e")
}
}
// The old behaviour, and a worse one: this hands the user to a separate browser app, which
// backgrounds this process. The listener survives a short trip but the whole point of the
// tab above is not to take one.
startActivity(Intent(Intent.ACTION_VIEW, uri))
}
/**
* A Custom Tab belongs to Chrome and cannot be closed by the app that launched it. What can be
* done is to bring this activity back to the front of the task the tab was launched into, which
* pops the tab off on the way. Same move AppAuth makes, and the background-start restrictions
* do not apply because this activity is already in that task's back stack.
*/
@JavascriptInterface
fun close() {
val intent = Intent(this@MainActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
startActivity(intent)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -35,6 +89,7 @@ class MainActivity : TauriActivity() {
// wry calls this between constructing the WebView and loading the URL, which is the only point
// at which an interface can be added and still be there for the first document.
webView.addJavascriptInterface(SafeArea(), "__androidSafeArea")
webView.addJavascriptInterface(AuthTab(), "__androidAuthTab")
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
// The cutout is folded in rather than trusted on its own: a landscape cutout down one side
+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(())
});