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

+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 {