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

+25 -18
View File
@@ -11,27 +11,34 @@ locked to `ipc:` exactly as margin has it.
## Authentication
Lifted from `margin/src-tauri/src/gdrive.rs`, then split in two when mobile arrived. Both halves
build the same consent URL with PKCE S256 and a CSRF state parameter and open it in the system
browser through the opener plugin, never an in-app webview: Google blocks the embedded-webview
flow, and it deserves to be blocked, because a webview the app controls can read the password
typed into it. They differ only in how the answer comes back.
Lifted from `margin/src-tauri/src/gdrive.rs`. Every platform builds the same consent URL with PKCE
S256 and a CSRF state parameter, and every platform opens it in the system's browser, never in a
webview this app owns: Google blocks the embedded-webview flow, and it deserves to be blocked,
because a webview the app controls can read the password typed into it.
Desktop binds a listener on `127.0.0.1:0` and catches the redirect on the loopback socket. The
verifier lives on that listener's stack for the two minutes it is alive.
The default flow is the same one on all five platforms. Bind a listener on `127.0.0.1:0`, ask
Google to redirect there, and catch the code on the loopback socket. Google allows a Desktop client
any loopback port without registering it, and the token endpoint checks the client id, the secret
and the redirect rather than the operating system, so a phone can use the desktop client too. What
makes that safe to rely on is where the browser is: on mobile the consent page opens **in front of**
the app, in `SFSafariViewController` or a Chrome Custom Tab, so this process stays foreground and
its listener stays live. Sending the user out to Safari would suspend it and the redirect would
arrive at a socket nobody is accepting on. `google/browser.rs` is that surface, and dismissing it
once the code lands.
Mobile cannot do that. iOS will not keep a background listener alive dependably, and Google
rejects loopback redirects for Android and iOS client types outright, so the redirect is a custom
URI scheme the OS routes back to the app through `tauri-plugin-deep-link`. There is no listener to
hold the verifier, the browser is a separate app and this process may be backgrounded while the
user consents, so the verifier waits in `AuthState.pending` and `handle_redirect` picks it up
whenever the link lands. It is taken rather than read, so a replayed link cannot start a second
exchange.
The second flow runs where a platform has been given its own OAuth client, which is Google's stated
guidance and what to fall back on if they ever enforce it. Those clients are public, have no secret,
and redirect to a custom URI scheme rather than to loopback. The verifier has no listener stack to
live on, so it waits in `AuthState.pending` until the callback lands, and is taken rather than read
so a replayed link cannot start a second exchange.
That split forces one more difference. A desktop client is confidential and has a secret; an
Android or iOS client is public and has none, so `google-credentials.json` carries up to three
clients and the build embeds the one for the platform it is compiling for. Details and the
console steps are in [mobile.md](mobile.md).
Where the callback lands differs. Android opens the external browser and the OS routes the scheme
back through `tauri-plugin-deep-link`, which is also the arrival route on a cold start. iOS uses
`ASWebAuthenticationSession`, which reports the URL straight to a completion handler and is the only
iOS surface that shares Safari's cookies, so an account already signed in on the phone is offered by
name. That last point is the reason the choice is not merely academic on iOS, and
[mobile.md](mobile.md) argues it out. `load_credentials` decides once, by whether the block is in
the file, and everything downstream follows from that.
The scope is `https://www.googleapis.com/auth/calendar` plus `openid email`. Calendar is a
sensitive scope, so an unverified client shows the unverified-app interstitial and caps at 100
+118 -12
View File
@@ -4,14 +4,72 @@ The same Rust core and the same React app, with a different shape of chrome and
getting a token back from Google. Nothing is forked: the layout switches on a `data-phone`
attribute and the OAuth flow switches on `cfg(mobile)`.
## Google will not let you reuse the desktop client
## Signing in, and what console work buys you
This is the part that cannot be automated away, so do it first or nothing will sign in.
Both platforms sign in out of the box with the `installed` client the desktop build already uses.
Android is finished at that point. iOS works, but signs the user in from scratch, and one OAuth
client fixes it. That asymmetry is the whole of this section.
Google issues OAuth clients per platform and refuses one in another's place. The desktop client
redirects to a loopback port; Google rejects loopback redirects for Android and iOS client types
outright, and iOS will not dependably keep a listener alive to catch one anyway. So a phone build
redirects to a custom URI scheme instead, and needs its own client to do it.
### The flow that needs nothing
A phone uses the desktop client, secret and all, and catches Google's answer on the same loopback
listener the desktop flow binds. Two facts make that work: a Desktop client may redirect to loopback
on **any** port with nothing registered in advance, and Google's token endpoint validates the client
id, the secret and the redirect URI without any way of knowing which operating system is asking.
Client types are policy guidance rather than a protocol check.
What used to make this impossible on a phone was not the protocol. It was that opening the consent
page in Safari or Chrome sends the user to another app, iOS suspends this process, and a suspended
process is not accepting on its socket, so the redirect carrying the code arrives at nobody. The
consent page now opens **in front of** the app instead, in the system's own browser component:
`SFSafariViewController` on iOS, a Chrome Custom Tab on Android. This app stays foreground and its
listener stays live. `src-tauri/src/google/browser.rs` is all of it.
Still the system browser, note, and 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
the password typed into it. A Safari sheet or a Custom Tab is a different process with the browser's
own cookies and autofill and an address bar the app cannot forge.
**Be clear about the trade.** Reusing a Desktop client from a phone is off Google's stated guidance,
which says to create an Android or iOS client per platform. It works because the protocol does not
check, not because Google blesses it. If Google ever starts enforcing the guidance the symptom will
be a rejected token exchange, and the fix is a per-platform client, which the code already supports
in full.
### Why Android needs nothing else
A Custom Tab is Chrome. It reads Chrome's cookie jar, so an account already signed in there is
offered by name and there is no password to type. Loopback and shared session at once, for nothing.
### Why iOS wants an `ios` client
`SFSafariViewController` has not shared cookies with Safari since iOS 11: every app gets its own
storage. So the no-setup iOS flow puts a real Safari view in front of the user with an empty cookie
jar behind it, and Google has no idea who they are. It signs in. It just asks for a full login every
time the token store is emptied.
`ASWebAuthenticationSession` is the API Apple shipped for exactly this, and it is the only one that
shares Safari's session, which is why iOS puts up its own "wants to use google.com to sign in"
prompt before it opens. But it intercepts a custom scheme and nothing else, never an http loopback
redirect, so on iOS a shared session and loopback are mutually exclusive. Adding an `ios` block is
what buys the scheme, and with it the good version.
So `auth.rs` runs three flows, not two: loopback everywhere by default, `ASWebAuthenticationSession`
plus the custom scheme when there is an `ios` client, and the external browser plus the deep link
when there is an `android` one. `load_credentials` decides once, by whether the block is in the
file.
An iOS client is about a minute of work: it wants the bundle identifier and nothing else, no
fingerprint, no keystore. An Android client wants a SHA-1 per signing key and buys nothing, so
there is no reason to make one unless Google forces the issue.
One thing observed rather than assumed, and worth knowing before anyone re-tests this on a
simulator: a cookie set in the simulator's Safari showed up in neither surface, including
`ASWebAuthenticationSession` after its sharing prompt was accepted. The prompt appears and the
plumbing is right, so this looks like the simulator not backing the shared jar rather than the API.
Judge the session sharing on a real device.
## Creating the per-platform clients
Both mobile clients are **public**: no client secret exists, and PKCE is the only thing between an
intercepted authorization code and a token. That is why the verifier is not optional anywhere in
@@ -34,7 +92,7 @@ In the Google Cloud console, on the same project that already has the Calendar A
2. Create an OAuth client of type **iOS**. It wants the bundle identifier, which is also
`studio.margin.calendar`.
3. Put both client ids in `google-credentials.json` alongside the desktop one, matching
3. Put the client ids in `google-credentials.json` alongside the desktop one, matching
`google-credentials.example.json`:
```json
@@ -45,12 +103,17 @@ In the Google Cloud console, on the same project that already has the Calendar A
}
```
The file is gitignored and embedded at build time. A build with no `android` or `ios` block
compiles and then says so at runtime rather than failing to compile, which is the same bargain
the desktop client already had.
The file is gitignored and embedded at build time. Adding a block for one platform leaves the
other on loopback: the choice is per platform, not per file.
A refresh token belongs to the client that obtained it, so adding or removing a block invalidates
whatever is already stored on that device. Disconnect and reconnect the account afterwards.
## The redirect schemes
These matter only for the flow above. The loopback flow redirects to `http://127.0.0.1:PORT` and
never touches a scheme.
Android redirects to `studio.margin.calendar:/oauth2redirect`. That is the package name, which is
Google's documented form for an Android client, and being known at build time means it can sit in
`AndroidManifest.xml` permanently rather than being pasted in per install.
@@ -75,10 +138,47 @@ when the `mobile` array is empty it deletes the key outright. That one empty arr
callback was dead on both platforms at first, so if a deep link ever stops arriving, look here
before anywhere else.
The `studio.margin.calendar` scheme stays registered on both platforms whether or not anything
uses it, because the OS is told about a scheme at install time and cannot be told about one later.
If the console shows you something different from either default, put it in the client's block in
`google-credentials.json` as `redirect_uri` and it wins over both. Whatever you put there still
has to have its scheme registered above, or the OS has no reason to hand the link to this app.
## The consent browser, and taking it away again
Neither surface closes itself once the redirect has landed, so `browser.rs` dismisses both. What is
on screen by then is the listener's own "you can close this" page, and leaving it up would look
like a sign-in that hung while the token exchange quietly succeeded behind it.
On iOS that is a `dismissViewControllerAnimated:` on the main thread. `SFSafariViewController` has
no objc2 binding, since `objc2-safari-services` covers only the macOS extension API, so the class
is reached by name and the SafariServices framework is linked by hand to put it in the process at
all. The controller and its delegate are both retained for the length of the flow: UIKit holds a
delegate weakly, and a controller nothing retains deallocates mid-sign-in.
On Android a Custom Tab belongs to Chrome and cannot be closed by the app that launched it. What
works instead is starting `MainActivity` with `FLAG_ACTIVITY_CLEAR_TOP | FLAG_ACTIVITY_SINGLE_TOP`,
which brings this app back to the front of the task the tab was launched into and pops the tab off
on the way. Same move AppAuth makes.
Closing the browser by hand is the other exit, and it has to be noticed or the accounts panel waits
on a sign-in that will never arrive. iOS gets it directly from `safariViewControllerDidFinish:`.
Android has no such callback, so it reads a process resume while a consent attempt is in flight,
which means the tab is gone: the tab is in this app's own task, and dismissal is the only way out
of it. Either way `await_code` sees the flag on its next poll and gives up.
`ASWebAuthenticationSession` needs none of that. It takes itself away when the callback scheme
matches and reports a cancel as error code 1, so the completion handler is the whole of it. What it
does need is to be retained: a session nothing holds deallocates and then never calls back, which
is the classic way to lose an afternoon to that API, and its presentation context provider is held
weakly so it goes the same way. Both are kept in `browser.rs` until the next attempt replaces them,
rather than being released inside the completion handler that the session itself owns.
One thing the Rust side cannot do anything about: a cancel still reaches the accounts panel as a
failed connect, because `AuthEvent` has no third shape, so it reads as "Sign-in was cancelled."
under a toast rather than as nothing at all.
## Toolchain
iOS needs Xcode and the iOS Rust targets:
@@ -109,8 +209,14 @@ pnpm tauri android dev # emulator or attached device
pnpm tauri android build
```
The generated projects are committed. Re-running `init` overwrites them, so check afterwards that
`MainActivity.kt` still publishes the window insets, since nothing else on Android does.
The generated projects are committed, and three files in the Android one are edited by hand.
`MainActivity.kt` publishes the window insets and launches the consent tab, `app/build.gradle.kts`
carries the `androidx.browser` dependency the tab needs, and `AndroidManifest.xml` carries a
`<queries>` element without which Android 11 and up hide every browser from
`CustomTabsClient.getPackageName` and the consent page quietly falls back to a separate browser
app. Re-running `android init` overwrites all three and nothing else supplies any of them, so check
for them afterwards: without the insets the bars overlap the page, and without the bridge tapping
Connect opens nothing at all.
Both `dev` commands start Vite themselves and fail outright if port 1430 is already taken, which
it will be if a browser dev server is still up. Kill it, or pass a different port through