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
+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(())
});
+5 -1
View File
@@ -6,6 +6,7 @@ import { Header } from "./components/Header";
import { PhoneMenu, PhoneTabBar, PhoneTopBar } from "./components/PhoneBar";
import { GridView } from "./components/GridView";
import { AgendaView } from "./components/AgendaView";
import { FirstRun } from "./components/FirstRun";
import { MiniMonthOverlay } from "./components/MiniMonth";
import { CalendarList } from "./components/CalendarList";
import { EventEditor } from "./components/EventEditor";
@@ -51,7 +52,9 @@ function App() {
if (!isTauri) return;
const menu = listen<string>("menu-action", (event) => handleMenuAction(event.payload));
const auth = listen<AuthEvent>("auth", (event) => {
void useAccounts.getState().handleAuthEvent(event.payload.ok, event.payload.error);
void useAccounts
.getState()
.handleAuthEvent(event.payload.ok, event.payload.error, event.payload.cancelled);
});
// A pass that fails in the background used to set the error and say nothing, so a calendar
// that never arrived looked like a calendar you do not have. Surface it once per distinct
@@ -113,6 +116,7 @@ function App() {
<main className="stage">
<GridView />
<AgendaView />
<FirstRun />
</main>
{phone ? <PhoneTabBar /> : null}
<PhoneMenu />
+5 -5
View File
@@ -55,7 +55,7 @@ export function Accounts() {
return (
<Sheet
open={showing}
title="Accounts"
title="Google accounts"
onClose={close}
foot={
confirming ? undefined : (
@@ -67,7 +67,7 @@ export function Accounts() {
disabled={connecting || phase === "working"}
onClick={() => void connect()}
>
Connect an account
Connect a Google account
</button>
)
}
@@ -78,8 +78,8 @@ export function Accounts() {
body={
<p>
The token is revoked and every calendar, event and pending write stored on this
computer for that account is deleted. Nothing changes in Google Calendar itself, and
you can connect the account again afterwards.
computer for that Google account is deleted. Nothing changes in Google Calendar
itself, and you can connect it again afterwards.
</p>
}
confirmLabel="Disconnect"
@@ -127,7 +127,7 @@ export function Accounts() {
{accounts.length === 0 && !connecting ? (
<p className="panel-note">
No account is connected, so there is nothing to show on the grid yet.
Connect a Google account to see its calendars here. Nothing syncs until you do.
</p>
) : (
accounts.map((account) => (
+1 -1
View File
@@ -74,7 +74,7 @@ export function CalendarList() {
<div className="panel-empty">
<p>No calendars here yet.</p>
<button type="button" className="panel-button" onClick={() => show("accounts")}>
Connect an account
Connect a Google account
</button>
</div>
) : (
+14 -6
View File
@@ -48,18 +48,26 @@ const TEXT = "M4 6h16M4 12h12M4 18h9";
const USERS =
"M17 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M12.5 7.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75";
/** Clear of the window edges, and clear of the one row of chrome that is always resident. */
/** Clear of the window edges, and clear of the chrome that is always resident. */
const EDGE = 8;
/**
* What the card is allowed to cover, measured off the chrome rather than named in tokens.
*
* A desktop has one row of it at the top. A phone has two, the second along the bottom, and both
* of them pad themselves out of the way of a notch and a home indicator, so their rectangles are
* the only thing that knows how tall they really are. Reading `--titlebar-h` instead meant the
* card was placed against the window: in landscape it came down over the tab bar, and since the
* bars paint above it, Edit, Delete and Close ended up behind the tab bar and unhittable.
*/
function viewBounds(): Bounds {
const bar = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--titlebar-h"),
);
const top = document.querySelector(".titlebar, .phonebar")?.getBoundingClientRect().bottom ?? 0;
const bottom = document.querySelector(".tabbar")?.getBoundingClientRect().top ?? window.innerHeight;
return {
top: (Number.isFinite(bar) ? bar : 0) + EDGE,
top: top + EDGE,
left: EDGE,
right: window.innerWidth - EDGE,
bottom: window.innerHeight - EDGE,
bottom: bottom - EDGE,
};
}
+38
View File
@@ -0,0 +1,38 @@
// What a first launch says. With nothing connected the grid is a correct and completely empty
// calendar, which looks exactly like a calendar you have nothing in, so the one thing to do next
// went unsaid: the panel that does it is behind a key on the desktop and an overflow sheet on a
// phone, and neither is somewhere you look when you do not yet know it exists.
//
// It covers the grid rather than sitting beside it. There is nothing underneath worth reading, and
// a note floating over an empty axis reads as a thing that failed to load.
import { runCommand } from "../keys/commands";
import { useAccounts } from "../store/useAccounts";
export function FirstRun() {
const loaded = useAccounts((s) => s.loaded);
const accounts = useAccounts((s) => s.accounts);
if (!loaded || accounts.length > 0) return null;
return (
<div className="first-run">
<div className="first-run-text">
<h2 className="first-run-title">No Google account connected</h2>
<p className="first-run-note">
Connect one and the calendars on it show up here. Nothing syncs until you do.
</p>
<button
type="button"
className="panel-button"
data-variant="primary"
onClick={() => runCommand("accounts")}
>
Connect a Google account
</button>
</div>
</div>
);
}
export default FirstRun;
+35
View File
@@ -31,6 +31,12 @@ import { openDetailsFor } from "./useDetails";
const CLICK_SLOP = 3;
const TOUCH_CLICK_SLOP = 12;
/**
* How long the click a touch leaves behind is still worth waiting for. It lands a frame or so
* after the release here, and historically as much as 300ms behind it on a mobile browser.
*/
const GHOST_CLICK_MS = 400;
interface GridEventProps {
item: Placed;
top: number;
@@ -42,6 +48,34 @@ interface GridEventProps {
onPointerDown: (e: ReactPointerEvent, item: Placed, mode: DragMode) => void;
}
/**
* Eats the click the browser sends after a touch, and only that one.
*
* Cancelling the pointerdown stops the mouse events that travel with it but never the click, and
* the click is hit tested wherever the finger is when it lands, which by then is the card this
* press has just opened. Left alone it presses whatever the card put under the finger: on a phone
* in landscape the card is the whole stage, so a tap on a meeting opened its editor or its
* conference link on its own.
*/
function swallowGhostClick(): void {
let timer = 0;
const done = () => {
window.clearTimeout(timer);
window.removeEventListener("click", eat, true);
window.removeEventListener("pointerdown", done, true);
};
const eat = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
done();
};
window.addEventListener("click", eat, true);
// The first click after a release is the ghost, and a press that starts before it arrives means
// it is never coming. The timer is only the backstop for a browser that sends neither.
window.addEventListener("pointerdown", done, true);
timer = window.setTimeout(done, GHOST_CLICK_MS);
}
function edgeMode(target: EventTarget | null): DragMode {
const el = target instanceof Element ? target.closest("[data-edge]") : null;
const edge = el?.getAttribute("data-edge");
@@ -103,6 +137,7 @@ export const GridEvent = memo(function GridEvent({
if (Math.abs(event.clientX - downX) > slop) return;
if (Math.abs(event.clientY - downY) > slop) return;
open(element);
if (event.pointerType !== "mouse") swallowGhostClick();
};
window.addEventListener("pointerup", up, true);
window.addEventListener("pointercancel", stop, true);
+9 -2
View File
@@ -430,12 +430,19 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
if (e.button !== 0 || gesture.current) return;
e.stopPropagation();
select(keyOf(item.instance));
if (item.instance.readOnly || useGrid.getState().draft) return;
const dayStart = startOfDay(item.startMs);
const index = days.findIndex((d) => d === dayStart);
if (index === -1) return;
const { startMin, endMin } = dayMinutes(item, dayStart);
begin(e, mode, item, index, startMin, endMin);
// A block nothing can be done to is still something a swipe has to travel through. The press
// is stopped here rather than on the canvas, so without this the page turn was dead over
// every read-only event, which on a day with a couple of meetings marked busy is most of the
// column. It gets a gesture with no long press behind it: the only thing it can become is
// the swipe, and there is no item on it to commit a move to.
const inert = item.instance.readOnly || useGrid.getState().draft !== null;
if (inert && !isCoarse(e)) return;
begin(e, mode, inert ? null : item, index, startMin, endMin);
if (inert && gesture.current) clearPress(gesture.current);
},
// `begin` closes over the current layout and days, which is what a fresh gesture wants.
[days, layout, select],
+1 -1
View File
@@ -93,7 +93,7 @@ export function Settings() {
<div className="setting-row">
<span className="setting-text">
<span className="setting-name">Accounts</span>
<span className="setting-name">Google accounts</span>
<span className="setting-note">
{accounts.length === 0
? "No Google account connected yet."
+1 -1
View File
@@ -30,7 +30,7 @@ const TITLES: Record<string, string> = {
calendars: "calendars",
"mini-month": "the calendar",
editor: "the event",
accounts: "accounts",
accounts: "Google accounts",
settings: "settings",
shortcuts: "shortcuts",
menu: "the menu",
+16 -2
View File
@@ -24,13 +24,26 @@ function apply(list: Instance[]): Instance[] {
});
}
/**
* A first launch, which the fixture otherwise has no way to show: it is seeded with two connected
* accounts, so the one screen somebody new actually opens on was the one screen nobody could look
* at. With this set the three reads come back empty, the way they do before anything is connected.
*/
const firstRun = (): boolean => {
try {
return localStorage.getItem("margincal-dev-empty") === "1";
} catch {
return false;
}
};
export async function mockCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const a = (args ?? {}) as Record<string, never>;
switch (command) {
case "accounts_list":
return devAccounts as unknown as T;
return (firstRun() ? [] : devAccounts) as unknown as T;
case "calendars_list":
return calendars as unknown as T;
return (firstRun() ? [] : calendars) as unknown as T;
case "calendar_set_selected": {
const id = a.calendarId as unknown as string;
const selected = a.selected as unknown as boolean;
@@ -39,6 +52,7 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
return undefined as T;
}
case "instances_range": {
if (firstRun()) return [] as unknown as T;
const from = a.fromUtc as unknown as number;
const to = a.toUtc as unknown as number;
const selected = new Set(calendars.filter((c) => c.selected).map((c) => c.id));
+10
View File
@@ -24,6 +24,14 @@ const isMobileOs =
*/
export const isDesktop = isTauri && !isMobileOs;
/**
* The one window whose title bar has the traffic lights inside the page. `titleBarStyle: "Overlay"`
* in tauri.conf.json is a macOS-only setting, so on Linux, Windows and every mobile build the
* header has nothing to leave room for.
*/
export const isMacDesktop =
isDesktop && typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
/**
* True when there is a backend to answer a command: Tauri, or the dev fixture in a browser.
* Data-loading actions gate on this. Anything touching a window API must gate on `isDesktop`
@@ -168,6 +176,8 @@ export interface AuthEvent {
error: string | null;
accountId: string | null;
email: string | null;
/** The consent browser was closed by hand. Not `ok`, but not a failure to report either. */
cancelled: boolean;
}
/** Payload of `sync-progress`. `store-changed` carries a plain reason string. */
+3 -1
View File
@@ -176,7 +176,9 @@ const TABLE: Record<CommandId, Omit<Command, "id">> = {
},
},
calendars: { label: "Calendars", palette: true, run: () => overlays().show("calendars") },
accounts: { label: "Accounts", palette: true, run: () => overlays().show("accounts") },
// Named for what it connects to rather than for the panel. "Accounts" on its own says nothing
// about whose, and this row is how somebody opening the app cold finds the thing to do first.
accounts: { label: "Google accounts", palette: true, run: () => overlays().show("accounts") },
settings: { label: "Settings", palette: true, run: () => overlays().show("settings") },
"toggle-theme": { label: "Toggle dark mode", palette: true, run: () => useTheme.getState().toggle() },
"check-updates": {
+5
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { isMacDesktop } from "./ipc";
import { trackSafeArea } from "./safeArea";
import "./styles/tokens.css";
import "./styles/fonts.css";
@@ -9,6 +10,10 @@ import "./styles/app.css";
// Before the first render, so the bars are the right height on the first paint rather than after.
trackSafeArea();
// The header's lane for the traffic lights, which only one platform draws over it. Written here
// rather than assumed by the stylesheet, for the same reason: the first paint is the right shape.
if (isMacDesktop) document.documentElement.setAttribute("data-traffic", "");
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
+19 -7
View File
@@ -10,6 +10,12 @@ type Phase = "idle" | "connecting" | "working" | "error";
interface AccountsState {
accounts: Account[];
calendars: Calendar[];
/**
* True once a list has actually come back. An empty `accounts` before that is a page that has not
* asked yet, and telling the two apart is the difference between "connect one" and a first frame
* of it on every launch.
*/
loaded: boolean;
phase: Phase;
error: string | null;
authUrl: string | null;
@@ -17,7 +23,7 @@ interface AccountsState {
refresh: () => Promise<void>;
connect: () => Promise<boolean>;
cancelConnect: () => void;
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
handleAuthEvent: (ok: boolean, error: string | null, cancelled?: boolean) => Promise<void>;
openAuthUrl: () => void;
copyAuthUrl: () => Promise<void>;
disconnect: (accountId: string) => Promise<void>;
@@ -27,6 +33,7 @@ interface AccountsState {
export const useAccounts = create<AccountsState>((set, get) => ({
accounts: [],
calendars: [],
loaded: false,
phase: "idle",
error: null,
authUrl: null,
@@ -35,7 +42,7 @@ export const useAccounts = create<AccountsState>((set, get) => ({
if (!live()) return;
try {
const [accounts, calendars] = await Promise.all([accountsList(), calendarsList()]);
set({ accounts, calendars });
set({ accounts, calendars, loaded: true });
} catch (e) {
set({ error: String(e) });
}
@@ -50,7 +57,7 @@ export const useAccounts = create<AccountsState>((set, get) => ({
.then((url) => set({ authUrl: url }))
.catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`);
notify(`Could not connect your Google account: ${e}`);
resolve(false);
});
}),
@@ -59,17 +66,22 @@ export const useAccounts = create<AccountsState>((set, get) => ({
set({ phase: "idle", authUrl: null, resolveConnect: null });
resolve?.(false);
},
handleAuthEvent: async (ok, error) => {
handleAuthEvent: async (ok, error, cancelled = false) => {
if (get().phase !== "connecting") return;
const resolve = get().resolveConnect;
if (ok) {
await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Calendar");
} else if (cancelled) {
// Closing the consent browser is an answer, not a fault. Back to idle with nothing said:
// the user already knows what they did, and a red panel telling them about it reads as
// though shutting the sheet broke something.
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
} else {
const message = error ?? "authorization failed";
set({ phase: "error", authUrl: null, error: message, resolveConnect: null });
notify(`Could not connect: ${message}`);
notify(`Could not connect your Google account: ${message}`);
}
resolve?.(ok);
},
@@ -93,10 +105,10 @@ export const useAccounts = create<AccountsState>((set, get) => ({
await accountDisconnect(accountId);
await get().refresh();
set({ phase: "idle" });
notify("Disconnected");
notify("Google account disconnected");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Could not disconnect: ${e}`);
notify(`Could not disconnect that Google account: ${e}`);
}
},
setSelected: async (calendarId, selected) => {
+48 -2
View File
@@ -103,15 +103,26 @@ select {
flex: none;
position: relative;
z-index: 45;
height: var(--titlebar-h);
/* Out of the way of a status bar or a notch, the same way the phone bars do it. A desktop reads
both insets as zero; an iPad gets this header rather than those bars and does not. */
height: calc(var(--titlebar-h) + var(--safe-top));
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 14px 0 var(--traffic-pad);
padding: var(--safe-top) 14px 0;
background: var(--shell);
border-bottom: 1px solid var(--line);
}
/* The macOS traffic lights float over this row, so it opens a lane for them and costs no extra
height. macOS is the only platform with any: they come from `titleBarStyle: "Overlay"`, which is
a macOS-only window setting, and on Linux, Windows, an iPad or a browser the lane was 84px of
nothing, pushing the view switcher off centre and starving the date range of the room it needed
to say what day it is. */
:root[data-traffic] .titlebar {
padding-left: var(--traffic-pad);
}
.titlebar .lead {
display: flex;
align-items: center;
@@ -273,9 +284,44 @@ select {
min-height: 0;
display: flex;
flex-direction: column;
/* The first-run note lays itself over whatever is here, so this is the box it covers. */
position: relative;
background: var(--paper);
}
/* Above every part of the grid, which stacks up to 15, and under the overlays, which start at 20:
the panel this opens has to come up in front of it. */
.first-run {
position: absolute;
inset: 0;
z-index: 19;
display: grid;
place-items: center;
padding: 24px;
background: var(--paper);
}
.first-run-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
max-width: 320px;
text-align: center;
}
.first-run-title {
margin: 0;
font-family: var(--font-heading);
font-weight: 500;
font-size: 18px;
}
.first-run-note {
margin: 0;
color: var(--ink-soft);
}
/* Overlays, margin's idiom: summoned by a key, dismissed with Escape, never resident. */
.overlay {
position: fixed;
+10
View File
@@ -45,6 +45,16 @@
max-height: min(480px, calc(100vh - var(--titlebar-h) - 16px));
}
/* There is no title bar here. What the card has to fit inside is the window less both bars and the
insets they pad themselves with, which is the same box the placement measures. A cap any taller
than that does not merely scroll, it gets placed over the top bar: the card is laid out first and
pinned to its block second, so a card too tall for the gap has nowhere legal to go. */
:root[data-phone] .details-card {
max-height: calc(
100dvh - var(--phonebar-h) - var(--safe-top) - var(--tabbar-h) - var(--safe-bottom) - 16px
);
}
/* The one scroll region. The footer is outside it, so however long the description runs, Edit,
Delete and Close are where they were. */
.details-body {
+249
View File
@@ -0,0 +1,249 @@
// Defects found by driving the app at sizes and with pointers nothing else in this suite uses:
// a phone on its side, a tablet, and a window whose safe areas are not zero.
//
// Same rules as the rest of the suite. Nothing reaches into the app, every assertion is something
// the browser laid out, and each test failed before the fix that sits next to it.
import { expect, test, type Page } from "@playwright/test";
import { box, openApp, settle } from "./app";
interface Point {
x: number;
y: number;
}
/**
* A finger, the same way touch.spec.ts makes one: through CDP, because those arrive as real touch
* events and only those carry `pointerType` `touch` into the handlers that read it.
*/
async function finger(page: Page) {
const session = await page.context().newCDPSession(page);
const send = (type: string, points: Point[]) =>
session.send("Input.dispatchTouchEvent", {
type,
touchPoints: points.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) })),
});
return {
down: (at: Point) => send("touchStart", [at]),
moveTo: (at: Point) => send("touchMove", [at]),
up: () => send("touchEnd", []),
};
}
async function swipe(page: Page, from: Point, dx: number): Promise<void> {
const hand = await finger(page);
await hand.down(from);
for (let step = 1; step <= 6; step++) await hand.moveTo({ x: from.x + (dx * step) / 6, y: from.y });
await hand.up();
await settle(page);
}
const dates = (page: Page) => page.locator(".grid-head-date").allTextContents();
/**
* The card's buttons that the chrome has taken over: a tap in the middle of one reaches a bar
* instead. Anything hidden by the card's own scrolling is the card's business and not this.
*/
function buriedButtons(page: Page) {
return page.evaluate(() => {
const card = document.querySelector<HTMLElement>(".details-card");
if (!card) throw new Error("no details card is open");
return [...card.querySelectorAll<HTMLElement>("button")]
.map((el) => {
const rect = el.getBoundingClientRect();
const hit = document.elementFromPoint(
Math.round(rect.left + rect.width / 2),
Math.round(rect.top + rect.height / 2),
);
const chrome = (hit as HTMLElement | null)?.closest(".tabbar, .phonebar");
return chrome
? `${(el.textContent ?? "").trim() || el.getAttribute("aria-label")} is behind the ${chrome.className}`
: null;
})
.filter((entry): entry is string => entry !== null);
});
}
// A phone on its side. 568x320 is an iPhone SE in landscape, still narrow enough for the phone
// chrome, and the one shape where the two bars leave the least behind.
test.describe("a phone in landscape", () => {
test.use({ viewport: { width: 568, height: 320 }, hasTouch: true, isMobile: true });
// The card was placed against the window and painted under the bars, which put Edit, Delete and
// Close behind the tab bar: not merely covered, unhittable.
test("the details card stays clear of the tab bar", async ({ page }) => {
await openApp(page, { view: "day" });
await page.locator(".grid-event", { hasText: "Design review" }).first().tap();
await expect(page.locator(".details-card[data-placed]")).toBeVisible();
await settle(page);
const card = await box(page.locator(".details-card"));
const tabs = await box(page.locator(".tabbar"));
const bar = await box(page.locator(".phonebar"));
expect(card.bottom).toBeLessThanOrEqual(tabs.top);
expect(card.top).toBeGreaterThanOrEqual(bar.bottom);
expect(await buriedButtons(page)).toEqual([]);
});
// The card opens where the finger already is, and a touch is followed by a click the finger never
// asked for. Cancelling the pointerdown stops the mouse events either side of it and not that
// click, so it landed on whatever the card had just put under the thumb: tapping a meeting opened
// its editor, and one with a conference link would have opened a browser.
test("a tap opens the card and nothing else", async ({ page }) => {
await openApp(page, { view: "day" });
await page.locator(".grid-event", { hasText: "Design review" }).first().tap();
await expect(page.locator(".details-card")).toBeVisible();
await settle(page);
await expect(page.getByRole("dialog", { name: "Edit event" })).toHaveCount(0);
await expect(page.locator(".details-card")).toBeVisible();
});
});
test.describe("a phone", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
// The swipe is the way a phone pages the day, and a block you cannot pick up used to swallow it:
// the block's own handler stopped the press reaching the canvas and then returned. On a day with
// a couple of meetings marked busy that is most of the column.
test("a swipe still pages the day when it starts on a read-only block", async ({ page }) => {
await openApp(page, { view: "day" });
const before = await dates(page);
const block = page.locator(".grid-event[data-readonly]").first();
const rect = await box(block);
await swipe(page, { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, -160);
expect(await dates(page)).not.toEqual(before);
// And it paged instead of picking the block up, rather than as well as.
await expect(page.locator(".grid-ghost")).toHaveCount(0);
await expect(page.locator(".quick-create")).toHaveCount(0);
});
// A read-only block is still a block: the swipe must not have cost it its tap.
test("tapping a read-only block still opens it", async ({ page }) => {
await openApp(page, { view: "day" });
await page.locator(".grid-event[data-readonly]").first().tap();
await expect(page.locator(".details-card")).toBeVisible();
});
});
// The desktop header, on the platforms that are not macOS. Both of these are wrong everywhere
// except the one window the header was drawn in, and an iPad is now one of the places it lands.
test.describe("the desktop header off macOS", () => {
// The 84px lane exists for the macOS traffic lights, which come from `titleBarStyle: "Overlay"`,
// a macOS-only setting. Everywhere else, Linux and Windows and an iPad and this browser, it was
// 84px of nothing that pushed the whole row off centre and starved the date range.
test("there is no lane for traffic lights that do not exist", async ({ page }) => {
await openApp(page);
const lead = await box(page.locator(".titlebar .lead"));
const views = await box(page.locator(".view-switch"));
const width = page.viewportSize()!.width;
expect(lead.left).toBeLessThanOrEqual(16);
// The view switcher is the middle column of the row, so off centre means the row is padded for
// something that is not there.
expect(Math.abs((views.left + views.right) / 2 - width / 2)).toBeLessThanOrEqual(2);
// And the lane is still there for the one window that has traffic lights in it. `main.tsx`
// writes this attribute on a macOS desktop build and nowhere else.
await page.evaluate(() => document.documentElement.setAttribute("data-traffic", ""));
await settle(page);
expect((await box(page.locator(".titlebar .lead"))).left).toBeGreaterThan(64);
});
// `--safe-top` is what the phone bars pad themselves with, and it is not zero on an iPad, which
// gets this header rather than those bars. The header claimed the top of the screen regardless,
// which puts its controls under the status bar.
test("the header clears the safe area above it", async ({ page }) => {
await openApp(page);
const before = await box(page.locator(".titlebar"));
// Written the same way the Android bridge writes it in src/safeArea.ts, which is the one thing
// that ever sets these at runtime.
await page.evaluate(() => document.documentElement.style.setProperty("--safe-top", "44px"));
await settle(page);
const after = await box(page.locator(".titlebar"));
expect(after.height).toBeCloseTo(before.height + 44, 0);
const controls = await page.locator(".titlebar button").all();
for (const control of controls) {
const rect = await box(control);
expect(rect.top).toBeGreaterThanOrEqual(44);
}
});
});
// A launch with nothing connected. The fixture ships two accounts, so this state existed and was
// never looked at: an empty grid, which is exactly what a quiet week looks like, and no hint that
// the thing to do is behind a key on the desktop and an overflow sheet on a phone.
test.describe("a first launch", () => {
// Not `openApp`: it waits for an event to be on screen, and the whole point of this state is that
// there are none. The seed is otherwise the same.
const openEmpty = async (page: Page) => {
await page.addInitScript(() => {
localStorage.clear();
localStorage.setItem("margincal-theme", "light");
localStorage.setItem("margincal-view", "week");
localStorage.setItem("margincal-dev-empty", "1");
});
await page.goto("/");
};
test("the empty calendar says what to do next", async ({ page }) => {
await openEmpty(page);
const note = page.locator(".first-run");
await expect(note).toBeVisible();
await expect(note).toContainText("Google account");
// And the way in works from here, rather than only naming the thing.
await note.getByRole("button", { name: "Connect a Google account" }).click();
await expect(page.getByRole("dialog", { name: "Google accounts" })).toBeVisible();
});
test("it is gone as soon as there is an account", async ({ page }) => {
await openApp(page);
await expect(page.locator(".first-run")).toHaveCount(0);
});
});
// The phone is the tightest thing any of this copy has to fit in, and a button that wraps is the
// tell that a label got longer than the chrome it lives in.
test.describe("account copy on the smallest phone", () => {
test.use({ viewport: { width: 320, height: 568 }, hasTouch: true, isMobile: true });
test("no account control wraps or spills", async ({ page }) => {
await openApp(page, { view: "day" });
await page.getByRole("button", { name: "Menu" }).click();
await page.locator(".menu-item", { hasText: "Google accounts" }).click();
const panel = page.getByRole("dialog", { name: "Google accounts" });
await expect(panel).toBeVisible();
// A `.panel-button` never wraps, so a label that outgrows the sheet does not get taller, it
// runs out of its row and is clipped by the panel. Judged on the laid-out box against the row
// it sits in rather than on the length of the string.
const spilled = await page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>(".panel button, .menu-item, .first-run button")]
.map((el) => {
const row = el.parentElement?.getBoundingClientRect();
if (!row) return null;
const box = el.getBoundingClientRect();
const past = Math.max(Math.round(box.right - row.right), Math.round(row.left - box.left));
return past > 1 ? `${(el.textContent ?? "").trim()} runs ${past}px past its row` : null;
})
.filter((entry): entry is string => entry !== null),
);
expect(spilled).toEqual([]);
const spill = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(spill).toBeLessThanOrEqual(0);
});
});
+3 -3
View File
@@ -17,12 +17,12 @@ const PANELS = [
{ name: "Calendars", open: openCalendars, contains: /you@example\.com/ },
{ name: "Settings", open: openSettings, contains: /Week view/ },
{
name: "Accounts",
name: "Google accounts",
open: async (page: Page) => {
await openSettings(page);
await page.getByRole("button", { name: "Manage" }).click();
},
contains: /Connect an account/,
contains: /Connect a Google account/,
},
];
@@ -71,7 +71,7 @@ test("Escape unwinds one layer at a time", async ({ page }) => {
await openSettings(page);
await page.getByRole("button", { name: "Manage" }).click();
const accounts = page.getByRole("dialog", { name: "Accounts" });
const accounts = page.getByRole("dialog", { name: "Google accounts" });
await expect(accounts).toBeVisible();
await page.getByRole("button", { name: "Disconnect" }).first().click();
+1 -1
View File
@@ -88,7 +88,7 @@ test("the tab bar switches the view", async ({ page }) => {
const ROWS: { label: string; opens: string }[] = [
{ label: "Calendars", opens: "Calendars" },
{ label: "Settings", opens: "Settings" },
{ label: "Accounts", opens: "Accounts" },
{ label: "Google accounts", opens: "Google accounts" },
{ label: "Keyboard shortcuts", opens: "Keyboard shortcuts" },
];