mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
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:
1 parent
661100dfdc
commit
d4c3a304b5
31 files changed
+1319
-114
No files matched your search
Generated
+2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user