Margin Calendar: a Google Calendar client for desktop and phone

Tauri 2, React 19 and zustand on the front, Rust behind. Rust owns auth,
all HTTP to Google, the SQLite store, the sync loop, recurrence expansion
and timezone maths. TypeScript owns rendering and never talks to Google,
which keeps the content security policy locked to ipc:.

Week, day and agenda views, and no month view: it would be a second layout
engine, and the fit and fold logic that makes a day fit the window without
scrolling is the whole point of the app.

Runs on macOS, Linux, Android and iOS. Desktop catches Google's OAuth
redirect on a loopback port. A phone cannot, and Google rejects loopback
for mobile client types anyway, so it redirects to a custom URI scheme and
needs its own public OAuth clients, which docs/mobile.md covers. Refresh
tokens are sealed with XChaCha20-Poly1305 in the app data directory on
every platform, with no OS credential store in the picture.

On a phone the chrome becomes a top bar and a bottom tab bar, overlays
become sheets, hover affordances become taps, and dragging out an event
waits for a long press. Navigation moves one day at a time everywhere,
a swipe included.
This commit is contained in:
pj committed 2026-08-12 17:09:21 +05:30
commit 661100dfdc
243 files changed
+35200

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
// The IPC contract. Every type here has a matching declaration in src/ipc.ts. Both sides are
// frozen once written: implementation modules add bodies, not fields.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Account {
pub id: String,
pub email: String,
pub connected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Calendar {
pub id: String,
pub account_id: String,
pub summary: String,
pub description: Option<String>,
pub color_hex: String,
pub selected: bool,
pub access_role: String,
pub time_zone: String,
pub primary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attendee {
pub email: String,
pub display_name: Option<String>,
/// needsAction | declined | tentative | accepted
pub response_status: String,
pub organizer: bool,
#[serde(rename = "self")]
pub is_self: bool,
pub optional: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Conference {
/// hangoutsMeet, addOn, and so on
pub kind: String,
pub uri: Option<String>,
pub label: Option<String>,
}
/// Identifies one occurrence of a series, stable across syncs. `original_start` is None for a
/// single event and the unmoved start of the occurrence for anything recurring.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstanceKey {
pub event_id: String,
pub original_start: Option<String>,
}
/// One event occurrence, already expanded and already converted to the local zone. `start` and
/// `end` are RFC3339 with an offset, except when `all_day`, where they are date-only `YYYY-MM-DD`
/// and must never be shifted into a zone. `start_ms` and `end_ms` are epoch milliseconds, with
/// all-day events pinned to local midnight, and `end_ms` is exclusive.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Instance {
pub event_id: String,
pub calendar_id: String,
pub account_id: String,
pub original_start: Option<String>,
pub start: String,
pub end: String,
pub start_ms: i64,
pub end_ms: i64,
pub all_day: bool,
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
/// confirmed | tentative | cancelled
pub status: String,
pub recurring: bool,
/// Resolved for rendering: the event's own colour when it has one, else its calendar's.
pub color_hex: String,
/// Google's per-event colour id, 1 to 11. None means the event follows its calendar.
pub color_id: Option<String>,
pub etag: Option<String>,
pub organizer: Option<String>,
pub attendees: Vec<Attendee>,
pub conference: Option<Conference>,
pub read_only: bool,
/// Written locally and still sitting in the outbox.
pub pending: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventDraft {
pub calendar_id: String,
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
pub start: String,
pub end: String,
pub all_day: bool,
/// Raw RFC5545 lines (RRULE/RDATE/EXDATE), empty for a one-off.
#[serde(default)]
pub recurrence: Vec<String>,
/// Google's per-event colour id, 1 to 11. Absent means follow the calendar.
#[serde(default)]
pub color_id: Option<String>,
}
/// An absent field means unchanged. An empty string on `description` or `location` clears it.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventPatch {
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub start: Option<String>,
#[serde(default)]
pub end: Option<String>,
#[serde(default)]
pub all_day: Option<bool>,
#[serde(default)]
pub calendar_id: Option<String>,
#[serde(default)]
pub recurrence: Option<Vec<String>>,
/// An empty string clears it back to the calendar's colour.
#[serde(default)]
pub color_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Scope {
This,
Following,
All,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncStatus {
/// idle | syncing | error
pub phase: String,
/// Epoch milliseconds of the last successful sync.
pub last_sync: Option<i64>,
pub error: Option<String>,
pub pending_writes: u32,
pub message: Option<String>,
}
impl Default for SyncStatus {
fn default() -> Self {
SyncStatus {
phase: "idle".to_string(),
last_sync: None,
error: None,
pending_writes: 0,
message: None,
}
}
}
/// Payload of the `auth` event, the same shape as margin's `gdrive-auth`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthEvent {
pub ok: bool,
pub error: Option<String>,
pub account_id: Option<String>,
pub email: Option<String>,
}
+476
View File
@@ -0,0 +1,476 @@
// Typed wrapper over the Google Calendar REST API.
//
// Everything the sync engine and the write path need:
// calendar_list(access, sync_token, page_token) -> CalendarListPage
// events_list(access, calendar_id, sync_token, page) -> EventsPage (one page; caller pages)
// events_insert / events_patch / events_delete, all carrying If-Match where an etag is known
// events_instances(access, calendar_id, event_id, original_start) -> Vec<RawEvent>
//
// The sync chain constraint: `singleEvents=false`, `showDeleted=true`, `maxResults=2500`, and the
// parameter set byte-identical on every call in a chain including the first. `timeMin`/`timeMax`
// are rejected alongside `syncToken`.
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::google::auth::HTTP;
const BASE: &str = "https://www.googleapis.com/calendar/v3";
/// One page of events. Google caps this at 2500 and the value is part of the sync chain, so it is
/// a constant rather than an argument.
pub const MAX_RESULTS: &str = "2500";
/// A Google event exactly as returned, before flattening into the store.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawEvent {
pub id: String,
#[serde(default)]
pub etag: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub start: Option<EventDateTime>,
#[serde(default)]
pub end: Option<EventDateTime>,
#[serde(default)]
pub recurrence: Option<Vec<String>>,
#[serde(default)]
pub recurring_event_id: Option<String>,
#[serde(default)]
pub original_start_time: Option<EventDateTime>,
#[serde(default)]
pub organizer: Option<serde_json::Value>,
#[serde(default)]
pub attendees: Option<serde_json::Value>,
#[serde(default)]
pub conference_data: Option<serde_json::Value>,
#[serde(default)]
pub updated: Option<String>,
#[serde(default)]
pub transparency: Option<String>,
#[serde(default)]
pub color_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventDateTime {
/// Date-only, for an all-day event. Never shift this into a zone.
#[serde(default)]
pub date: Option<String>,
/// RFC3339 with an offset.
#[serde(default)]
pub date_time: Option<String>,
#[serde(default)]
pub time_zone: Option<String>,
}
/// One page of `events.list`. `next_sync_token` is present only on the final page.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventsPage {
#[serde(default)]
pub items: Vec<RawEvent>,
#[serde(default)]
pub next_page_token: Option<String>,
#[serde(default)]
pub next_sync_token: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawCalendar {
pub id: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub background_color: Option<String>,
#[serde(default)]
pub access_role: Option<String>,
#[serde(default)]
pub time_zone: Option<String>,
#[serde(default)]
pub primary: Option<bool>,
#[serde(default)]
pub selected: Option<bool>,
#[serde(default)]
pub deleted: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarListPage {
#[serde(default)]
pub items: Vec<RawCalendar>,
#[serde(default)]
pub next_page_token: Option<String>,
#[serde(default)]
pub next_sync_token: Option<String>,
}
/// One page of `events.instances`.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InstancesPage {
#[serde(default)]
items: Vec<RawEvent>,
}
/// A sync token that Google has expired. The caller drops that one calendar's rows and cursor and
/// full-resyncs it alone.
#[derive(Debug, Clone)]
pub enum ApiError {
/// HTTP 410, the sync token is dead.
SyncTokenExpired,
/// HTTP 412, the etag did not match, so someone else won.
PreconditionFailed,
/// Network reachability, as opposed to a rejection from Google.
Offline(String),
Other(String),
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::SyncTokenExpired => write!(f, "sync token expired"),
ApiError::PreconditionFailed => write!(f, "the event changed elsewhere"),
ApiError::Offline(e) => write!(f, "offline: {e}"),
ApiError::Other(e) => write!(f, "{e}"),
}
}
}
impl From<ApiError> for String {
fn from(e: ApiError) -> String {
e.to_string()
}
}
/// A failure to reach Google at all is Offline. Anything reqwest raises after a response has
/// arrived is a real error and keeps its text.
impl From<reqwest::Error> for ApiError {
fn from(e: reqwest::Error) -> ApiError {
if e.is_connect() || e.is_timeout() || e.is_request() || e.is_body() {
ApiError::Offline(e.to_string())
} else {
ApiError::Other(e.to_string())
}
}
}
/// Google's error body is a wall of JSON carrying the same sentence three times over. The one
/// useful line is `error.message`, so pull it out and keep the rest out of the user's face. A body
/// that will not parse is truncated rather than pasted whole.
fn explain(body: &str) -> String {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(message) = value
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
{
return message.to_string();
}
}
let trimmed = body.trim();
match trimmed.char_indices().nth(200) {
Some((end, _)) => format!("{}…", &trimmed[..end]),
None => trimmed.to_string(),
}
}
fn error_for(status: u16, context: &str, body: &str) -> ApiError {
match status {
410 => ApiError::SyncTokenExpired,
412 => ApiError::PreconditionFailed,
_ => ApiError::Other(format!("{context} failed ({status}): {}", explain(body))),
}
}
/// The same body-to-String-first shape as `auth::read_json`, so Google's error payload survives
/// into the message, but keeping the status so the sync engine can tell a 410 from a 412.
async fn read<T: DeserializeOwned>(resp: reqwest::Response, context: &str) -> Result<T, ApiError> {
let status = resp.status().as_u16();
let text = resp.text().await?;
if !(200..300).contains(&status) {
return Err(error_for(status, context, &text));
}
serde_json::from_str(&text)
.map_err(|e| ApiError::Other(format!("{context}: could not parse response: {e}")))
}
/// Calendar ids are email shaped and can carry a '#', so they are percent-encoded into the path.
/// Not `form_urlencoded`, which would turn a space into a '+'.
fn path_segment(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
/// The one definition of the sync chain's parameter set. Every call in a chain, including the
/// first, sends exactly this, which is what makes the chain valid.
fn events_params() -> Vec<(&'static str, &'static str)> {
vec![
("singleEvents", "false"),
("showDeleted", "true"),
("maxResults", MAX_RESULTS),
]
}
fn calendar_params() -> Vec<(&'static str, &'static str)> {
vec![
("showDeleted", "true"),
("showHidden", "true"),
("maxResults", "250"),
]
}
pub async fn calendar_list(
access_token: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> Result<CalendarListPage, ApiError> {
let mut query = calendar_params();
if let Some(token) = sync_token {
query.push(("syncToken", token));
}
if let Some(token) = page_token {
query.push(("pageToken", token));
}
let resp = HTTP
.get(format!("{BASE}/users/me/calendarList"))
.bearer_auth(access_token)
.query(&query)
.send()
.await?;
read(resp, "Google calendar list").await
}
/// One page. The caller walks `next_page_token` to exhaustion, because `next_sync_token` only
/// appears on the final page and nothing may be committed before it does. Both tokens are passed
/// through when both are given, which is what the official clients do with `list_next`.
pub async fn events_list(
access_token: &str,
calendar_id: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> Result<EventsPage, ApiError> {
let mut query = events_params();
if let Some(token) = sync_token {
query.push(("syncToken", token));
}
if let Some(token) = page_token {
query.push(("pageToken", token));
}
let resp = HTTP
.get(format!(
"{BASE}/calendars/{}/events",
path_segment(calendar_id)
))
.bearer_auth(access_token)
.query(&query)
.send()
.await?;
read(resp, "Google events list").await
}
pub async fn events_insert(
access_token: &str,
calendar_id: &str,
body: &serde_json::Value,
) -> Result<RawEvent, ApiError> {
let resp = HTTP
.post(format!(
"{BASE}/calendars/{}/events",
path_segment(calendar_id)
))
.bearer_auth(access_token)
.json(body)
.send()
.await?;
read(resp, "Google event create").await
}
/// `etag` becomes an If-Match, so a 412 tells the caller someone else changed the event first
/// rather than the write silently clobbering them.
pub async fn events_patch(
access_token: &str,
calendar_id: &str,
event_id: &str,
body: &serde_json::Value,
etag: Option<&str>,
) -> Result<RawEvent, ApiError> {
let mut request = HTTP
.patch(format!(
"{BASE}/calendars/{}/events/{}",
path_segment(calendar_id),
path_segment(event_id)
))
.bearer_auth(access_token)
.json(body);
if let Some(etag) = etag {
request = request.header(reqwest::header::IF_MATCH, etag);
}
let resp = request.send().await?;
read(resp, "Google event update").await
}
/// Idempotent by design: Google answers 410 for an event that is already gone, and a delete of
/// something already deleted is the outcome the caller wanted. That is the one place where a 410
/// is not a dead sync token.
pub async fn events_delete(
access_token: &str,
calendar_id: &str,
event_id: &str,
etag: Option<&str>,
) -> Result<(), ApiError> {
let mut request = HTTP
.delete(format!(
"{BASE}/calendars/{}/events/{}",
path_segment(calendar_id),
path_segment(event_id)
))
.bearer_auth(access_token);
if let Some(etag) = etag {
request = request.header(reqwest::header::IF_MATCH, etag);
}
let resp = request.send().await?;
let status = resp.status().as_u16();
if (200..300).contains(&status) || status == 404 || status == 410 {
return Ok(());
}
let text = resp.text().await.unwrap_or_default();
Err(error_for(status, "Google event delete", &text))
}
/// Resolves the concrete instance of a series at `original_start`, which is how a "this occurrence"
/// edit finds its event rather than constructing the instance id by hand. The filter narrows the
/// result to that one occurrence, so there is nothing to paginate.
pub async fn events_instances(
access_token: &str,
calendar_id: &str,
event_id: &str,
original_start: &str,
) -> Result<Vec<RawEvent>, ApiError> {
let resp = HTTP
.get(format!(
"{BASE}/calendars/{}/events/{}/instances",
path_segment(calendar_id),
path_segment(event_id)
))
.bearer_auth(access_token)
.query(&[
("originalStart", original_start),
("showDeleted", "true"),
("maxResults", "250"),
])
.send()
.await?;
let page: InstancesPage = read(resp, "Google event instances").await?;
Ok(page.items)
}
#[cfg(test)]
mod tests {
use super::*;
/// The real 403 body that reached the UI as a wall of JSON: the same sentence repeated in
/// `errors`, `details` and a localised copy. Only the first sentence is worth showing.
#[test]
fn a_google_error_body_is_reduced_to_its_message() {
let body = r#"{"error":{"code":403,
"message":"Google Calendar API has not been used in project 205537985128 before or it is disabled.",
"errors":[{"message":"Google Calendar API has not been used in project 205537985128 before or it is disabled.","domain":"usageLimits","reason":"accessNotConfigured"}],
"status":"PERMISSION_DENIED",
"details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"SERVICE_DISABLED"}]}}"#;
let ApiError::Other(message) = error_for(403, "Google calendar list", body) else {
panic!("a 403 is an Other");
};
assert_eq!(
message,
"Google calendar list failed (403): Google Calendar API has not been used in project 205537985128 before or it is disabled."
);
assert!(!message.contains("PERMISSION_DENIED"));
}
#[test]
fn an_unparseable_body_is_truncated_rather_than_pasted_whole() {
let body = "x".repeat(5000);
let ApiError::Other(message) = error_for(500, "Google events list", &body) else {
panic!("a 500 is an Other");
};
assert!(message.chars().count() < 260, "was {}", message.chars().count());
assert!(message.ends_with('…'));
}
#[test]
fn a_410_is_a_dead_sync_token() {
assert!(matches!(
error_for(410, "Google events list", "{}"),
ApiError::SyncTokenExpired
));
}
#[test]
fn a_412_is_a_lost_race() {
assert!(matches!(
error_for(412, "Google event update", "{}"),
ApiError::PreconditionFailed
));
}
#[test]
fn anything_else_carries_googles_own_body() {
let error = error_for(403, "Google events list", "rateLimitExceeded");
match error {
ApiError::Other(message) => {
assert!(message.contains("403"), "{message}");
assert!(message.contains("rateLimitExceeded"), "{message}");
}
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn the_sync_chain_parameters_are_fixed_and_carry_no_window() {
let params = events_params();
assert_eq!(
params,
vec![
("singleEvents", "false"),
("showDeleted", "true"),
("maxResults", "2500"),
]
);
assert!(params.iter().all(|(key, _)| *key != "timeMin" && *key != "timeMax"));
}
#[test]
fn calendar_ids_are_percent_encoded_into_the_path() {
assert_eq!(
path_segment("[email protected]"),
"a.person%40example.com"
);
assert_eq!(
path_segment("en.uk#[email protected]"),
"en.uk%23holiday%40group.v.calendar.google.com"
);
assert_eq!(path_segment("primary"), "primary");
}
}
+851
View File
@@ -0,0 +1,851 @@
// The OAuth desktop flow, ported from margin/src-tauri/src/gdrive.rs with three fixes:
//
// 1. HTTP gets real timeouts. margin's `LazyLock::new(reqwest::Client::new)` has none, which a
// polling client cannot afford.
// 2. valid_access_token is single-flight. N concurrent calendar syncs must not all refresh.
// 3. Disconnect wipes the whole store for that account, not just the token, so reconnecting as
// 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};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rand::RngCore;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use tauri::{Emitter, Manager};
use tokio::sync::Mutex;
use crate::dto::{Account, AuthEvent};
use crate::google::secrets;
pub const SCOPES: &str = "openid email https://www.googleapis.com/auth/calendar";
#[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.
#[cfg(mobile)]
pub const PENDING_TIMEOUT_SECS: u64 = 900;
/// Refresh this many seconds before Google would expire the token, so a request in flight when the
/// clock rolls over does not come back 401.
const EXPIRY_SKEW_SECS: u64 = 60;
const CREDENTIALS_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/google-credentials.json"));
pub static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.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.
#[derive(Deserialize)]
struct CredentialsFile {
installed: Credentials,
#[serde(default)]
android: Option<Credentials>,
#[serde(default)]
ios: Option<Credentials>,
}
#[derive(Deserialize)]
pub struct Credentials {
pub client_id: String,
/// Absent for Android and iOS clients. A public client has nothing to keep secret, so PKCE is
/// the only thing standing between an intercepted code and a token, which is why the verifier
/// is not optional anywhere in this file.
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default = "default_auth_uri")]
pub auth_uri: String,
#[serde(default = "default_token_uri")]
pub token_uri: String,
/// Mobile only, and only when Google's console shows something other than the default below.
#[serde(default)]
pub redirect_uri: Option<String>,
}
fn default_auth_uri() -> String {
"https://accounts.google.com/o/oauth2/auth".to_string()
}
fn default_token_uri() -> String {
"https://oauth2.googleapis.com/token".to_string()
}
/// The custom URI scheme an Android build redirects to. It is the package name, which is Google's
/// documented form for an Android client and, unlike the reversed client id, is known at build
/// time, so it can sit in AndroidManifest.xml rather than being pasted in per install.
#[cfg(target_os = "android")]
pub const ANDROID_REDIRECT: &str = "studio.margin.calendar:/oauth2redirect";
/// iOS gets no such choice: Google requires the reversed client id, so the scheme is only knowable
/// once the client id is. `docs/mobile.md` says which line of Info.plist to put it on.
#[cfg(target_os = "ios")]
fn reversed_client_id(client_id: &str) -> String {
let base = client_id
.strip_suffix(".apps.googleusercontent.com")
.unwrap_or(client_id);
format!("com.googleusercontent.apps.{base}:/oauth2redirect")
}
const NOT_SET_UP: &str = "Google Calendar is not set up yet. Add a real OAuth client to google-credentials.json and rebuild.";
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.",
)?;
#[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.",
)?;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let creds = parsed.installed;
if creds.client_id.starts_with("YOUR_CLIENT_ID")
|| creds
.client_secret
.as_deref()
.is_some_and(|secret| secret.starts_with("YOUR_CLIENT_SECRET"))
{
return Err(NOT_SET_UP.to_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.
#[cfg(mobile)]
pub fn redirect_uri(creds: &Credentials) -> String {
if let Some(explicit) = &creds.redirect_uri {
return explicit.clone();
}
#[cfg(target_os = "android")]
{
ANDROID_REDIRECT.to_string()
}
#[cfg(not(target_os = "android"))]
{
reversed_client_id(&creds.client_id)
}
}
/// Reads the body to a String first so Google's error payload survives into the message.
/// Ported from gdrive.rs:286.
pub async fn read_json<T: DeserializeOwned>(
resp: reqwest::Response,
context: &str,
) -> Result<T, String> {
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("{context} failed ({status}): {text}"));
}
serde_json::from_str(&text).map_err(|e| format!("{context}: could not parse response: {e}"))
}
#[derive(Default)]
pub struct Session {
pub access_token: Option<String>,
pub access_expiry: u64,
pub email: Option<String>,
}
/// A consent round trip that has left for the browser and not come back.
///
/// Desktop does not need this: the loopback listener holds the verifier on its own stack for the
/// two minutes it is alive. Mobile has no listener. The browser is a separate app, this process
/// may be backgrounded while the user consents, and the answer arrives later as a deep link with
/// nothing but a code and a state parameter, so the verifier has to be waiting for it here.
#[cfg(mobile)]
pub struct Pending {
pub state: String,
pub verifier: String,
pub redirect: String,
pub expires: u64,
}
/// One entry per connected account. The outer Mutex is tokio's, not std's, because
/// `valid_access_token` holds it across the refresh await to keep refresh single-flight.
#[derive(Default)]
pub struct AuthState {
pub sessions: Mutex<HashMap<String, Session>>,
#[cfg(mobile)]
pub pending: Mutex<Option<Pending>>,
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn random_b64(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
URL_SAFE_NO_PAD.encode(buf)
}
fn pkce_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
}
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>\
<body style=\"font-family:system-ui,sans-serif;text-align:center;padding-top:80px;color:#222\">\
<h2>{message}</h2></body></html>"
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
#[cfg(desktop)]
#[derive(Debug, PartialEq, Eq)]
enum Redirect {
Code(String),
Denied(String),
Mismatch,
/// Anything else the browser asked for on the way, including the favicon.
Waiting,
}
#[cfg(desktop)]
fn request_path(request: &str) -> &str {
request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("")
}
#[cfg(desktop)]
fn parse_redirect(path: &str, expected_state: &str) -> Redirect {
if path == "/favicon.ico" {
return Redirect::Waiting;
}
let parsed = match url::Url::parse(&format!("http://127.0.0.1{path}")) {
Ok(parsed) => parsed,
Err(_) => return Redirect::Waiting,
};
let mut code = None;
let mut state = None;
let mut error = None;
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"code" => code = Some(value.into_owned()),
"state" => state = Some(value.into_owned()),
"error" => error = Some(value.into_owned()),
_ => {}
}
}
if let Some(error) = error {
return Redirect::Denied(error);
}
match (code, state) {
(Some(code), Some(state)) if state == expected_state => Redirect::Code(code),
(Some(_), _) => Redirect::Mismatch,
_ => Redirect::Waiting,
}
}
#[cfg(desktop)]
fn await_code(
listener: TcpListener,
expected_state: &str,
deadline: Instant,
) -> Result<String, String> {
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
loop {
if Instant::now() > deadline {
return Err("Timed out waiting for Google authorization.".to_string());
}
match listener.accept() {
Ok((mut stream, _)) => {
stream.set_nonblocking(false).ok();
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
match parse_redirect(request_path(&request), expected_state) {
Redirect::Code(code) => {
write_http_message(
&mut stream,
"Connected to Margin Calendar. You can close this tab.",
);
return Ok(code);
}
Redirect::Denied(error) => {
write_http_message(
&mut stream,
"Authorization was cancelled. You can close this tab.",
);
return Err(format!("Google authorization failed: {error}"));
}
Redirect::Mismatch => {
write_http_message(
&mut stream,
"Could not verify the request. You can close this tab.",
);
return Err("State mismatch during Google authorization.".to_string());
}
Redirect::Waiting => write_http_message(&mut stream, "Waiting for Google…"),
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(150));
}
Err(e) => return Err(e.to_string()),
}
}
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: u64,
#[serde(default)]
id_token: Option<String>,
}
#[derive(Deserialize)]
struct UserInfo {
#[serde(default)]
email: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct IdClaims {
/// Google's stable user id. It survives an email change, which the email does not.
#[serde(default)]
sub: Option<String>,
#[serde(default)]
email: Option<String>,
}
/// The id_token arrived over TLS straight from Google's token endpoint, so verifying its signature
/// would only re-prove what the transport already proved. Only the payload is read.
fn id_token_claims(id_token: &str) -> Option<IdClaims> {
let payload = id_token.split('.').nth(1)?;
let bytes = URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// Google rejects an empty `client_secret` rather than ignoring it, so a public mobile client has
/// to omit the field entirely rather than send a blank one.
fn with_secret<'a>(
creds: &'a Credentials,
mut form: Vec<(&'a str, &'a str)>,
) -> Vec<(&'a str, &'a str)> {
if let Some(secret) = creds.client_secret.as_deref() {
form.push(("client_secret", secret));
}
form
}
async fn exchange_code(
creds: &Credentials,
code: &str,
redirect: &str,
verifier: &str,
) -> Result<TokenResponse, String> {
let form = with_secret(
creds,
vec![
("client_id", creds.client_id.as_str()),
("code", code),
("code_verifier", verifier),
("grant_type", "authorization_code"),
("redirect_uri", redirect),
],
);
let resp = HTTP
.post(&creds.token_uri)
.form(&form)
.send()
.await
.map_err(|e| e.to_string())?;
read_json(resp, "Google token exchange").await
}
async fn refresh_access_token(
creds: &Credentials,
refresh_token: &str,
) -> Result<TokenResponse, String> {
let form = with_secret(
creds,
vec![
("client_id", creds.client_id.as_str()),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
],
);
let resp = HTTP
.post(&creds.token_uri)
.form(&form)
.send()
.await
.map_err(|e| e.to_string())?;
read_json(resp, "Google token refresh").await
}
async fn fetch_email(access_token: &str) -> Result<String, String> {
let resp = HTTP
.get("https://www.googleapis.com/oauth2/v2/userinfo")
.bearer_auth(access_token)
.send()
.await
.map_err(|e| e.to_string())?;
let info: UserInfo = read_json(resp, "Google account lookup").await?;
Ok(info.email.unwrap_or_default())
}
async fn revoke(token: &str) {
let _ = HTTP
.post("https://oauth2.googleapis.com/revoke")
.form(&[("token", token)])
.send()
.await;
}
/// Takes the store's connection for one synchronous unit of work. Nothing here may await: the
/// guard is a std one, so holding it across a suspension point would make the future non-Send.
fn with_conn<T>(
app: &tauri::AppHandle,
f: impl FnOnce(&rusqlite::Connection) -> Result<T, String>,
) -> Result<T, String> {
let store = app
.try_state::<crate::store::Store>()
.ok_or("the local store is not open")?;
let conn = store.conn.lock().map_err(|e| e.to_string())?;
f(&conn)
}
/// Reads the row's `keychain_ref` and stops there. It deliberately does NOT open the token store
/// to confirm the secret is still present.
///
/// This runs on every `store-changed`, which sync emits on every pass, so a probe here is a file
/// read and a key derivation once a minute for an answer nothing acts on. If the secret really has
/// gone, the next `valid_access_token` says so and the failure surfaces as a sync error, which is
/// the honest place to learn it.
pub async fn list_accounts(
app: &tauri::AppHandle,
_state: &tauri::State<'_, AuthState>,
) -> Result<Vec<Account>, String> {
with_conn(app, crate::store::read::accounts)
}
/// The consent URL. Identical on every platform apart from the redirect it asks Google to come
/// back to, which is the whole of the difference between the desktop and mobile flows.
///
/// select_account on top of margin's consent prompt, because adding a second account is the whole
/// point of the accounts list and Google would otherwise silently reuse the signed-in one.
fn auth_url(creds: &Credentials, redirect: &str, challenge: &str, csrf: &str) -> String {
format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&state={}&access_type=offline&prompt={}",
creds.auth_uri,
urlencode(&creds.client_id),
urlencode(redirect),
urlencode(SCOPES),
challenge,
urlencode(csrf),
urlencode("select_account consent"),
)
}
/// 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) {
use tauri_plugin_opener::OpenerExt;
let _ = app.opener().open_url(url.to_string(), None::<&str>);
}
fn emit_auth(app: &tauri::AppHandle, outcome: Result<(String, String), String>) {
let event = match outcome {
Ok((account_id, email)) => {
crate::emit_store_changed(app, "account-connected");
AuthEvent {
ok: true,
error: None,
account_id: Some(account_id),
email: Some(email),
}
}
Err(error) => AuthEvent {
ok: false,
error: Some(error),
account_id: None,
email: None,
},
};
let _ = app.emit("auth", event);
}
#[cfg(desktop)]
pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
let creds = load_credentials()?;
let verifier = random_b64(64);
let challenge = pkce_challenge(&verifier);
let csrf = random_b64(24);
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let redirect = format!("http://127.0.0.1:{port}");
let url = auth_url(&creds, &redirect, &challenge, &csrf);
open_in_browser(&app, &url);
let app_bg = app.clone();
tauri::async_runtime::spawn(async move {
let outcome = complete_auth(&app_bg, listener, csrf, verifier, redirect, creds).await;
emit_auth(&app_bg, outcome);
});
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.
#[cfg(mobile)]
pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
let creds = load_credentials()?;
let verifier = random_b64(64);
let challenge = pkce_challenge(&verifier);
let csrf = random_b64(24);
let redirect = redirect_uri(&creds);
let url = auth_url(&creds, &redirect, &challenge, &csrf);
{
let state = app.state::<AuthState>();
let mut pending = state.pending.lock().await;
*pending = Some(Pending {
state: csrf,
verifier,
redirect,
expires: now() + PENDING_TIMEOUT_SECS,
});
}
open_in_browser(&app, &url);
Ok(url)
}
/// 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
/// authorization failure worth putting on screen.
#[cfg(mobile)]
pub async fn handle_redirect(app: tauri::AppHandle, incoming: &url::Url) {
let mut code = None;
let mut state = None;
let mut error = None;
for (key, value) in incoming.query_pairs() {
match key.as_ref() {
"code" => code = Some(value.into_owned()),
"state" => state = Some(value.into_owned()),
"error" => error = Some(value.into_owned()),
_ => {}
}
}
let state = match state {
Some(state) => state,
None => return,
};
// Taken, not read: a code is good once, so leaving the verifier in place would let a replayed
// link start a second exchange.
let auth = app.state::<AuthState>();
let pending = {
let mut slot = auth.pending.lock().await;
match slot.as_ref() {
Some(p) if p.state == state => slot.take(),
// A link whose state matches nothing is either stale or forged. Either way it is not
// this app's pending request, so it is not this app's business.
_ => return,
}
};
let pending = match pending {
Some(pending) => pending,
None => return,
};
if let Some(error) = error {
emit_auth(&app, Err(format!("Google authorization failed: {error}")));
return;
}
if now() > pending.expires {
emit_auth(
&app,
Err("The sign-in took too long. Try connecting again.".to_string()),
);
return;
}
let code = match code {
Some(code) => code,
None => {
emit_auth(&app, Err("Google returned no authorization code.".to_string()));
return;
}
};
let outcome = match load_credentials() {
Ok(creds) => finish(&app, &creds, &code, &pending.redirect, &pending.verifier).await,
Err(e) => Err(e),
};
emit_auth(&app, outcome);
}
#[cfg(desktop)]
async fn complete_auth(
app: &tauri::AppHandle,
listener: TcpListener,
csrf: String,
verifier: String,
redirect: String,
creds: Credentials,
) -> Result<(String, String), String> {
let code = tauri::async_runtime::spawn_blocking(move || {
let deadline = Instant::now() + Duration::from_secs(AUTH_TIMEOUT_SECS);
await_code(listener, &csrf, deadline)
})
.await
.map_err(|e| e.to_string())??;
finish(app, &creds, &code, &redirect, &verifier).await
}
/// Code to stored account. Everything past the point where the two flows stop differing.
async fn finish(
app: &tauri::AppHandle,
creds: &Credentials,
code: &str,
redirect: &str,
verifier: &str,
) -> Result<(String, String), String> {
let tokens = exchange_code(creds, code, redirect, verifier).await?;
let refresh = tokens
.refresh_token
.clone()
.ok_or("Google did not return a refresh token. Remove Margin Calendar from your Google account permissions and connect again.")?;
let claims = tokens
.id_token
.as_deref()
.and_then(id_token_claims)
.unwrap_or_default();
let email = match claims.email {
Some(email) if !email.is_empty() => email,
_ => fetch_email(&tokens.access_token).await?,
};
// The Google user id keys everything. It falls back to the email only when the id_token is
// missing, which should not happen while `openid` is in the scope set.
let account_id = claims.sub.unwrap_or_else(|| email.clone());
if account_id.is_empty() {
return Err("Google returned neither a user id nor an email address.".to_string());
}
secrets::store(&account_id, &refresh)?;
let reference = secrets::reference(&account_id);
with_conn(app, |conn| {
crate::store::write::upsert_account(conn, &account_id, &email, Some(&reference))
})?;
let state = app.state::<AuthState>();
let mut sessions = state.sessions.lock().await;
sessions.insert(
account_id.clone(),
Session {
access_token: Some(tokens.access_token),
access_expiry: now() + tokens.expires_in.saturating_sub(EXPIRY_SKEW_SECS),
email: Some(email.clone()),
},
);
Ok((account_id, email))
}
pub async fn disconnect(
app: &tauri::AppHandle,
state: &tauri::State<'_, AuthState>,
account_id: &str,
) -> Result<(), String> {
if let Ok(Some(refresh)) = secrets::load(account_id) {
revoke(&refresh).await;
}
{
let mut sessions = state.sessions.lock().await;
sessions.remove(account_id);
}
// Every row the account owns, not just its token. margin's disconnect cleared the token and
// left the remote ids behind, so connecting a different account afterwards wrote against them.
let removed = secrets::delete(account_id);
with_conn(app, |conn| {
crate::store::write::wipe_account(conn, account_id)
})?;
crate::emit_store_changed(app, "disconnect");
removed
}
/// Single-flight: the map lock is held across the refresh await, so concurrent callers queue on
/// one token request rather than each firing their own. That serializes refreshes across accounts
/// as well, which is the right trade for something that happens once an hour per account.
pub async fn valid_access_token(
_app: &tauri::AppHandle,
state: &AuthState,
account_id: &str,
) -> Result<String, String> {
let mut sessions = state.sessions.lock().await;
if let Some(session) = sessions.get(account_id) {
if let Some(token) = &session.access_token {
if now() < session.access_expiry {
return Ok(token.clone());
}
}
}
let refresh = secrets::load(account_id)?
.ok_or_else(|| format!("Account {account_id} is not connected to Google."))?;
let creds = load_credentials()?;
let tokens = refresh_access_token(&creds, &refresh).await?;
if let Some(rotated) = &tokens.refresh_token {
if rotated != &refresh {
secrets::store(account_id, rotated)?;
}
}
let session = sessions.entry(account_id.to_string()).or_default();
session.access_token = Some(tokens.access_token.clone());
session.access_expiry = now() + tokens.expires_in.saturating_sub(EXPIRY_SKEW_SECS);
Ok(tokens.access_token)
}
/// The token store needs a directory and the sessions map wants the stored emails, both of which
/// are only knowable once the app is up.
pub fn init_sessions(app: &tauri::AppHandle) {
if let Ok(dir) = crate::library::app_data_dir(app) {
secrets::init(dir);
}
let app = app.clone();
tauri::async_runtime::spawn(async move {
let accounts = match with_conn(&app, crate::store::read::accounts) {
Ok(accounts) => accounts,
Err(_) => return,
};
let state = app.state::<AuthState>();
let mut sessions = state.sessions.lock().await;
for account in accounts {
sessions.entry(account.id).or_default().email = Some(account.email);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkce_challenge_matches_the_rfc7636_vector() {
assert_eq!(
pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn a_verifier_is_url_safe_and_unpadded() {
let verifier = random_b64(64);
assert!(verifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
}
#[test]
fn request_path_reads_the_target_of_the_request_line() {
let request = "GET /?code=abc HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
assert_eq!(request_path(request), "/?code=abc");
assert_eq!(request_path(""), "");
}
#[test]
fn a_matching_state_yields_the_code() {
assert_eq!(
parse_redirect("/?code=4%2F0Ab&state=xyz&scope=openid", "xyz"),
Redirect::Code("4/0Ab".to_string())
);
}
#[test]
fn a_foreign_state_is_rejected_rather_than_exchanged() {
assert_eq!(parse_redirect("/?code=4/0Ab&state=other", "xyz"), Redirect::Mismatch);
assert_eq!(parse_redirect("/?code=4/0Ab", "xyz"), Redirect::Mismatch);
}
#[test]
fn a_denial_carries_googles_reason() {
assert_eq!(
parse_redirect("/?error=access_denied&state=xyz", "xyz"),
Redirect::Denied("access_denied".to_string())
);
}
#[test]
fn incidental_requests_keep_the_listener_waiting() {
assert_eq!(parse_redirect("/favicon.ico", "xyz"), Redirect::Waiting);
assert_eq!(parse_redirect("/", "xyz"), Redirect::Waiting);
assert_eq!(parse_redirect("", "xyz"), Redirect::Waiting);
}
#[test]
fn id_token_claims_reads_the_subject_and_email() {
let payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"11829","email":"[email protected]","aud":"x"}"#);
let claims = id_token_claims(&format!("header.{payload}.signature")).expect("claims");
assert_eq!(claims.sub.as_deref(), Some("11829"));
assert_eq!(claims.email.as_deref(), Some("[email protected]"));
}
#[test]
fn a_malformed_id_token_is_none_rather_than_a_panic() {
assert!(id_token_claims("not-a-jwt").is_none());
assert!(id_token_claims("header..signature").is_none());
}
}
+34
View File
@@ -0,0 +1,34 @@
// auth.rs OAuth, token refresh. Loopback on desktop, a deep link on mobile.
// api.rs typed Google Calendar REST wrapper
// secrets.rs refresh tokens, encrypted on disk, same on every platform
pub mod api;
pub mod auth;
pub mod secrets;
use crate::dto::Account;
pub use auth::AuthState;
#[tauri::command]
pub async fn accounts_list(
app: tauri::AppHandle,
state: tauri::State<'_, AuthState>,
) -> Result<Vec<Account>, String> {
auth::list_accounts(&app, &state).await
}
/// Returns the consent URL. Completion arrives on the frontend as the `auth` event.
#[tauri::command]
pub async fn account_connect(app: tauri::AppHandle) -> Result<String, String> {
auth::connect(app).await
}
#[tauri::command]
pub async fn account_disconnect(
app: tauri::AppHandle,
state: tauri::State<'_, AuthState>,
account_id: String,
) -> Result<(), String> {
auth::disconnect(&app, &state, &account_id).await
}
+275
View File
@@ -0,0 +1,275 @@
// Where refresh tokens live: an encrypted file in the app's data directory, on every platform.
// Never plaintext on disk, never in the SQLite store, one entry per account id.
//
// This used to be the OS credential store with this file as a fallback, and that is gone. The
// credential stores did not survive contact with five platforms:
//
// macOS Keychain ties an item's ACL to the code signature, so an ad-hoc signed build gets a
// new identity on every compile and macOS correctly re-asks for authorization every
// single time. An authorization dialog a minute is not something to ask anyone to live
// with, so macOS was already excluded before mobile came up at all.
// Android The keyring crate has no Android backend. There is nothing to fall back from.
// Linux A minimal window manager often runs no Secret Service daemon, so the fallback ran
// anyway on exactly the machines that most wanted the daemon.
//
// That left one real implementation and four ways of reaching it, so the branching went and the
// file stayed. Be clear about what it is worth, because it is not the same everywhere:
//
// iOS, Android The app sandbox is the boundary. Another app cannot read this file, so the
// encryption is defence in depth over a boundary the OS already enforces.
// Desktop Anyone who can read the user's home directory can read the token. The key is
// derived from a salt sitting next to the ciphertext, mixed with a machine
// identifier, so a copied home directory does not decrypt elsewhere, and that is
// the whole of the protection. Better than plaintext, worse than the Secret
// Service, and chosen knowing that.
//
// The token is a Google refresh token scoped to one calendar account, revocable from the user's
// Google account page, and revoked here on disconnect.
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use chacha20poly1305::aead::Aead;
use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
use rand::RngCore;
use sha2::{Digest, Sha256};
pub const SERVICE: &str = "studio.margin.calendar";
const BLOB_NAME: &str = "tokens.enc";
const SALT_NAME: &str = "tokens.salt";
const KEY_CONTEXT: &str = "margin-calendar token store v1";
const NONCE_LEN: usize = 24;
/// The names the encrypted store used while it was still the fallback behind a credential store.
/// Read once and migrated, so an existing install does not silently lose its accounts and ask the
/// user to reconnect for no reason they can see.
const LEGACY_BLOB_NAME: &str = "keychain-fallback.enc";
const LEGACY_SALT_NAME: &str = "keychain-fallback.salt";
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Called from `auth::init_sessions` during setup, before any command can run, because this needs
/// a directory and the signatures below deliberately do not carry an AppHandle.
pub fn init(dir: PathBuf) {
let _ = DATA_DIR.set(dir);
}
/// The reference stored in the `accounts` row. It names the entry, never the secret.
///
/// The column is still called `keychain_ref` because renaming it would cost a migration and buy
/// nothing: it has only ever held this string.
pub fn reference(account_id: &str) -> String {
format!("{SERVICE}/{account_id}")
}
pub fn store(account_id: &str, refresh_token: &str) -> Result<(), String> {
let sealed = seal(&key()?, refresh_token.as_bytes())?;
let mut entries = read_blob()?;
entries.insert(account_id.to_string(), sealed);
write_blob(&entries)
}
pub fn load(account_id: &str) -> Result<Option<String>, String> {
let entries = read_blob()?;
let sealed = match entries.get(account_id) {
Some(sealed) => sealed,
None => return Ok(None),
};
let plain = open(&key()?, sealed)?;
String::from_utf8(plain)
.map(Some)
.map_err(|_| "the stored token is not valid UTF-8".to_string())
}
pub fn delete(account_id: &str) -> Result<(), String> {
let mut entries = read_blob()?;
if entries.remove(account_id).is_none() {
return Ok(());
}
write_blob(&entries)
}
fn data_dir() -> Result<PathBuf, String> {
if let Some(dir) = DATA_DIR.get() {
return Ok(dir.clone());
}
// Only reachable before setup has run, which on mobile is never: there is no XDG layout to
// guess at there, so the honest answer is the error rather than a path that does not exist.
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
.ok_or("no app data directory is known")?;
let dir = base.join(SERVICE);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
/// The salt is per install and random. Losing it means losing every stored token, which costs a
/// reconnect and nothing else, so it is written once and never rotated.
fn key() -> Result<[u8; 32], String> {
let dir = data_dir()?;
let path = dir.join(SALT_NAME);
let legacy = dir.join(LEGACY_SALT_NAME);
let salt = match std::fs::read(&path) {
Ok(bytes) if bytes.len() == 32 => bytes,
_ => match std::fs::read(&legacy) {
// Same salt, new name. Rewritten rather than renamed so an interrupted migration
// leaves both files readable rather than neither.
Ok(bytes) if bytes.len() == 32 => {
write_private(&path, &bytes)?;
bytes
}
_ => {
let mut bytes = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
write_private(&path, &bytes)?;
bytes
}
},
};
let mut hasher = Sha256::new();
hasher.update(KEY_CONTEXT.as_bytes());
hasher.update(&salt);
hasher.update(machine_id().as_bytes());
Ok(hasher.finalize().into())
}
/// Mixed into the key so a copied home directory does not decrypt on another machine. Empty on
/// macOS, iOS and Android, where no such file exists and the sandbox or the file mode is doing the
/// work instead. An empty contribution is not a weakness here: the salt is already random and
/// per install, and this only ever adds entropy.
fn machine_id() -> String {
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(id) = std::fs::read_to_string(path) {
let id = id.trim();
if !id.is_empty() {
return id.to_string();
}
}
}
String::new()
}
fn read_blob() -> Result<BTreeMap<String, String>, String> {
let dir = data_dir()?;
let path = dir.join(BLOB_NAME);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
match std::fs::read_to_string(dir.join(LEGACY_BLOB_NAME)) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(e.to_string()),
}
}
Err(e) => return Err(e.to_string()),
};
serde_json::from_str(&text).map_err(|e| e.to_string())
}
fn write_blob(entries: &BTreeMap<String, String>) -> Result<(), String> {
let path = data_dir()?.join(BLOB_NAME);
let text = serde_json::to_string(entries).map_err(|e| e.to_string())?;
write_private(&path, text.as_bytes())
}
fn seal(key_bytes: &[u8; 32], plain: &[u8]) -> Result<String, String> {
let cipher = XChaCha20Poly1305::new(Key::from_slice(key_bytes));
let mut nonce = [0u8; NONCE_LEN];
rand::thread_rng().fill_bytes(&mut nonce);
let mut sealed = cipher
.encrypt(XNonce::from_slice(&nonce), plain)
.map_err(|_| "could not encrypt the token".to_string())?;
let mut out = nonce.to_vec();
out.append(&mut sealed);
Ok(BASE64.encode(out))
}
fn open(key_bytes: &[u8; 32], sealed: &str) -> Result<Vec<u8>, String> {
let raw = BASE64
.decode(sealed)
.map_err(|e| format!("the stored token is malformed: {e}"))?;
if raw.len() <= NONCE_LEN {
return Err("the stored token is truncated".to_string());
}
let (nonce, body) = raw.split_at(NONCE_LEN);
let cipher = XChaCha20Poly1305::new(Key::from_slice(key_bytes));
cipher
.decrypt(XNonce::from_slice(nonce), body)
.map_err(|_| "the stored token could not be decrypted on this machine".to_string())
}
/// Written 0600 from creation rather than chmodded after the fact, so the ciphertext is never
/// briefly world readable. The mode is a no-op on the mobile sandboxes and costs nothing there.
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let tmp = PathBuf::from(format!("{}.tmp", path.display()));
{
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&tmp).map_err(|e| e.to_string())?;
file.write_all(bytes).map_err(|e| e.to_string())?;
file.sync_all().map_err(|e| e.to_string())?;
}
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sealed_tokens_round_trip() {
let key = [7u8; 32];
let sealed = seal(&key, b"1//refresh-token").expect("seal");
assert_eq!(open(&key, &sealed).expect("open"), b"1//refresh-token");
}
#[test]
fn a_different_key_does_not_open_the_token() {
let sealed = seal(&[7u8; 32], b"1//refresh-token").expect("seal");
assert!(open(&[8u8; 32], &sealed).is_err());
}
#[test]
fn each_seal_uses_a_fresh_nonce() {
let key = [7u8; 32];
assert_ne!(
seal(&key, b"same").expect("seal"),
seal(&key, b"same").expect("seal")
);
}
#[test]
fn a_truncated_blob_is_rejected_rather_than_panicking() {
assert!(open(&[7u8; 32], &BASE64.encode([0u8; 8])).is_err());
}
#[test]
fn a_tampered_blob_is_rejected_rather_than_returning_plaintext() {
let key = [7u8; 32];
let sealed = seal(&key, b"1//refresh-token").expect("seal");
let mut raw = BASE64.decode(&sealed).expect("decode");
// Past the nonce, so it is the ciphertext that changed and the tag that catches it.
let last = raw.len() - 1;
raw[last] ^= 0x01;
assert!(open(&key, &BASE64.encode(raw)).is_err());
}
#[test]
fn a_reference_names_the_entry_and_never_the_secret() {
assert_eq!(reference("1234"), "studio.margin.calendar/1234");
}
}
+279
View File
@@ -0,0 +1,279 @@
mod dto;
mod google;
mod library;
mod recur;
mod store;
mod sync;
#[cfg(desktop)]
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
use tauri::{Emitter, Manager};
#[cfg(desktop)]
use tauri::Runtime;
/// The frontend treats this purely as an invalidation signal and re-requests the visible range.
pub fn emit_store_changed(app: &tauri::AppHandle, reason: &str) {
let _ = app.emit("store-changed", reason);
}
#[cfg(desktop)]
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
let menu = Menu::default(handle)?;
let new_event = MenuItemBuilder::with_id("new-event", "New Event")
.accelerator("CmdOrCtrl+N")
.build(handle)?;
let command_palette = MenuItemBuilder::with_id("command-palette", "Command Palette…")
.accelerator("CmdOrCtrl+K")
.build(handle)?;
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 check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
let settings = MenuItemBuilder::with_id("settings", "Settings…")
.accelerator("CmdOrCtrl+,")
.build(handle)?;
let search = MenuItemBuilder::with_id("search", "Search…")
.accelerator("CmdOrCtrl+F")
.build(handle)?;
let today = MenuItemBuilder::with_id("today", "Today")
.accelerator("CmdOrCtrl+T")
.build(handle)?;
let shortcuts = MenuItemBuilder::with_id("shortcuts", "Keyboard Shortcuts")
.accelerator("CmdOrCtrl+/")
.build(handle)?;
let report_issue =
MenuItemBuilder::with_id("report-issue", "Report an Issue…").build(handle)?;
let submenus: Vec<_> = menu
.items()?
.into_iter()
.filter_map(|item| match item {
MenuItemKind::Submenu(submenu) => Some(submenu),
_ => None,
})
.collect();
let find_submenu = |name: &str| {
submenus
.iter()
.find(|submenu| submenu.text().map(|t| t == name).unwrap_or(false))
.cloned()
};
match find_submenu("File") {
Some(submenu) => {
submenu.prepend_items(&[
&new_event,
&command_palette,
&PredefinedMenuItem::separator(handle)?,
&sync_now,
&accounts,
&PredefinedMenuItem::separator(handle)?,
])?;
}
None => {
let submenu = SubmenuBuilder::new(handle, "File")
.item(&new_event)
.item(&command_palette)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&sync_now)
.item(&accounts)
.build()?;
menu.insert(&submenu, 1)?;
}
}
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&PredefinedMenuItem::separator(handle)?, &search])?;
}
if let Some(help) = find_submenu("Help") {
help.append_items(&[&shortcuts, &report_issue])?;
}
#[cfg(target_os = "macos")]
{
if let Some(app_submenu) = submenus.first() {
app_submenu.insert(&check_updates, 1)?;
app_submenu.insert(&settings, 3)?;
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
}
if let Some(view) = find_submenu("View") {
let view_week = MenuItemBuilder::with_id("view-week", "Week")
.accelerator("CmdOrCtrl+2")
.build(handle)?;
let view_day = MenuItemBuilder::with_id("view-day", "Day")
.accelerator("CmdOrCtrl+1")
.build(handle)?;
let view_agenda = MenuItemBuilder::with_id("view-agenda", "Agenda")
.accelerator("CmdOrCtrl+3")
.build(handle)?;
view.prepend_items(&[
&view_day,
&view_week,
&view_agenda,
&PredefinedMenuItem::separator(handle)?,
&today,
&PredefinedMenuItem::separator(handle)?,
])?;
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(file) = find_submenu("File") {
file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?;
}
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&settings])?;
}
let _ = &today;
}
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.
///
/// 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;
/// `get_current` catches the one that launched a process the OS had killed in the meantime.
/// Handling it twice is harmless: `handle_redirect` takes the pending verifier rather than reading
/// it, so the second call finds nothing waiting and returns.
#[cfg(mobile)]
fn listen_for_redirects(handle: &tauri::AppHandle) {
use tauri_plugin_deep_link::DeepLinkExt;
let dispatch = |handle: &tauri::AppHandle, url: url::Url| {
let handle = handle.clone();
tauri::async_runtime::spawn(async move {
google::auth::handle_redirect(handle, &url).await;
});
};
if let Ok(Some(urls)) = handle.deep_link().get_current() {
for url in urls {
dispatch(handle, url);
}
}
let handle = handle.clone();
handle.clone().deep_link().on_open_url(move |event| {
for url in event.urls() {
dispatch(&handle, url);
}
});
}
/// UIKit hands a scroll view the safe areas as content insets unless it is told not to, and wry
/// never tells it not to: wry 0.55.1's src/wkwebview/mod.rs:528 reaches for the scroll view only to
/// switch `bounces` off. WebKit then lays the page out in what is left over. On an iPhone 17 Pro
/// that is a layout viewport 778pt tall against an 874pt screen, still anchored at y 0, so the
/// bottom 96pt of the display is outside the page altogether. `body` is `position: fixed`, which
/// clips to that box, so nothing can paint down there whatever the CSS says: the tab bar sits in
/// the middle of the glass with a dead band of shell colour beneath it.
///
/// `never` gives the page the whole screen back. The insets are not lost, they arrive as
/// `env(safe-area-inset-*)` instead, which is where the two bars in app.css already read them from,
/// and all four viewport units finally agree on 874.
#[cfg(target_os = "ios")]
fn stop_uikit_shrinking_the_viewport(window: &tauri::WebviewWindow) {
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_ui_kit::{UIScrollView, UIScrollViewContentInsetAdjustmentBehavior};
let _ = window.with_webview(|webview| {
// `inner()` is the WKWebView, and `with_webview` runs the closure inline when the caller is
// already on the main thread, which setup is. So this is a plain main-thread UIKit call.
let scroll_view: Retained<UIScrollView> =
unsafe { objc2::msg_send![webview.inner().cast::<AnyObject>(), scrollView] };
scroll_view
.setContentInsetAdjustmentBehavior(UIScrollViewContentInsetAdjustmentBehavior::Never);
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// generate_context! first, so the updater plugin registers only when the merged config
// actually has an `updater` key. Ported from margin's lib.rs:146.
let context = tauri::generate_context!();
#[cfg_attr(mobile, allow(unused_mut))]
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_deep_link::init());
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_process::init());
if context.config().plugins.0.contains_key("updater") {
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
}
}
builder = builder
.manage(google::AuthState::default())
.manage(sync::SyncState::default())
.setup(|app| {
let handle = app.handle();
let store = store::Store::open(handle)?;
app.manage(store);
google::auth::init_sessions(handle);
sync::start_loop(handle.clone());
#[cfg(mobile)]
listen_for_redirects(handle);
#[cfg(target_os = "ios")]
if let Some(window) = handle.get_webview_window("main") {
stop_uikit_shrinking_the_viewport(&window);
}
Ok(())
});
#[cfg(desktop)]
{
builder = builder
.menu(|handle| build_menu(handle))
.on_menu_event(|app, event| {
if matches!(
event.id().0.as_str(),
"new-event"
| "command-palette"
| "sync-now"
| "accounts"
| "check-updates"
| "settings"
| "search"
| "today"
| "shortcuts"
| "view-day"
| "view-week"
| "view-agenda"
| "report-issue"
) {
app.emit("menu-action", event.id().0.as_str()).ok();
}
});
}
builder
.invoke_handler(tauri::generate_handler![
google::accounts_list,
google::account_connect,
google::account_disconnect,
store::calendars_list,
store::calendar_set_selected,
recur::instances_range,
sync::event_create,
sync::event_update,
sync::event_delete,
sync::sync_now,
sync::sync_status,
sync::sync_flush
])
.run(context)
.expect("error while running Margin Calendar");
}
+9
View File
@@ -0,0 +1,9 @@
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
pub fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
margin_calendar_lib::run()
}
File diff suppressed because it is too large. Load diff
+443
View File
@@ -0,0 +1,443 @@
// schema.rs table definitions and migrations
// read.rs row-level reads: accounts, calendars, masters overlapping a window, outbox depth
// write.rs row-level writes: upserts from sync, optimistic local writes, outbox enqueue/drain
pub mod read;
pub mod schema;
pub mod write;
use std::sync::Mutex;
use rusqlite::Connection;
use crate::dto::Calendar;
/// One connection behind a Mutex. Every caller is either a Tauri command or the sync loop, and
/// neither is hot enough to want a pool.
pub struct Store {
pub conn: Mutex<Connection>,
}
impl Store {
pub fn open(app: &tauri::AppHandle) -> Result<Store, String> {
let path = crate::library::app_data_dir(app)?.join("calendar.sqlite3");
let conn = Connection::open(&path).map_err(|e| e.to_string())?;
conn.pragma_update(None, "journal_mode", "WAL")
.map_err(|e| e.to_string())?;
conn.pragma_update(None, "foreign_keys", "ON")
.map_err(|e| e.to_string())?;
conn.pragma_update(None, "synchronous", "NORMAL")
.map_err(|e| e.to_string())?;
schema::migrate(&conn)?;
Ok(Store { conn: Mutex::new(conn) })
}
}
#[tauri::command]
pub fn calendars_list(store: tauri::State<'_, Store>) -> Result<Vec<Calendar>, String> {
let conn = store.conn.lock().map_err(|e| e.to_string())?;
read::calendars(&conn)
}
#[tauri::command]
pub fn calendar_set_selected(
app: tauri::AppHandle,
store: tauri::State<'_, Store>,
calendar_id: String,
selected: bool,
) -> Result<(), String> {
{
let conn = store.conn.lock().map_err(|e| e.to_string())?;
write::set_calendar_selected(&conn, &calendar_id, selected)?;
}
crate::emit_store_changed(&app, "calendar-selection");
Ok(())
}
#[cfg(test)]
mod tests {
use rusqlite::Connection;
use super::write::{CalendarRow, EventRow, OutboxRow};
use super::{read, schema, write};
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory database");
schema::migrate(&conn).expect("migrate");
conn
}
fn ms(rfc3339: &str) -> i64 {
chrono::DateTime::parse_from_rfc3339(rfc3339)
.expect("a timestamp")
.timestamp_millis()
}
fn calendar(conn: &Connection, id: &str, account_id: &str) {
write::upsert_calendar(
conn,
&CalendarRow {
id: id.to_string(),
account_id: account_id.to_string(),
summary: id.to_string(),
color_hex: "#4285f4".to_string(),
access_role: "owner".to_string(),
time_zone: "Europe/London".to_string(),
selected: true,
..Default::default()
},
)
.expect("upsert calendar");
}
fn event(id: &str, calendar_id: &str, start: &str, end: &str) -> EventRow {
EventRow {
id: id.to_string(),
calendar_id: calendar_id.to_string(),
account_id: "acct".to_string(),
status: "confirmed".to_string(),
summary: id.to_string(),
start_at: start.to_string(),
end_at: end.to_string(),
..Default::default()
}
}
fn ids(rows: &[EventRow]) -> Vec<&str> {
rows.iter().map(|r| r.id.as_str()).collect()
}
/// A calendar shared with two connected accounts is listed by both. If the second claimed the
/// row, the owner would flip on every pass, and since a sync token is issued per user, each
/// flip would invalidate it and force a full resync of that calendar for ever.
#[test]
fn a_shared_calendar_keeps_the_account_that_saw_it_first() {
let conn = db();
calendar(&conn, "shared", "first");
write::set_calendar_sync_token(&conn, "shared", Some("cursor")).expect("set token");
calendar(&conn, "shared", "second");
let rows = read::calendars(&conn).expect("calendars");
let row = rows.iter().find(|c| c.id == "shared").expect("the shared calendar");
assert_eq!(row.account_id, "first");
assert_eq!(
write::calendar_sync_token(&conn, "shared").expect("token"),
Some("cursor".to_string())
);
}
#[test]
fn migrate_runs_twice_without_complaint() {
let conn = db();
schema::migrate(&conn).expect("second migrate");
let version: String = conn
.query_row("SELECT value FROM meta WHERE key = 'schema_version'", [], |r| r.get(0))
.expect("a recorded version");
assert_eq!(version, schema::VERSION.to_string());
let tables: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'
AND name IN ('meta', 'accounts', 'calendars', 'events', 'outbox')",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(tables, 5);
}
#[test]
fn migrate_refuses_a_database_from_a_newer_build() {
let conn = db();
write::meta_set(&conn, "schema_version", "99").expect("bump");
assert!(schema::migrate(&conn).is_err());
}
#[test]
fn wiping_an_account_takes_its_calendars_events_and_queued_writes() {
let conn = db();
write::upsert_account(&conn, "acct", "[email protected]", Some("ref")).expect("account");
write::upsert_account(&conn, "other", "[email protected]", Some("ref")).expect("account");
calendar(&conn, "cal-a", "acct");
calendar(&conn, "cal-b", "other");
write::meta_set(
&conn,
&write::account_meta_key("acct", "calendar-list-token"),
"tok",
)
.expect("meta");
write::upsert_event(
&conn,
&event("one", "cal-a", "2026-08-10T09:00:00Z", "2026-08-10T10:00:00Z"),
)
.expect("event");
let mut kept = event("two", "cal-b", "2026-08-10T09:00:00Z", "2026-08-10T10:00:00Z");
kept.account_id = "other".to_string();
write::upsert_event(&conn, &kept).expect("event");
for calendar_id in ["cal-a", "cal-b"] {
write::enqueue(
&conn,
&OutboxRow {
op: "patch".to_string(),
calendar_id: calendar_id.to_string(),
event_id: Some("one".to_string()),
..Default::default()
},
)
.expect("enqueue");
}
write::wipe_account(&conn, "acct").expect("wipe");
assert_eq!(read::accounts(&conn).expect("accounts").len(), 1);
assert_eq!(read::calendars(&conn).expect("calendars").len(), 1);
assert_eq!(read::pending_writes(&conn).expect("outbox"), 1);
assert!(read::event(&conn, "cal-a", "one").expect("read").is_none());
assert!(read::event(&conn, "cal-b", "two").expect("read").is_some());
let meta = write::meta_get(&conn, &write::account_meta_key("acct", "calendar-list-token"));
assert_eq!(meta.expect("meta"), None);
}
#[test]
fn clearing_one_calendar_leaves_the_others_alone() {
let conn = db();
calendar(&conn, "cal-a", "acct");
calendar(&conn, "cal-b", "acct");
write::set_calendar_sync_token(&conn, "cal-a", Some("token-a")).expect("token");
write::set_calendar_sync_token(&conn, "cal-b", Some("token-b")).expect("token");
write::upsert_event(
&conn,
&event("one", "cal-a", "2026-08-10T09:00:00Z", "2026-08-10T10:00:00Z"),
)
.expect("event");
write::upsert_event(
&conn,
&event("two", "cal-b", "2026-08-10T09:00:00Z", "2026-08-10T10:00:00Z"),
)
.expect("event");
write::clear_calendar(&conn, "cal-a").expect("clear");
assert!(read::event(&conn, "cal-a", "one").expect("read").is_none());
assert!(read::event(&conn, "cal-b", "two").expect("read").is_some());
assert_eq!(write::calendar_sync_token(&conn, "cal-a").expect("token"), None);
assert_eq!(
write::calendar_sync_token(&conn, "cal-b").expect("token"),
Some("token-b".to_string())
);
assert_eq!(read::calendars(&conn).expect("calendars").len(), 2);
}
#[test]
fn a_calendar_refresh_does_not_re_tick_an_unticked_calendar() {
let conn = db();
calendar(&conn, "cal-a", "acct");
write::set_calendar_selected(&conn, "cal-a", false).expect("unselect");
write::set_calendar_sync_token(&conn, "cal-a", Some("token-a")).expect("token");
calendar(&conn, "cal-a", "acct");
let calendars = read::calendars(&conn).expect("calendars");
assert!(!calendars[0].selected);
assert_eq!(
write::calendar_sync_token(&conn, "cal-a").expect("token"),
Some("token-a".to_string())
);
}
#[test]
fn the_window_read_keeps_an_exception_whose_own_times_fall_outside_it() {
let conn = db();
calendar(&conn, "cal-a", "acct");
let mut master = event(
"series",
"cal-a",
"2026-08-03T09:00:00Z",
"2026-08-03T10:00:00Z",
);
master.recurrence = vec!["RRULE:FREQ=WEEKLY;BYDAY=MO".to_string()];
write::upsert_event(&conn, &master).expect("master");
// Dragged out of the window entirely.
let mut moved = event(
"series_20260810T090000Z",
"cal-a",
"2026-09-20T09:00:00Z",
"2026-09-20T10:00:00Z",
);
moved.recurring_event_id = Some("series".to_string());
moved.original_start = Some("2026-08-10T09:00:00Z".to_string());
write::upsert_event(&conn, &moved).expect("moved");
// Cancelled, so it carries no times at all.
let mut cancelled = event("series_20260817T090000Z", "cal-a", "", "");
cancelled.status = "cancelled".to_string();
cancelled.recurring_event_id = Some("series".to_string());
cancelled.original_start = Some("2026-08-17T09:00:00Z".to_string());
write::upsert_event(&conn, &cancelled).expect("cancelled");
// Nothing to do with the window.
write::upsert_event(
&conn,
&event("elsewhere", "cal-a", "2027-01-01T09:00:00Z", "2027-01-01T10:00:00Z"),
)
.expect("single");
let rows = read::masters_overlapping(
&conn,
ms("2026-08-10T00:00:00Z"),
ms("2026-08-17T00:00:00Z"),
)
.expect("window");
let found = ids(&rows);
assert!(found.contains(&"series"), "the master: {found:?}");
assert!(found.contains(&"series_20260810T090000Z"), "the moved instance: {found:?}");
assert!(found.contains(&"series_20260817T090000Z"), "the cancelled instance: {found:?}");
assert!(!found.contains(&"elsewhere"), "an unrelated single: {found:?}");
}
#[test]
fn the_window_read_drops_a_series_that_has_already_ended() {
let conn = db();
calendar(&conn, "cal-a", "acct");
let mut ended = event("ended", "cal-a", "2025-01-06T09:00:00Z", "2025-01-06T10:00:00Z");
ended.recurrence = vec!["RRULE:FREQ=WEEKLY;UNTIL=20250201T000000Z".to_string()];
write::upsert_event(&conn, &ended).expect("ended");
let mut endless = event("endless", "cal-a", "2025-01-06T09:00:00Z", "2025-01-06T10:00:00Z");
endless.recurrence = vec!["RRULE:FREQ=WEEKLY;COUNT=500".to_string()];
write::upsert_event(&conn, &endless).expect("endless");
let rows = read::masters_overlapping(
&conn,
ms("2026-08-10T00:00:00Z"),
ms("2026-08-17T00:00:00Z"),
)
.expect("window");
let found = ids(&rows);
assert!(!found.contains(&"ended"), "{found:?}");
assert!(found.contains(&"endless"), "COUNT cannot be resolved in SQL: {found:?}");
}
#[test]
fn the_window_read_ignores_unselected_calendars() {
let conn = db();
calendar(&conn, "cal-a", "acct");
calendar(&conn, "cal-b", "acct");
write::set_calendar_selected(&conn, "cal-b", false).expect("unselect");
write::upsert_event(
&conn,
&event("one", "cal-a", "2026-08-11T09:00:00Z", "2026-08-11T10:00:00Z"),
)
.expect("event");
write::upsert_event(
&conn,
&event("two", "cal-b", "2026-08-11T09:00:00Z", "2026-08-11T10:00:00Z"),
)
.expect("event");
let rows = read::masters_overlapping(
&conn,
ms("2026-08-10T00:00:00Z"),
ms("2026-08-17T00:00:00Z"),
)
.expect("window");
assert_eq!(ids(&rows), vec!["one"]);
}
#[test]
fn an_all_day_event_survives_the_window_bounds() {
let conn = db();
calendar(&conn, "cal-a", "acct");
let mut all_day = event("holiday", "cal-a", "2026-08-11", "2026-08-12");
all_day.all_day = true;
write::upsert_event(&conn, &all_day).expect("event");
let rows = read::masters_overlapping(
&conn,
ms("2026-08-10T00:00:00Z"),
ms("2026-08-17T00:00:00Z"),
)
.expect("window");
assert_eq!(ids(&rows), vec!["holiday"]);
}
#[test]
fn the_outbox_drains_in_the_order_it_was_filled() {
let conn = db();
calendar(&conn, "cal-a", "acct");
let first = write::enqueue(
&conn,
&OutboxRow {
op: "create".to_string(),
calendar_id: "cal-a".to_string(),
created_at: 10,
..Default::default()
},
)
.expect("enqueue");
let second = write::enqueue(
&conn,
&OutboxRow {
op: "patch".to_string(),
calendar_id: "cal-a".to_string(),
created_at: 20,
..Default::default()
},
)
.expect("enqueue");
let queued = write::peek_outbox(&conn, 10).expect("peek");
assert_eq!(
queued.iter().map(|r| r.id).collect::<Vec<_>>(),
vec![first, second]
);
assert_eq!(read::pending_writes(&conn).expect("depth"), 2);
write::mark_attempt_failed(&conn, first, "offline").expect("failed");
let queued = write::peek_outbox(&conn, 10).expect("peek");
assert_eq!(queued[0].attempts, 1);
assert_eq!(queued[0].last_error.as_deref(), Some("offline"));
write::dequeue(&conn, first).expect("dequeue");
assert_eq!(read::pending_writes(&conn).expect("depth"), 1);
}
#[test]
fn a_local_write_is_dirty_until_it_lands() {
let conn = db();
calendar(&conn, "cal-a", "acct");
let row = event("one", "cal-a", "2026-08-11T09:00:00Z", "2026-08-11T10:00:00Z");
write::upsert_event_dirty(&conn, &row).expect("write");
assert!(read::event(&conn, "cal-a", "one").expect("read").expect("row").dirty);
write::clear_dirty(&conn, "cal-a", "one").expect("clear");
assert!(!read::event(&conn, "cal-a", "one").expect("read").expect("row").dirty);
}
#[test]
fn the_same_event_id_can_live_in_two_calendars() {
let conn = db();
calendar(&conn, "cal-a", "acct");
calendar(&conn, "cal-b", "acct");
write::upsert_events(
&conn,
&[
event("shared", "cal-a", "2026-08-11T09:00:00Z", "2026-08-11T10:00:00Z"),
event("shared", "cal-b", "2026-08-11T09:00:00Z", "2026-08-11T10:00:00Z"),
],
)
.expect("write");
write::delete_event(&conn, "cal-a", "shared").expect("delete");
assert!(read::event(&conn, "cal-a", "shared").expect("read").is_none());
assert!(read::event(&conn, "cal-b", "shared").expect("read").is_some());
}
}
+252
View File
@@ -0,0 +1,252 @@
// Row-level reads. Nothing here interprets a timestamp: `masters_overlapping` narrows the rows
// with a deliberately loose window and `recur` does the exact work.
use std::collections::HashSet;
use rusqlite::{params, Connection, OptionalExtension, Row};
use crate::dto::{Account, Calendar};
use crate::store::write::EventRow;
/// The window is padded by a day at each end before it touches SQL. All-day rows carry a `start_ms`
/// pinned to UTC midnight while the caller's bounds are local, so up to a zone offset of slack is
/// needed either side; over-returning costs the expander a comparison, under-returning loses an
/// event.
const DAY_MS: i64 = 86_400_000;
const EVENT_COLS: &str = "e.id, e.calendar_id, e.account_id, e.etag, e.status, e.summary, \
e.description, e.location, e.start_at, e.start_tz, e.end_at, e.end_tz, e.all_day, \
e.recurrence, e.recurring_event_id, e.original_start, e.attendees, e.conference, \
e.updated_at, e.dirty, e.color_id";
const LIVE: &str = "JOIN calendars c ON c.id = e.calendar_id WHERE c.selected = 1 AND c.deleted = 0";
const IS_MASTER: &str = "(e.recurrence IS NOT NULL AND e.recurrence <> '' AND e.recurrence <> '[]')";
pub fn accounts(conn: &Connection) -> Result<Vec<Account>, String> {
let mut stmt = conn
.prepare("SELECT id, email, keychain_ref FROM accounts ORDER BY created_at, email")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
let keychain_ref: Option<String> = r.get(2)?;
Ok(Account {
id: r.get(0)?,
email: r.get(1)?,
// The store's view of connected is "there is a credential to go and fetch". Whether
// that credential still refreshes is the auth agent's answer, not this one.
connected: keychain_ref.is_some_and(|v| !v.is_empty()),
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
pub fn calendars(conn: &Connection) -> Result<Vec<Calendar>, String> {
let mut stmt = conn
.prepare(
"SELECT id, account_id, summary, description, color_hex, selected, access_role,
time_zone, primary_cal
FROM calendars WHERE deleted = 0
ORDER BY primary_cal DESC, summary COLLATE NOCASE, id",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
Ok(Calendar {
id: r.get(0)?,
account_id: r.get(1)?,
summary: r.get(2)?,
description: r.get(3)?,
color_hex: r.get(4)?,
selected: r.get::<_, i64>(5)? != 0,
access_role: r.get(6)?,
time_zone: r.get(7)?,
primary: r.get::<_, i64>(8)? != 0,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
/// One calendar the sync loop has to visit, with the cursor that belongs to it. Deleted rows are
/// left out; unselected ones are not, because selection is a display choice and a calendar that
/// stops syncing while it is unticked comes back stale.
#[derive(Debug, Clone)]
pub struct SyncTarget {
pub calendar_id: String,
pub account_id: String,
pub sync_token: Option<String>,
}
pub fn sync_targets(conn: &Connection) -> Result<Vec<SyncTarget>, String> {
let mut stmt = conn
.prepare(
"SELECT id, account_id, sync_token FROM calendars WHERE deleted = 0
ORDER BY account_id, primary_cal DESC, id",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
Ok(SyncTarget {
calendar_id: r.get(0)?,
account_id: r.get(1)?,
sync_token: r.get(2)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
/// A single row by its full key, for the outbox drain, which needs the stored etag to send an
/// If-Match. Ignores selection, unlike the window read.
pub fn event(
conn: &Connection,
calendar_id: &str,
event_id: &str,
) -> Result<Option<EventRow>, String> {
let sql = format!(
"SELECT {EVENT_COLS} FROM events e WHERE e.id = ?1 AND e.calendar_id = ?2"
);
conn.query_row(&sql, params![event_id, calendar_id], map_event)
.optional()
.map_err(|e| e.to_string())
}
pub fn pending_writes(conn: &Connection) -> Result<u32, String> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM outbox", [], |r| r.get(0))
.map_err(|e| e.to_string())?;
Ok(count.max(0) as u32)
}
/// Every event row that could contribute an occurrence to [from_ms, to_ms): singles overlapping
/// the window, every master with a recurrence rule that has not already ended, and every
/// exception instance pointing at one of those masters.
///
/// An RRULE cannot be evaluated in SQL, so the master test is loose on purpose: a series is
/// returned unless it starts after the window or its UNTIL has already passed. Everything else is
/// the expander's problem.
pub fn masters_overlapping(
conn: &Connection,
from_ms: i64,
to_ms: i64,
) -> Result<Vec<EventRow>, String> {
let from = from_ms.saturating_sub(DAY_MS);
let to = to_ms.saturating_add(DAY_MS);
let mut out: Vec<EventRow> = Vec::new();
let mut seen: HashSet<(String, String)> = HashSet::new();
let sql = format!(
"SELECT {EVENT_COLS} FROM events e {LIVE}
AND e.start_ms IS NOT NULL AND e.start_ms < ?2
AND (
(e.recurring_event_id IS NOT NULL AND (e.end_ms IS NULL OR e.end_ms >= ?1))
OR (e.status <> 'cancelled' AND {IS_MASTER}
AND (e.series_until_ms IS NULL OR e.series_until_ms >= ?1))
OR (e.status <> 'cancelled' AND e.recurring_event_id IS NULL AND NOT {IS_MASTER}
AND (e.end_ms IS NULL OR e.end_ms >= ?1))
)
ORDER BY e.start_ms, e.id"
);
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![from, to], map_event)
.map_err(|e| e.to_string())?;
for row in rows {
let row = row.map_err(|e| e.to_string())?;
if seen.insert(key(&row)) {
out.push(row);
}
}
drop(stmt);
// Every series the matched rows imply, whether the master matched on its own or only an
// exception of it did.
let mut series: Vec<(String, String)> = Vec::new();
let mut wanted: HashSet<(String, String)> = HashSet::new();
for row in &out {
let series_key = match (&row.recurring_event_id, row.recurrence.is_empty()) {
(Some(master), _) => (master.clone(), row.calendar_id.clone()),
(None, false) => (row.id.clone(), row.calendar_id.clone()),
(None, true) => continue,
};
if wanted.insert(series_key.clone()) {
series.push(series_key);
}
}
let master_sql = format!(
"SELECT {EVENT_COLS} FROM events e {LIVE} AND e.id = ?1 AND e.calendar_id = ?2"
);
let mut stmt = conn.prepare(&master_sql).map_err(|e| e.to_string())?;
for (event_id, calendar_id) in &series {
if seen.contains(&(event_id.clone(), calendar_id.clone())) {
continue;
}
let row = stmt
.query_row(params![event_id, calendar_id], map_event)
.optional()
.map_err(|e| e.to_string())?;
if let Some(row) = row {
if seen.insert(key(&row)) {
out.push(row);
}
}
}
drop(stmt);
// Exceptions come back whatever their own times say. A moved instance can be dragged into the
// window from outside it, and a cancelled one is only guaranteed to carry its id, its master,
// its original start and its status, so it has no times to filter on at all.
let exception_sql = format!(
"SELECT {EVENT_COLS} FROM events e {LIVE}
AND e.recurring_event_id = ?1 AND e.calendar_id = ?2"
);
let mut stmt = conn.prepare(&exception_sql).map_err(|e| e.to_string())?;
for (event_id, calendar_id) in &series {
let rows = stmt
.query_map(params![event_id, calendar_id], map_event)
.map_err(|e| e.to_string())?;
for row in rows {
let row = row.map_err(|e| e.to_string())?;
if seen.insert(key(&row)) {
out.push(row);
}
}
}
Ok(out)
}
fn key(row: &EventRow) -> (String, String) {
(row.id.clone(), row.calendar_id.clone())
}
fn map_event(r: &Row<'_>) -> rusqlite::Result<EventRow> {
let recurrence: String = r.get(13)?;
Ok(EventRow {
id: r.get(0)?,
calendar_id: r.get(1)?,
account_id: r.get(2)?,
etag: r.get(3)?,
status: r.get(4)?,
summary: r.get(5)?,
description: r.get(6)?,
location: r.get(7)?,
start_at: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
start_tz: r.get(9)?,
end_at: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
end_tz: r.get(11)?,
all_day: r.get::<_, i64>(12)? != 0,
recurrence: serde_json::from_str(&recurrence).unwrap_or_default(),
recurring_event_id: r.get(14)?,
original_start: r.get(15)?,
attendees: r.get(16)?,
conference: r.get(17)?,
updated_at: r.get(18)?,
dirty: r.get::<_, i64>(19)? != 0,
color_id: r.get(20)?,
})
}
+136
View File
@@ -0,0 +1,136 @@
// Tables and migrations. `migrate` is idempotent and forward-only: it runs the steps between the
// version recorded in `meta` and VERSION, and refuses to open a database written by a newer build
// rather than silently misreading it.
//
// No table declares a FOREIGN KEY. The auth agent writes `accounts` with INSERT OR REPLACE, and a
// REPLACE deletes the conflicting row first, which under `foreign_keys=ON` would either cascade
// away every calendar and event for that account or be rejected outright. Referential integrity is
// enforced by `write::wipe_account` and `write::clear_calendar` instead, which is the only place it
// actually matters.
use rusqlite::{Connection, OptionalExtension};
use crate::store::write::Tx;
pub const VERSION: i32 = 2;
/// The key `meta` carries the schema version under. Every other key belongs to a caller; account
/// scoped ones go through `write::account_meta_key` so `wipe_account` can find them again.
const VERSION_KEY: &str = "schema_version";
// `start_ms`, `end_ms` and `series_until_ms` are not in Google's shape. `start_at` is RFC3339 with
// an offset, or a bare YYYY-MM-DD when all-day, and neither form sorts or compares against an
// epoch-millisecond window in SQL. They are derived on every write from the columns beside them,
// so they are a cache, never a source of truth, and `read::masters_overlapping` is the only reader.
const V1: &str = "
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
keychain_ref TEXT,
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS calendars (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
description TEXT,
color_hex TEXT NOT NULL DEFAULT '',
selected INTEGER NOT NULL DEFAULT 1,
access_role TEXT NOT NULL DEFAULT 'reader',
time_zone TEXT NOT NULL DEFAULT '',
primary_cal INTEGER NOT NULL DEFAULT 0,
sync_token TEXT,
deleted INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (
id TEXT NOT NULL,
calendar_id TEXT NOT NULL,
account_id TEXT NOT NULL,
etag TEXT,
status TEXT NOT NULL DEFAULT 'confirmed',
summary TEXT NOT NULL DEFAULT '',
description TEXT,
location TEXT,
start_at TEXT,
start_tz TEXT,
end_at TEXT,
end_tz TEXT,
all_day INTEGER NOT NULL DEFAULT 0,
recurrence TEXT NOT NULL DEFAULT '[]',
recurring_event_id TEXT,
original_start TEXT,
attendees TEXT,
conference TEXT,
updated_at TEXT,
dirty INTEGER NOT NULL DEFAULT 0,
start_ms INTEGER,
end_ms INTEGER,
series_until_ms INTEGER,
color_id TEXT,
PRIMARY KEY (id, calendar_id)
);
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
op TEXT NOT NULL,
calendar_id TEXT NOT NULL,
event_id TEXT,
original_start TEXT,
scope TEXT,
payload TEXT,
etag TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_calendar_start ON events (calendar_id, start_ms);
CREATE INDEX IF NOT EXISTS idx_events_series ON events (recurring_event_id, original_start);
CREATE INDEX IF NOT EXISTS idx_events_account ON events (account_id);
CREATE INDEX IF NOT EXISTS idx_calendars_account ON calendars (account_id);
CREATE INDEX IF NOT EXISTS idx_outbox_created ON outbox (created_at, id);
";
// Google's per-event colour, which overrides the calendar's. Stored as the id rather than a hex so
// it round-trips to `colorId` on the wire unchanged.
const V2: &str = "ALTER TABLE events ADD COLUMN color_id TEXT;";
pub fn migrate(conn: &Connection) -> Result<(), String> {
conn.execute_batch("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);")
.map_err(|e| e.to_string())?;
let found = version(conn)?;
if found > VERSION {
return Err(format!(
"this calendar database is at schema {found}, which is newer than this build understands ({VERSION})"
));
}
if found == VERSION {
return Ok(());
}
let tx = Tx::begin(conn)?;
if found < 1 {
conn.execute_batch(V1).map_err(|e| e.to_string())?;
} else if found < 2 {
// Only for a database that predates the column; V1 already declares it.
conn.execute_batch(V2).map_err(|e| e.to_string())?;
}
conn.execute(
"INSERT INTO meta (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
rusqlite::params![VERSION_KEY, VERSION.to_string()],
)
.map_err(|e| e.to_string())?;
tx.commit()
}
fn version(conn: &Connection) -> Result<i32, String> {
let raw: Option<String> = conn
.query_row("SELECT value FROM meta WHERE key = ?1", [VERSION_KEY], |r| r.get(0))
.optional()
.map_err(|e| e.to_string())?;
Ok(raw.and_then(|v| v.parse::<i32>().ok()).unwrap_or(0))
}
+520
View File
@@ -0,0 +1,520 @@
// Row-level writes: upserts from sync, optimistic local writes, the outbox, and the two destructive
// paths that have to be surgical (a 410 drops one calendar, a disconnect drops one account).
//
// Every function takes `&Connection` rather than `&mut Connection` so callers can work straight off
// the `MutexGuard` in `Store`. Multi-row work runs under `Tx`, which rolls back on drop.
use chrono::{NaiveDate, NaiveDateTime, TimeZone, Utc};
use rusqlite::{params, Connection, OptionalExtension};
/// The flattened Google event shape as stored. `recurrence` keeps the raw RFC5545 lines so the
/// expander can hand them to the rrule crate untouched.
#[derive(Debug, Clone, Default)]
pub struct EventRow {
pub id: String,
pub calendar_id: String,
pub account_id: String,
pub etag: Option<String>,
pub status: String,
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
/// RFC3339 with offset, or YYYY-MM-DD when all_day. Empty when the row is a cancelled
/// instance, which Google returns carrying nothing but its id, master and original start.
pub start_at: String,
pub start_tz: Option<String>,
pub end_at: String,
pub end_tz: Option<String>,
pub all_day: bool,
pub recurrence: Vec<String>,
pub recurring_event_id: Option<String>,
pub original_start: Option<String>,
pub attendees: Option<String>,
pub conference: Option<String>,
pub updated_at: Option<String>,
/// Google's per-event colour id, 1 to 11, overriding the calendar's colour when set.
pub color_id: Option<String>,
pub dirty: bool,
}
/// One pending write. `payload` is JSON the sync agent defines; the store never reads it.
#[derive(Debug, Clone, Default)]
pub struct OutboxRow {
pub id: i64,
/// create | patch | delete
pub op: String,
pub calendar_id: String,
pub event_id: Option<String>,
pub original_start: Option<String>,
/// this | following | all
pub scope: Option<String>,
pub payload: Option<String>,
pub etag: Option<String>,
pub attempts: i64,
pub created_at: i64,
/// Recorded for diagnosis, never branched on. The drain decides from the live ApiError.
#[allow(dead_code)]
pub last_error: Option<String>,
}
/// A transaction that does not need `&mut Connection`. Rolls back unless committed, so an early
/// `?` cannot leave a half-applied sync page behind.
pub struct Tx<'a> {
conn: &'a Connection,
done: bool,
}
impl<'a> Tx<'a> {
pub fn begin(conn: &'a Connection) -> Result<Tx<'a>, String> {
conn.execute_batch("BEGIN IMMEDIATE;").map_err(|e| e.to_string())?;
Ok(Tx { conn, done: false })
}
pub fn commit(mut self) -> Result<(), String> {
self.done = true;
self.conn.execute_batch("COMMIT;").map_err(|e| e.to_string())
}
}
impl Drop for Tx<'_> {
fn drop(&mut self) {
if !self.done {
let _ = self.conn.execute_batch("ROLLBACK;");
}
}
}
pub fn now_ms() -> i64 {
Utc::now().timestamp_millis()
}
/// Epoch milliseconds for a stored timestamp. An all-day date is pinned to UTC midnight, which is
/// wrong by up to a zone offset on purpose: these columns only ever coarsen a window query, and
/// `read::masters_overlapping` pads the window by a day to cover it. The exact local placement
/// happens in `recur`, which is the only code allowed to interpret an all-day date.
pub fn epoch_ms(value: &str, all_day: bool) -> Option<i64> {
let value = value.trim();
if value.is_empty() {
return None;
}
if all_day || value.len() == 10 {
return NaiveDate::parse_from_str(value, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| Utc.from_utc_datetime(&dt).timestamp_millis());
}
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.timestamp_millis())
}
/// The last instant a series can produce, when the rule says so without being evaluated. None
/// means "assume it runs forever": COUNT needs the rule expanded to resolve, and an RDATE can put
/// an occurrence past any UNTIL, so both fall back to None and the expander does the real work.
fn series_until_ms(recurrence: &[String]) -> Option<i64> {
if recurrence.is_empty() {
return None;
}
let mut latest: Option<i64> = None;
for line in recurrence {
let upper = line.trim().to_uppercase();
if upper.starts_with("RDATE") {
return None;
}
if !upper.starts_with("RRULE") {
continue;
}
let body = upper.strip_prefix("RRULE:").unwrap_or(&upper);
let until = body
.split(';')
.find_map(|part| part.trim().strip_prefix("UNTIL="))?;
let ms = parse_until(until)?;
latest = Some(latest.map_or(ms, |current: i64| current.max(ms)));
}
latest
}
fn parse_until(value: &str) -> Option<i64> {
let value = value.trim();
for format in ["%Y%m%dT%H%M%SZ", "%Y%m%dT%H%M%S"] {
if let Ok(dt) = NaiveDateTime::parse_from_str(value, format) {
return Some(Utc.from_utc_datetime(&dt).timestamp_millis());
}
}
NaiveDate::parse_from_str(value, "%Y%m%d")
.ok()
.and_then(|d| d.and_hms_opt(23, 59, 59))
.map(|dt| Utc.from_utc_datetime(&dt).timestamp_millis())
}
fn blank_to_none(value: &str) -> Option<&str> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value)
}
}
// -- accounts ----------------------------------------------------------------------------------
/// Namespaced so `wipe_account` can delete every trace of an account by prefix. The calendarList
/// cursor belongs here rather than on `accounts`, whose columns the auth agent overwrites wholesale
/// with INSERT OR REPLACE.
pub fn account_meta_key(account_id: &str, name: &str) -> String {
format!("account:{account_id}:{name}")
}
pub fn upsert_account(
conn: &Connection,
id: &str,
email: &str,
keychain_ref: Option<&str>,
) -> Result<(), String> {
conn.execute(
"INSERT INTO accounts (id, email, keychain_ref, created_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET email = excluded.email, keychain_ref = excluded.keychain_ref",
params![id, email, keychain_ref, now_ms()],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Everything that belongs to an account: its calendars, their events, their queued writes, its
/// meta keys and the account row itself. Disconnect calls this rather than clearing a token,
/// because a store still holding another account's remote ids is a store that will happily write
/// against them after a reconnect.
pub fn wipe_account(conn: &Connection, account_id: &str) -> Result<(), String> {
let tx = Tx::begin(conn)?;
conn.execute(
"DELETE FROM outbox WHERE calendar_id IN (SELECT id FROM calendars WHERE account_id = ?1)",
[account_id],
)
.map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM events WHERE account_id = ?1
OR calendar_id IN (SELECT id FROM calendars WHERE account_id = ?1)",
[account_id],
)
.map_err(|e| e.to_string())?;
conn.execute("DELETE FROM calendars WHERE account_id = ?1", [account_id])
.map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM meta WHERE key LIKE 'account:' || ?1 || ':%'",
[account_id],
)
.map_err(|e| e.to_string())?;
conn.execute("DELETE FROM accounts WHERE id = ?1", [account_id])
.map_err(|e| e.to_string())?;
tx.commit()
}
// -- meta --------------------------------------------------------------------------------------
pub fn meta_get(conn: &Connection, key: &str) -> Result<Option<String>, String> {
conn.query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0))
.optional()
.map_err(|e| e.to_string())
}
pub fn meta_set(conn: &Connection, key: &str, value: &str) -> Result<(), String> {
conn.execute(
"INSERT INTO meta (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)
.map_err(|e| e.to_string())?;
Ok(())
}
// -- calendars ---------------------------------------------------------------------------------
/// A calendarList entry flattened. `selected` and `sync_token` are deliberately absent: selection
/// is the user's, held locally, and the cursor is the sync engine's.
#[derive(Debug, Clone, Default)]
pub struct CalendarRow {
pub id: String,
pub account_id: String,
pub summary: String,
pub description: Option<String>,
pub color_hex: String,
pub access_role: String,
pub time_zone: String,
pub primary: bool,
pub deleted: bool,
/// Google's own tick for this calendar. Seeds the local selection on insert only.
pub selected: bool,
}
/// Deliberately leaves `selected` and `sync_token` alone on an existing row, so a calendarList
/// refresh cannot re-tick a calendar the user unticked or discard a live cursor. On insert,
/// `selected` is seeded from Google's own setting, so a calendar hidden there starts hidden here.
///
/// `account_id` is left alone for the same reason. A calendar shared with two connected accounts
/// comes back in both calendarList responses, and letting the second one claim the row would flip
/// the owner on every pass. The sync token is issued per user, so each flip invalidates it and
/// forces a full resync of that calendar, for ever. First account to see it keeps it.
pub fn upsert_calendar(conn: &Connection, row: &CalendarRow) -> Result<(), String> {
conn.execute(
"INSERT INTO calendars
(id, account_id, summary, description, color_hex, selected, access_role, time_zone,
primary_cal, sync_token, deleted)
VALUES (?1, ?2, ?3, ?4, ?5, ?10, ?6, ?7, ?8, NULL, ?9)
ON CONFLICT(id) DO UPDATE SET
summary = excluded.summary,
description = excluded.description,
color_hex = excluded.color_hex,
access_role = excluded.access_role,
time_zone = excluded.time_zone,
primary_cal = excluded.primary_cal,
deleted = excluded.deleted",
params![
row.id,
row.account_id,
row.summary,
row.description,
row.color_hex,
row.access_role,
row.time_zone,
row.primary as i32,
row.deleted as i32,
row.selected as i32,
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn upsert_calendars(conn: &Connection, rows: &[CalendarRow]) -> Result<(), String> {
let tx = Tx::begin(conn)?;
for row in rows {
upsert_calendar(conn, row)?;
}
tx.commit()
}
pub fn set_calendar_selected(
conn: &Connection,
calendar_id: &str,
selected: bool,
) -> Result<(), String> {
let changed = conn
.execute(
"UPDATE calendars SET selected = ?2 WHERE id = ?1",
params![calendar_id, selected as i32],
)
.map_err(|e| e.to_string())?;
if changed == 0 {
return Err(format!("no such calendar: {calendar_id}"));
}
Ok(())
}
/// Removes a calendar and everything hanging off it, for a calendarList entry Google reports as
/// deleted.
pub fn delete_calendar(conn: &Connection, calendar_id: &str) -> Result<(), String> {
let tx = Tx::begin(conn)?;
conn.execute("DELETE FROM outbox WHERE calendar_id = ?1", [calendar_id])
.map_err(|e| e.to_string())?;
conn.execute("DELETE FROM events WHERE calendar_id = ?1", [calendar_id])
.map_err(|e| e.to_string())?;
conn.execute("DELETE FROM calendars WHERE id = ?1", [calendar_id])
.map_err(|e| e.to_string())?;
tx.commit()
}
/// Store surface kept for symmetry with the setter. The pull reads cursors through
/// `read::sync_targets`, which fetches them for every calendar in one statement.
#[allow(dead_code)]
pub fn calendar_sync_token(conn: &Connection, calendar_id: &str) -> Result<Option<String>, String> {
let token: Option<Option<String>> = conn
.query_row(
"SELECT sync_token FROM calendars WHERE id = ?1",
[calendar_id],
|r| r.get(0),
)
.optional()
.map_err(|e| e.to_string())?;
Ok(token.flatten())
}
pub fn set_calendar_sync_token(
conn: &Connection,
calendar_id: &str,
token: Option<&str>,
) -> Result<(), String> {
conn.execute(
"UPDATE calendars SET sync_token = ?2 WHERE id = ?1",
params![calendar_id, token],
)
.map_err(|e| e.to_string())?;
Ok(())
}
// -- events ------------------------------------------------------------------------------------
fn write_event(conn: &Connection, row: &EventRow, dirty: bool) -> Result<(), String> {
let recurrence = serde_json::to_string(&row.recurrence).map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO events
(id, calendar_id, account_id, etag, status, summary, description, location,
start_at, start_tz, end_at, end_tz, all_day, recurrence, recurring_event_id,
original_start, attendees, conference, updated_at, dirty,
start_ms, end_ms, series_until_ms, color_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18,
?19, ?20, ?21, ?22, ?23, ?24)",
params![
row.id,
row.calendar_id,
row.account_id,
row.etag,
row.status,
row.summary,
row.description,
row.location,
blank_to_none(&row.start_at),
row.start_tz,
blank_to_none(&row.end_at),
row.end_tz,
row.all_day as i32,
recurrence,
row.recurring_event_id,
row.original_start,
row.attendees,
row.conference,
row.updated_at,
dirty as i32,
epoch_ms(&row.start_at, row.all_day),
epoch_ms(&row.end_at, row.all_day),
series_until_ms(&row.recurrence),
row.color_id,
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn upsert_event(conn: &Connection, row: &EventRow) -> Result<(), String> {
write_event(conn, row, row.dirty)
}
/// One transaction for a whole sync page, so a failure part way through leaves the cursor and the
/// rows consistent with each other.
/// Store surface kept for symmetry with `upsert_calendars`. The pull batches inside its own
/// transaction so the cursor commits with the rows.
#[allow(dead_code)]
pub fn upsert_events(conn: &Connection, rows: &[EventRow]) -> Result<(), String> {
let tx = Tx::begin(conn)?;
for row in rows {
write_event(conn, row, row.dirty)?;
}
tx.commit()
}
/// The optimistic local write: the row lands marked dirty and renders before Google has seen it.
pub fn upsert_event_dirty(conn: &Connection, row: &EventRow) -> Result<(), String> {
write_event(conn, row, true)
}
/// Called once a queued write has actually landed at Google.
pub fn clear_dirty(conn: &Connection, calendar_id: &str, event_id: &str) -> Result<(), String> {
conn.execute(
"UPDATE events SET dirty = 0 WHERE id = ?1 AND calendar_id = ?2",
params![event_id, calendar_id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn delete_event(conn: &Connection, calendar_id: &str, event_id: &str) -> Result<(), String> {
conn.execute(
"DELETE FROM events WHERE id = ?1 AND calendar_id = ?2",
params![event_id, calendar_id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// 410 recovery. Drops one calendar's rows and its cursor and nothing else, so the full resync that
/// follows is scoped to the calendar whose token died.
pub fn clear_calendar(conn: &Connection, calendar_id: &str) -> Result<(), String> {
let tx = Tx::begin(conn)?;
conn.execute("DELETE FROM events WHERE calendar_id = ?1", [calendar_id])
.map_err(|e| e.to_string())?;
conn.execute(
"UPDATE calendars SET sync_token = NULL WHERE id = ?1",
[calendar_id],
)
.map_err(|e| e.to_string())?;
tx.commit()
}
// -- outbox ------------------------------------------------------------------------------------
/// Returns the new row id. `created_at` is filled in here unless the caller has already set it, so
/// the drain order is the enqueue order.
pub fn enqueue(conn: &Connection, row: &OutboxRow) -> Result<i64, String> {
let created_at = if row.created_at > 0 { row.created_at } else { now_ms() };
conn.execute(
"INSERT INTO outbox
(op, calendar_id, event_id, original_start, scope, payload, etag, attempts,
created_at, last_error)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, ?8, NULL)",
params![
row.op,
row.calendar_id,
row.event_id,
row.original_start,
row.scope,
row.payload,
row.etag,
created_at,
],
)
.map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid())
}
/// Oldest first. The drain takes a batch, pushes each in turn and dequeues what lands.
pub fn peek_outbox(conn: &Connection, limit: u32) -> Result<Vec<OutboxRow>, String> {
let mut stmt = conn
.prepare(
"SELECT id, op, calendar_id, event_id, original_start, scope, payload, etag,
attempts, created_at, last_error
FROM outbox ORDER BY created_at, id LIMIT ?1",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([limit], |r| {
Ok(OutboxRow {
id: r.get(0)?,
op: r.get(1)?,
calendar_id: r.get(2)?,
event_id: r.get(3)?,
original_start: r.get(4)?,
scope: r.get(5)?,
payload: r.get(6)?,
etag: r.get(7)?,
attempts: r.get(8)?,
created_at: r.get(9)?,
last_error: r.get(10)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
pub fn mark_attempt_failed(conn: &Connection, id: i64, error: &str) -> Result<(), String> {
conn.execute(
"UPDATE outbox SET attempts = attempts + 1, last_error = ?2 WHERE id = ?1",
params![id, error],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn dequeue(conn: &Connection, id: i64) -> Result<(), String> {
conn.execute("DELETE FROM outbox WHERE id = ?1", [id])
.map_err(|e| e.to_string())?;
Ok(())
}
+404
View File
@@ -0,0 +1,404 @@
// The sync engine: the poll loop, the pull, the outbox and the quit flush.
//
// events.list with a stored syncToken, singleEvents=false, showDeleted=true, maxResults=2500, and
// the parameter set byte-identical on every call in a chain including the first, which `api` owns
// so it cannot drift. timeMin/timeMax are rejected alongside syncToken, so there is no windowed
// incremental sync and the initial pull is the whole calendar history. That is only tolerable
// because singleEvents=false collapses a series to one row, and the mitigation for a slow first
// pull is `sync-progress`, not a window.
//
// Polling, not webhooks: watch channels need a publicly verified HTTPS callback and expire every
// few days. Sixty seconds focused, five minutes unfocused.
//
// Everything the engine does to Google goes through `transport::Transport`. That seam is what makes
// pagination, 410 recovery and the outbox drain testable against a stub and an in-memory database.
mod model;
mod pull;
mod push;
mod transport;
#[cfg(test)]
mod tests;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rusqlite::Connection;
use tauri::{Emitter, Listener, Manager};
use crate::dto::{EventDraft, EventPatch, Instance, InstanceKey, Scope, SyncStatus};
use crate::store::write::EventRow;
use crate::store::{read, write, Store};
pub const POLL_FOCUSED_SECS: u64 = 60;
pub const POLL_UNFOCUSED_SECS: u64 = 300;
/// The first pass runs a couple of seconds after launch rather than a poll interval later, so a
/// cold start shows fresh data.
const FIRST_PASS_SECS: u64 = 2;
/// How long a pass gives the outbox before it moves on to the pull. A deeper queue drains further
/// on the next tick rather than holding the pull up.
const PUSH_BUDGET_SECS: u64 = 20;
/// Quit is racing a timeout on the frontend, so the flush takes what it can get and returns.
const FLUSH_BUDGET_SECS: u64 = 4;
/// Widened by a day either side, this is the window that finds a series' stored exceptions. The
/// store has no read for "the rows of one series", and this is the read that comes closest.
const DAY_MS: i64 = 86_400_000;
const LAST_SYNC_KEY: &str = "last-sync";
#[derive(Default)]
pub struct SyncState {
pub status: Mutex<SyncStatus>,
/// Held for the duration of a sync pass so the poll tick and a manual sync_now cannot overlap.
pub running: tokio::sync::Mutex<()>,
/// Wakes the poll loop early. A new account or a local write should not wait out the interval.
wake: Arc<tokio::sync::Notify>,
}
/// What one half of a pass did. `offline` is deliberately not an error: it is the expected state on
/// a plane, and surfacing it as a failure would train the user to ignore the failure indicator.
#[derive(Debug, Default)]
pub struct Outcome {
pub changed: bool,
pub error: Option<String>,
pub offline: bool,
}
/// Where progress goes. The engine is written against this rather than against `AppHandle` so the
/// tests can run a whole pass with nothing but a recorder.
pub trait Sink: Sync {
/// Merged into the stored status and emitted as `sync-progress`.
fn message(&self, text: &str);
fn status(&self, status: &SyncStatus);
/// Purely an invalidation signal: the frontend re-requests its visible range and nothing here
/// tries to describe what moved.
fn changed(&self, reason: &str);
}
struct AppSink {
app: tauri::AppHandle,
}
impl Sink for AppSink {
fn message(&self, text: &str) {
let status = match self.app.try_state::<SyncState>() {
Some(state) => {
let Ok(mut current) = state.status.lock() else {
return;
};
current.phase = "syncing".to_string();
current.message = Some(text.to_string());
current.clone()
}
None => return,
};
let _ = self.app.emit("sync-progress", status);
}
fn status(&self, status: &SyncStatus) {
if let Some(state) = self.app.try_state::<SyncState>() {
if let Ok(mut current) = state.status.lock() {
*current = status.clone();
}
}
let _ = self.app.emit("sync-progress", status.clone());
}
fn changed(&self, reason: &str) {
crate::emit_store_changed(&self.app, reason);
}
}
/// Takes the store's connection for one synchronous unit of work. Nothing inside may await: the
/// guard is a std one, so holding it across a suspension point would make the future non-Send.
fn with_conn<T>(
store: &Store,
f: impl FnOnce(&Connection) -> Result<T, String>,
) -> Result<T, String> {
let conn = store.conn.lock().map_err(|e| e.to_string())?;
f(&conn)
}
fn pending_writes(store: &Store) -> u32 {
with_conn(store, read::pending_writes).unwrap_or(0)
}
fn last_sync(store: &Store) -> Option<i64> {
with_conn(store, |conn| write::meta_get(conn, LAST_SYNC_KEY))
.ok()
.flatten()
.and_then(|value| value.parse().ok())
}
/// Push first, then pull, so a write that has just landed comes back as the server's own row in the
/// same pass rather than a tick later.
async fn run_pass(
store: &Store,
transport: &impl transport::Transport,
sink: &impl Sink,
) -> SyncStatus {
let mut status = SyncStatus {
phase: "syncing".to_string(),
last_sync: last_sync(store),
error: None,
pending_writes: pending_writes(store),
message: None,
};
sink.status(&status);
let deadline = Instant::now() + Duration::from_secs(PUSH_BUDGET_SECS);
let pushed = push::drain(store, transport, deadline).await;
if pushed.changed {
sink.changed("outbox");
}
let pulled = if pushed.offline {
Outcome::default()
} else {
pull::sync_all(store, transport, sink).await
};
status.pending_writes = pending_writes(store);
status.message = None;
match pushed.error.or(pulled.error) {
Some(error) => {
status.phase = "error".to_string();
status.error = Some(error);
}
None => {
status.phase = "idle".to_string();
status.error = None;
if pushed.offline || pulled.offline {
status.message = Some("Offline".to_string());
} else {
let now = write::now_ms();
let _ = with_conn(store, |conn| {
write::meta_set(conn, LAST_SYNC_KEY, &now.to_string())
});
status.last_sync = Some(now);
}
}
}
sink.status(&status);
status
}
/// Spawned from `setup`. Ticks on the focused interval while the window has focus and the
/// unfocused one otherwise, and wakes early when something has just been queued or connected.
pub fn start_loop(app: tauri::AppHandle) {
let wake = match app.try_state::<SyncState>() {
Some(state) => state.wake.clone(),
None => return,
};
// A freshly connected account should not sit unsynced for a poll interval. `store-changed` is
// the only signal the connect flow emits, and its reason distinguishes this from our own.
let listener = wake.clone();
app.listen("store-changed", move |event| {
if event.payload().contains("account-connected") {
listener.notify_one();
}
});
tauri::async_runtime::spawn(async move {
let mut delay = Duration::from_secs(FIRST_PASS_SECS);
loop {
let _ = tokio::time::timeout(delay, wake.notified()).await;
tick(&app).await;
delay = Duration::from_secs(if focused(&app) {
POLL_FOCUSED_SECS
} else {
POLL_UNFOCUSED_SECS
});
}
});
}
fn focused(app: &tauri::AppHandle) -> bool {
app.webview_windows()
.values()
.any(|window| window.is_focused().unwrap_or(false))
}
async fn tick(app: &tauri::AppHandle) {
let Some(store) = app.try_state::<Store>() else {
return;
};
let state = app.state::<SyncState>();
// A manual sync is already in flight, and this tick has nothing to add to it.
let Ok(_guard) = state.running.try_lock() else {
return;
};
let transport = transport::Google { app: app.clone() };
let sink = AppSink { app: app.clone() };
run_pass(&store, &transport, &sink).await;
}
fn kick(app: &tauri::AppHandle) {
if let Some(state) = app.try_state::<SyncState>() {
state.wake.notify_one();
}
}
/// The queue depth is half of the optimistic story: a write that has not left yet should say so
/// before the next pass gets round to saying it.
fn note_pending(app: &tauri::AppHandle, store: &Store) {
let Some(state) = app.try_state::<SyncState>() else {
return;
};
let count = pending_writes(store);
let status = {
let Ok(mut current) = state.status.lock() else {
return;
};
current.pending_writes = count;
current.clone()
};
let _ = app.emit("sync-progress", status);
}
/// Drains the outbox on quit. The frontend races this against a timeout before destroying the
/// window, the same shape as margin's App.tsx:45 close-request hook, so it returns on a budget
/// however deep the queue is.
#[tauri::command]
pub async fn sync_flush(app: tauri::AppHandle) -> Result<(), String> {
let Some(store) = app.try_state::<Store>() else {
return Ok(());
};
let state = app.state::<SyncState>();
// Wait briefly for a pass in flight, then go anyway: the caller is on its way out.
let _guard = tokio::time::timeout(Duration::from_secs(1), state.running.lock())
.await
.ok();
let transport = transport::Google { app: app.clone() };
let deadline = Instant::now() + Duration::from_secs(FLUSH_BUDGET_SECS);
let outcome = push::drain(&store, &transport, deadline).await;
if outcome.changed {
crate::emit_store_changed(&app, "outbox");
}
Ok(())
}
#[tauri::command]
pub async fn sync_now(
app: tauri::AppHandle,
state: tauri::State<'_, SyncState>,
) -> Result<SyncStatus, String> {
let _guard = state.running.lock().await;
let store = app
.try_state::<Store>()
.ok_or("the local store is not open")?;
let transport = transport::Google { app: app.clone() };
let sink = AppSink { app: app.clone() };
Ok(run_pass(&store, &transport, &sink).await)
}
#[tauri::command]
pub async fn sync_status(state: tauri::State<'_, SyncState>) -> Result<SyncStatus, String> {
let status = state.status.lock().map_err(|e| e.to_string())?.clone();
Ok(status)
}
#[tauri::command]
pub async fn event_create(app: tauri::AppHandle, draft: EventDraft) -> Result<Instance, String> {
let store = app
.try_state::<Store>()
.ok_or("the local store is not open")?;
let instance = with_conn(&store, |conn| push::create(conn, &draft))?;
note_pending(&app, &store);
crate::emit_store_changed(&app, "event-created");
kick(&app);
Ok(instance)
}
#[tauri::command]
pub async fn event_update(
app: tauri::AppHandle,
key: InstanceKey,
patch: EventPatch,
scope: Scope,
) -> Result<(), String> {
let store = app
.try_state::<Store>()
.ok_or("the local store is not open")?;
let rows = with_conn(&store, |conn| series_rows(conn, &key))?;
let plans = crate::recur::plan_edit(&rows, &key, &patch, scope)?;
with_conn(&store, |conn| push::apply_plans(conn, &rows, &plans))?;
note_pending(&app, &store);
crate::emit_store_changed(&app, "event-updated");
kick(&app);
Ok(())
}
#[tauri::command]
pub async fn event_delete(
app: tauri::AppHandle,
key: InstanceKey,
scope: Scope,
) -> Result<(), String> {
let store = app
.try_state::<Store>()
.ok_or("the local store is not open")?;
let rows = with_conn(&store, |conn| series_rows(conn, &key))?;
let plans = crate::recur::plan_delete(&rows, &key, scope)?;
with_conn(&store, |conn| push::apply_plans(conn, &rows, &plans))?;
note_pending(&app, &store);
crate::emit_store_changed(&app, "event-deleted");
kick(&app);
Ok(())
}
/// The rows `recur` needs to plan an edit: the event the key names, its master if the key named an
/// exception, and every stored exception of that series. `InstanceKey` carries no calendar, and the
/// store has no read that fetches a series by id, so the event comes from a point lookup per
/// calendar and the exceptions come from the window read around the occurrence.
fn series_rows(conn: &Connection, key: &InstanceKey) -> Result<Vec<EventRow>, String> {
let mut rows: Vec<EventRow> = Vec::new();
let calendars = read::calendars(conn)?;
for calendar in &calendars {
if let Some(row) = read::event(conn, &calendar.id, &key.event_id)? {
rows.push(row);
}
}
let masters: Vec<(String, String)> = rows
.iter()
.filter_map(|row| {
row.recurring_event_id
.clone()
.map(|master| (row.calendar_id.clone(), master))
})
.collect();
for (calendar_id, master) in masters {
if let Some(row) = read::event(conn, &calendar_id, &master)? {
rows.push(row);
}
}
let anchor = key
.original_start
.clone()
.or_else(|| rows.first().map(|row| row.start_at.clone()));
if let Some(anchor) = anchor {
let all_day = anchor.len() == 10;
if let Some(ms) = write::epoch_ms(&anchor, all_day) {
for row in read::masters_overlapping(conn, ms - DAY_MS, ms + DAY_MS)? {
let known = rows
.iter()
.any(|seen| seen.id == row.id && seen.calendar_id == row.calendar_id);
let of_this_series = row
.recurring_event_id
.as_deref()
.is_some_and(|master| master == key.event_id);
if of_this_series && !known {
rows.push(row);
}
}
}
}
Ok(rows)
}
+277
View File
@@ -0,0 +1,277 @@
// Conversions between Google's shape, the store's rows and the IPC DTOs. Nothing here reaches the
// network or the database, so every one of these is a pure function the tests can lean on.
use chrono::{Local, NaiveDate, TimeZone};
use serde_json::{json, Map, Value};
use crate::dto::{EventDraft, EventPatch, Instance};
use crate::google::api::{EventDateTime, RawCalendar, RawEvent};
use crate::store::write::{self, CalendarRow, EventRow};
/// A timestamp as stored: the RFC3339 form when there is one, the bare date when the event is
/// all-day. Never converted, because an all-day date shifted into a zone is the bug this whole
/// codebase is arranged to avoid.
fn stamp(value: &EventDateTime) -> Option<String> {
value.date_time.clone().or_else(|| value.date.clone())
}
fn split(value: Option<&EventDateTime>) -> (String, Option<String>) {
match value {
Some(value) => (stamp(value).unwrap_or_default(), value.time_zone.clone()),
None => (String::new(), None),
}
}
pub fn is_cancelled(raw: &RawEvent) -> bool {
raw.status.as_deref() == Some("cancelled")
}
pub fn event_row(raw: &RawEvent, calendar_id: &str, account_id: &str) -> EventRow {
let all_day = raw
.start
.as_ref()
.map(|start| start.date.is_some())
.unwrap_or(false);
let (start_at, start_tz) = split(raw.start.as_ref());
let (end_at, end_tz) = split(raw.end.as_ref());
EventRow {
id: raw.id.clone(),
calendar_id: calendar_id.to_string(),
account_id: account_id.to_string(),
etag: raw.etag.clone(),
status: raw
.status
.clone()
.unwrap_or_else(|| "confirmed".to_string()),
summary: raw.summary.clone().unwrap_or_default(),
description: raw.description.clone(),
location: raw.location.clone(),
start_at,
start_tz,
end_at,
end_tz,
all_day,
recurrence: raw.recurrence.clone().unwrap_or_default(),
recurring_event_id: raw.recurring_event_id.clone(),
original_start: raw.original_start_time.as_ref().and_then(stamp),
attendees: raw.attendees.as_ref().map(|v| v.to_string()),
conference: raw.conference_data.as_ref().map(|v| v.to_string()),
updated_at: raw.updated.clone(),
color_id: raw.color_id.clone(),
dirty: false,
}
}
pub fn calendar_row(raw: &RawCalendar, account_id: &str) -> CalendarRow {
CalendarRow {
id: raw.id.clone(),
account_id: account_id.to_string(),
summary: raw.summary.clone().unwrap_or_else(|| raw.id.clone()),
description: raw.description.clone(),
color_hex: raw.background_color.clone().unwrap_or_default(),
access_role: raw
.access_role
.clone()
.unwrap_or_else(|| "reader".to_string()),
time_zone: raw.time_zone.clone().unwrap_or_default(),
primary: raw.primary.unwrap_or(false),
deleted: raw.deleted.unwrap_or(false),
// Google's own tick, so a calendar the user has hidden there starts hidden here rather
// than dumping twenty calendars onto the grid on first sync. Insert only: once the row
// exists, the local selection is the user's and a refresh must not overwrite it.
selected: raw.selected.unwrap_or(true),
}
}
pub fn writable(access_role: &str) -> bool {
matches!(access_role, "owner" | "writer")
}
/// `{"date": ...}` for an all-day event, `{"dateTime": ...}` otherwise. A date-only string is
/// treated as all-day whatever the flag says, since sending one under `dateTime` is a 400.
///
/// The zone rides along on a timed event because the offset alone only pins the instant. A series
/// filed under the wrong zone drifts an hour away from itself at the next DST transition, and an
/// all-day date has no zone to be wrong about.
fn when(value: &str, all_day: bool, zone: Option<&str>) -> Value {
if all_day || value.len() == 10 {
return json!({ "date": value });
}
match zone.filter(|zone| !zone.is_empty()) {
Some(zone) => json!({ "dateTime": value, "timeZone": zone }),
None => json!({ "dateTime": value }),
}
}
pub fn draft_body(
draft: &EventDraft,
id: &str,
start_tz: Option<&str>,
end_tz: Option<&str>,
) -> Value {
let mut body = Map::new();
body.insert("id".to_string(), json!(id));
body.insert("summary".to_string(), json!(draft.summary));
if let Some(value) = &draft.description {
body.insert("description".to_string(), json!(value));
}
if let Some(value) = &draft.location {
body.insert("location".to_string(), json!(value));
}
body.insert(
"start".to_string(),
when(&draft.start, draft.all_day, start_tz),
);
body.insert("end".to_string(), when(&draft.end, draft.all_day, end_tz));
if !draft.recurrence.is_empty() {
body.insert("recurrence".to_string(), json!(draft.recurrence));
}
if let Some(color) = draft.color_id.as_deref().filter(|c| !c.is_empty()) {
body.insert("colorId".to_string(), json!(color));
}
Value::Object(body)
}
/// An absent field stays absent, which is what makes this a patch. `all_day` and the zones come
/// from the row rather than the patch, because a patch that moves an event without restating its
/// all-day flag still has to send the right half of the union, and one that moves a series without
/// restating its zone must not let Google refile it under the calendar's.
pub fn patch_body(patch: &EventPatch, all_day: bool, start_tz: Option<&str>, end_tz: Option<&str>) -> Value {
let mut body = Map::new();
if let Some(value) = &patch.summary {
body.insert("summary".to_string(), json!(value));
}
if let Some(value) = &patch.description {
body.insert("description".to_string(), json!(value));
}
if let Some(value) = &patch.location {
body.insert("location".to_string(), json!(value));
}
let all_day = patch.all_day.unwrap_or(all_day);
if let Some(value) = &patch.start {
body.insert("start".to_string(), when(value, all_day, start_tz));
}
if let Some(value) = &patch.end {
body.insert("end".to_string(), when(value, all_day, end_tz));
}
if let Some(value) = &patch.recurrence {
body.insert("recurrence".to_string(), json!(value));
}
// An empty string means "back to the calendar's colour", which Google spells as a null colorId.
if let Some(value) = &patch.color_id {
body.insert(
"colorId".to_string(),
if value.is_empty() { Value::Null } else { json!(value) },
);
}
Value::Object(body)
}
pub fn recurrence_body(recurrence: &[String]) -> Value {
json!({ "recurrence": recurrence })
}
pub fn draft_row(draft: &EventDraft, id: &str, account_id: &str) -> EventRow {
EventRow {
id: id.to_string(),
calendar_id: draft.calendar_id.clone(),
account_id: account_id.to_string(),
status: "confirmed".to_string(),
summary: draft.summary.clone(),
description: draft.description.clone(),
location: draft.location.clone(),
start_at: draft.start.clone(),
end_at: draft.end.clone(),
all_day: draft.all_day,
recurrence: draft.recurrence.clone(),
color_id: draft.color_id.clone().filter(|c| !c.is_empty()),
dirty: true,
..Default::default()
}
}
pub fn apply_patch(row: &mut EventRow, patch: &EventPatch) {
if let Some(value) = &patch.summary {
row.summary = value.clone();
}
if let Some(value) = &patch.description {
row.description = (!value.is_empty()).then(|| value.clone());
}
if let Some(value) = &patch.location {
row.location = (!value.is_empty()).then(|| value.clone());
}
if let Some(value) = &patch.color_id {
row.color_id = (!value.is_empty()).then(|| value.clone());
}
if let Some(value) = patch.all_day {
row.all_day = value;
}
if let Some(value) = &patch.start {
row.start_at = value.clone();
}
if let Some(value) = &patch.end {
row.end_at = value.clone();
}
if let Some(value) = &patch.recurrence {
row.recurrence = value.clone();
}
}
/// Epoch milliseconds for the optimistic echo only. `recur` owns the real arithmetic; this exists
/// because an all-day event has to sit at local midnight rather than the UTC midnight the store's
/// window columns use, and the echo renders before the next read replaces it.
fn local_ms(value: &str, all_day: bool) -> i64 {
if all_day || value.len() == 10 {
return NaiveDate::parse_from_str(value, "%Y-%m-%d")
.ok()
.and_then(|date| date.and_hms_opt(0, 0, 0))
.and_then(|naive| Local.from_local_datetime(&naive).earliest())
.map(|dt| dt.timestamp_millis())
.unwrap_or(0);
}
write::epoch_ms(value, false).unwrap_or(0)
}
/// The row a local write just made, as the frontend will render it until the next read. Attendees
/// and conferencing are Google's to fill in, so they are empty rather than guessed at.
pub fn pending_instance(row: &EventRow, color_hex: &str, read_only: bool) -> Instance {
let color_hex = crate::recur::event_color(row.color_id.as_deref()).unwrap_or(color_hex);
Instance {
event_id: row.id.clone(),
calendar_id: row.calendar_id.clone(),
account_id: row.account_id.clone(),
original_start: row.original_start.clone(),
start: row.start_at.clone(),
end: row.end_at.clone(),
start_ms: local_ms(&row.start_at, row.all_day),
end_ms: local_ms(&row.end_at, row.all_day),
all_day: row.all_day,
summary: row.summary.clone(),
description: row.description.clone(),
location: row.location.clone(),
status: row.status.clone(),
recurring: !row.recurrence.is_empty(),
color_hex: color_hex.to_string(),
color_id: row.color_id.clone(),
etag: row.etag.clone(),
organizer: None,
attendees: Vec::new(),
conference: None,
read_only,
pending: true,
}
}
/// Google accepts a client-chosen event id in base32hex, which is what makes an optimistic create
/// idempotent: the row keeps the id it rendered with, and a retry of a request whose response was
/// lost collides with itself rather than creating a second event.
pub fn new_event_id() -> String {
use rand::RngCore;
const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuv";
let mut bytes = [0u8; 26];
rand::thread_rng().fill_bytes(&mut bytes);
bytes
.iter()
.map(|byte| ALPHABET[(byte & 0x1f) as usize] as char)
.collect()
}
+297
View File
@@ -0,0 +1,297 @@
// The read half of a pass: the calendarList for every connected account, then `events.list` for
// every calendar, incremental where there is a cursor and full where there is not.
//
// The one rule that everything else hangs off: `nextSyncToken` appears only on the final page, so
// pages are walked to exhaustion and nothing at all is written until the walk is over. Committing a
// token from the middle of a chain corrupts the cursor silently, and the next incremental sync
// returns changes since the middle of a page rather than since the last pass.
use std::collections::HashSet;
use rusqlite::Connection;
use crate::google::api::{ApiError, RawCalendar, RawEvent};
use crate::store::read::{self, SyncTarget};
use crate::store::write::{self, Tx};
use crate::store::Store;
use super::model;
use super::transport::Transport;
use super::{with_conn, Outcome, Sink};
/// A calendar with more pages than this is a runaway, not a calendar. 2500 events a page puts the
/// ceiling well past any real history and stops a repeated `pageToken` looping forever.
const MAX_PAGES: usize = 2_000;
const CALENDAR_LIST_TOKEN: &str = "calendar-list-token";
pub async fn sync_all(
store: &Store,
transport: &impl Transport,
sink: &impl Sink,
) -> Outcome {
let mut outcome = Outcome::default();
let accounts = match with_conn(store, read::accounts) {
Ok(accounts) => accounts,
Err(error) => {
outcome.error = Some(error);
return outcome;
}
};
let connected: HashSet<String> = accounts
.iter()
.filter(|account| account.connected)
.map(|account| account.id.clone())
.collect();
for account_id in &connected {
match sync_calendar_list(store, transport, account_id).await {
Ok(changed) => outcome.changed |= changed,
Err(ApiError::Offline(_)) => {
outcome.offline = true;
return outcome;
}
Err(error) => {
outcome.error.get_or_insert(error.to_string());
}
}
}
if outcome.changed {
sink.changed("calendars");
}
let targets = match with_conn(store, read::sync_targets) {
Ok(targets) => targets,
Err(error) => {
outcome.error = Some(error);
return outcome;
}
};
let targets: Vec<SyncTarget> = targets
.into_iter()
.filter(|target| connected.contains(&target.account_id))
.collect();
let total = targets.len();
for (index, target) in targets.iter().enumerate() {
sink.message(&format!("Syncing calendar {} of {total}", index + 1));
match sync_calendar(store, transport, target).await {
Ok(changed) => {
if changed {
outcome.changed = true;
// Emitted per calendar rather than once at the end, so a long initial pull
// renders as it lands instead of after it.
sink.changed("sync");
}
}
Err(ApiError::Offline(_)) => {
outcome.offline = true;
return outcome;
}
// One calendar failing is not the pass failing: a shared calendar the user lost access
// to would otherwise stop every other calendar from ever syncing again.
Err(error) => {
outcome
.error
.get_or_insert(format!("{}: {error}", target.calendar_id));
}
}
}
outcome
}
/// Returns whether anything landed. A 410 clears this one calendar and resyncs it alone; every
/// other calendar keeps its rows and its cursor.
async fn sync_calendar(
store: &Store,
transport: &impl Transport,
target: &SyncTarget,
) -> Result<bool, ApiError> {
let mut token = target.sync_token.clone();
let mut recovered = false;
loop {
match collect_events(transport, target, token.as_deref()).await {
Ok((items, next)) => {
return with_conn(store, |conn| apply_events(conn, target, &items, &next))
.map_err(ApiError::Other);
}
Err(ApiError::SyncTokenExpired) if !recovered && token.is_some() => {
recovered = true;
token = None;
with_conn(store, |conn| {
write::clear_calendar(conn, &target.calendar_id)
})
.map_err(ApiError::Other)?;
}
Err(error) => return Err(error),
}
}
}
/// Walks to exhaustion and hands back everything at once, because the token that makes the pages
/// worth keeping only arrives with the last of them.
async fn collect_events(
transport: &impl Transport,
target: &SyncTarget,
sync_token: Option<&str>,
) -> Result<(Vec<RawEvent>, String), ApiError> {
let mut items: Vec<RawEvent> = Vec::new();
let mut page_token: Option<String> = None;
for _ in 0..MAX_PAGES {
let page = transport
.events_list(
&target.account_id,
&target.calendar_id,
sync_token,
page_token.as_deref(),
)
.await?;
items.extend(page.items);
match page.next_page_token {
Some(next) => page_token = Some(next),
None => {
let token = page.next_sync_token.ok_or_else(|| {
ApiError::Other(format!(
"{}: the last page of events carried no sync token",
target.calendar_id
))
})?;
return Ok((items, token));
}
}
}
Err(ApiError::Other(format!(
"{}: the event list did not end after {MAX_PAGES} pages",
target.calendar_id
)))
}
/// One transaction for the whole chain, so the rows and the cursor can never disagree.
fn apply_events(
conn: &Connection,
target: &SyncTarget,
items: &[RawEvent],
token: &str,
) -> Result<bool, String> {
let tx = Tx::begin(conn)?;
for raw in items {
let row = model::event_row(raw, &target.calendar_id, &target.account_id);
// A cancelled instance of a series is kept: it is how the expander knows to drop that
// occurrence, and it carries nothing but its id, its master and its original start. A
// cancelled anything else is simply gone.
if model::is_cancelled(raw) && row.recurring_event_id.is_none() {
write::delete_event(conn, &target.calendar_id, &row.id)?;
} else {
write::upsert_event(conn, &row)?;
}
}
write::set_calendar_sync_token(conn, &target.calendar_id, Some(token))?;
tx.commit()?;
Ok(!items.is_empty())
}
/// The calendarList carries its own sync token, kept in `meta` because `calendars` has no row to
/// hang an account-wide cursor off.
async fn sync_calendar_list(
store: &Store,
transport: &impl Transport,
account_id: &str,
) -> Result<bool, ApiError> {
let key = write::account_meta_key(account_id, CALENDAR_LIST_TOKEN);
let mut token = with_conn(store, |conn| write::meta_get(conn, &key)).map_err(ApiError::Other)?;
let mut recovered = false;
loop {
match collect_calendars(transport, account_id, token.as_deref()).await {
Ok((items, next)) => {
let full = token.is_none();
return with_conn(store, |conn| {
apply_calendars(conn, account_id, &items, &next, full)
})
.map_err(ApiError::Other);
}
Err(ApiError::SyncTokenExpired) if !recovered && token.is_some() => {
recovered = true;
token = None;
}
Err(error) => return Err(error),
}
}
}
async fn collect_calendars(
transport: &impl Transport,
account_id: &str,
sync_token: Option<&str>,
) -> Result<(Vec<RawCalendar>, String), ApiError> {
let mut items: Vec<RawCalendar> = Vec::new();
let mut page_token: Option<String> = None;
for _ in 0..MAX_PAGES {
let page = transport
.calendar_list(account_id, sync_token, page_token.as_deref())
.await?;
items.extend(page.items);
match page.next_page_token {
Some(next) => page_token = Some(next),
None => {
let token = page.next_sync_token.ok_or_else(|| {
ApiError::Other(
"the last page of the calendar list carried no sync token".to_string(),
)
})?;
return Ok((items, token));
}
}
}
Err(ApiError::Other(
"the calendar list did not end".to_string(),
))
}
fn apply_calendars(
conn: &Connection,
account_id: &str,
items: &[RawCalendar],
token: &str,
full: bool,
) -> Result<bool, String> {
let mut changed = false;
let rows: Vec<_> = items
.iter()
.map(|raw| model::calendar_row(raw, account_id))
.collect();
let live: Vec<_> = rows.iter().filter(|row| !row.deleted).cloned().collect();
if !live.is_empty() {
write::upsert_calendars(conn, &live)?;
changed = true;
}
for row in rows.iter().filter(|row| row.deleted) {
write::delete_calendar(conn, &row.id)?;
changed = true;
}
// A full list is the whole truth, so anything local that is missing from it is a calendar the
// account no longer has. An incremental page says nothing about what it did not mention.
if full {
let returned: HashSet<&str> = rows.iter().map(|row| row.id.as_str()).collect();
for calendar in read::calendars(conn)? {
if calendar.account_id == account_id && !returned.contains(calendar.id.as_str()) {
write::delete_calendar(conn, &calendar.id)?;
changed = true;
}
}
}
write::meta_set(
conn,
&write::account_meta_key(account_id, CALENDAR_LIST_TOKEN),
token,
)?;
Ok(changed)
}
+499
View File
@@ -0,0 +1,499 @@
// The write half: local writes land in SQLite dirty and queued, and the push to Google happens
// behind them.
//
// The queue is drained oldest first. Offline is not a failure, so the entry keeps its attempt count
// and waits for the network. A 412 is a lost race, so the entry is kept and surfaced but never
// retried with the etag dropped, because dropping it is exactly the clobber the check exists to
// prevent. Anything else counts an attempt, and past MAX_ATTEMPTS the entry stops being retried so
// one poisoned write cannot spin the queue forever.
use std::collections::{HashMap, HashSet};
use std::time::Instant;
use rusqlite::Connection;
use serde_json::Value;
use crate::dto::{EventDraft, Instance, Scope};
use crate::google::api::ApiError;
use crate::recur::EditPlan;
use crate::store::read;
use crate::store::write::{self, EventRow, OutboxRow, Tx};
use crate::store::Store;
use super::model;
use super::transport::Transport;
use super::{with_conn, Outcome};
/// Enough attempts to ride out a transient rejection, few enough that a write Google will never
/// accept stops being sent.
pub const MAX_ATTEMPTS: i64 = 5;
const BATCH: u32 = 20;
// -- local writes ------------------------------------------------------------------------------
pub fn create(conn: &Connection, draft: &EventDraft) -> Result<Instance, String> {
let calendar = read::calendars(conn)?
.into_iter()
.find(|calendar| calendar.id == draft.calendar_id)
.ok_or_else(|| format!("no such calendar: {}", draft.calendar_id))?;
let zone = (!calendar.time_zone.is_empty()).then(|| calendar.time_zone.clone());
let id = model::new_event_id();
let mut row = model::draft_row(draft, &id, &calendar.account_id);
row.start_tz = zone.clone();
row.end_tz = zone.clone();
let tx = Tx::begin(conn)?;
write::upsert_event_dirty(conn, &row)?;
queue(
conn,
"create",
&draft.calendar_id,
Some(&id),
None,
None,
Some(model::draft_body(draft, &id, zone.as_deref(), zone.as_deref())),
None,
)?;
tx.commit()?;
Ok(model::pending_instance(
&row,
&calendar.color_hex,
!model::writable(&calendar.access_role),
))
}
/// Performs the plans `recur` handed back: the optimistic local write, then the queued request.
pub fn apply_plans(conn: &Connection, rows: &[EventRow], plans: &[EditPlan]) -> Result<(), String> {
let tx = Tx::begin(conn)?;
for plan in plans {
match plan {
EditPlan::PatchInstance {
calendar_id,
event_id,
original_start,
patch,
} => {
// Only an occurrence that is already an exception has a row to write to. The rest
// exist solely as a rule until Google materialises them, which it does at push
// time, so the local half of the write waits for the next sync.
let existing = exception(rows, calendar_id, event_id, original_start.as_deref());
if let Some(row) = existing {
let mut updated = row.clone();
model::apply_patch(&mut updated, patch);
write::upsert_event_dirty(conn, &updated)?;
}
let master = read::event(conn, calendar_id, event_id)?;
let shape = existing.or(master.as_ref());
queue(
conn,
"patch",
calendar_id,
Some(event_id),
original_start.as_deref(),
Some(Scope::This),
Some(body_for(patch, shape)),
existing.and_then(|row| row.etag.as_deref()),
)?;
}
EditPlan::PatchMaster {
calendar_id,
event_id,
patch,
} => {
let existing = read::event(conn, calendar_id, event_id)?;
if let Some(row) = &existing {
let mut updated = row.clone();
model::apply_patch(&mut updated, patch);
write::upsert_event_dirty(conn, &updated)?;
}
queue(
conn,
"patch",
calendar_id,
Some(event_id),
None,
Some(Scope::All),
Some(body_for(patch, existing.as_ref())),
existing.as_ref().and_then(|row| row.etag.as_deref()),
)?;
}
EditPlan::Split {
calendar_id,
event_id,
until,
draft,
} => {
let master = truncate(conn, calendar_id, event_id, until, Scope::Following)?;
let calendar = calendar_of(conn, &draft.calendar_id);
let zone = calendar
.as_ref()
.map(|calendar| calendar.time_zone.clone())
.filter(|zone| !zone.is_empty());
let id = model::new_event_id();
let account_id = master
.as_ref()
.map(|row| row.account_id.clone())
.or_else(|| calendar.map(|calendar| calendar.account_id))
.unwrap_or_default();
let mut row = model::draft_row(draft, &id, &account_id);
// The tail inherits the head's zones. `EventDraft` carries an offset and no zone,
// and an offset alone lets Google file the new series under the calendar's zone,
// which puts the two halves an hour apart at the next DST transition.
row.start_tz = master
.as_ref()
.and_then(|master| master.start_tz.clone())
.or_else(|| zone.clone());
row.end_tz = master
.as_ref()
.and_then(|master| master.end_tz.clone())
.or(zone);
write::upsert_event_dirty(conn, &row)?;
queue(
conn,
"create",
&draft.calendar_id,
Some(&id),
None,
Some(Scope::Following),
Some(model::draft_body(
draft,
&id,
row.start_tz.as_deref(),
row.end_tz.as_deref(),
)),
None,
)?;
}
EditPlan::CancelInstance {
calendar_id,
event_id,
original_start,
} => {
let existing = exception(rows, calendar_id, event_id, original_start.as_deref());
if let Some(row) = existing {
let mut updated = row.clone();
updated.status = "cancelled".to_string();
write::upsert_event_dirty(conn, &updated)?;
}
queue(
conn,
"delete",
calendar_id,
Some(event_id),
original_start.as_deref(),
Some(Scope::This),
None,
existing.and_then(|row| row.etag.as_deref()),
)?;
}
EditPlan::TruncateMaster {
calendar_id,
event_id,
until,
} => {
truncate(conn, calendar_id, event_id, until, Scope::Following)?;
}
EditPlan::DeleteMaster {
calendar_id,
event_id,
} => {
let existing = read::event(conn, calendar_id, event_id)?;
write::delete_event(conn, calendar_id, event_id)?;
queue(
conn,
"delete",
calendar_id,
Some(event_id),
None,
Some(Scope::All),
None,
existing.as_ref().and_then(|row| row.etag.as_deref()),
)?;
}
}
}
tx.commit()
}
/// Rewrites the master's rule to stop at `until` and queues that patch. Returns the row it found,
/// which is where a split gets the account for the new master.
fn truncate(
conn: &Connection,
calendar_id: &str,
event_id: &str,
until: &str,
scope: Scope,
) -> Result<Option<EventRow>, String> {
let existing = read::event(conn, calendar_id, event_id)?;
let recurrence = existing
.as_ref()
.map(|row| crate::recur::truncate_recurrence(&row.recurrence, until))
.unwrap_or_default();
if let Some(row) = &existing {
let mut updated = row.clone();
updated.recurrence = recurrence.clone();
write::upsert_event_dirty(conn, &updated)?;
}
queue(
conn,
"patch",
calendar_id,
Some(event_id),
None,
Some(scope),
Some(model::recurrence_body(&recurrence)),
existing.as_ref().and_then(|row| row.etag.as_deref()),
)?;
Ok(existing)
}
fn exception<'a>(
rows: &'a [EventRow],
calendar_id: &str,
event_id: &str,
original_start: Option<&str>,
) -> Option<&'a EventRow> {
rows.iter().find(|row| {
row.calendar_id == calendar_id
&& row.recurring_event_id.as_deref() == Some(event_id)
&& row.original_start.as_deref() == original_start
})
}
fn calendar_of(conn: &Connection, calendar_id: &str) -> Option<crate::dto::Calendar> {
read::calendars(conn)
.ok()?
.into_iter()
.find(|calendar| calendar.id == calendar_id)
}
/// A patch keeps the shape of the row it is patching: its all-day flag and its zones, neither of
/// which the patch itself is obliged to restate.
fn body_for(patch: &crate::dto::EventPatch, row: Option<&EventRow>) -> Value {
model::patch_body(
patch,
row.map(|row| row.all_day).unwrap_or(false),
row.and_then(|row| row.start_tz.as_deref()),
row.and_then(|row| row.end_tz.as_deref()),
)
}
fn scope_name(scope: Scope) -> &'static str {
match scope {
Scope::This => "this",
Scope::Following => "following",
Scope::All => "all",
}
}
#[allow(clippy::too_many_arguments)]
fn queue(
conn: &Connection,
op: &str,
calendar_id: &str,
event_id: Option<&str>,
original_start: Option<&str>,
scope: Option<Scope>,
payload: Option<Value>,
etag: Option<&str>,
) -> Result<(), String> {
write::enqueue(
conn,
&OutboxRow {
op: op.to_string(),
calendar_id: calendar_id.to_string(),
event_id: event_id.map(str::to_string),
original_start: original_start.map(str::to_string),
scope: scope.map(|scope| scope_name(scope).to_string()),
payload: payload.map(|value| value.to_string()),
etag: etag.map(str::to_string),
..Default::default()
},
)?;
Ok(())
}
// -- the drain ---------------------------------------------------------------------------------
pub async fn drain(store: &Store, transport: &impl Transport, deadline: Instant) -> Outcome {
let mut outcome = Outcome::default();
let accounts = match with_conn(store, calendar_accounts) {
Ok(accounts) => accounts,
Err(error) => {
outcome.error = Some(error);
return outcome;
}
};
// One attempt per entry per drain. Without this a queue that is making progress would burn a
// failing entry's whole attempt budget in a single pass.
let mut attempted: HashSet<i64> = HashSet::new();
while Instant::now() < deadline {
let queued = match with_conn(store, |conn| write::peek_outbox(conn, BATCH)) {
Ok(queued) => queued,
Err(error) => {
outcome.error = Some(error);
return outcome;
}
};
let pending: Vec<OutboxRow> = queued
.into_iter()
.filter(|row| row.attempts < MAX_ATTEMPTS && !attempted.contains(&row.id))
.collect();
if pending.is_empty() {
break;
}
for row in pending {
if Instant::now() >= deadline {
return outcome;
}
attempted.insert(row.id);
let Some(account_id) = accounts.get(&row.calendar_id) else {
// The calendar is gone, so the write has nowhere to land and nothing to retry
// against.
let _ = with_conn(store, |conn| write::dequeue(conn, row.id));
continue;
};
match push_one(store, transport, account_id, &row).await {
Ok(()) => {
let _ = with_conn(store, |conn| write::dequeue(conn, row.id));
outcome.changed = true;
}
Err(ApiError::Offline(_)) => {
// Not a failure, so no attempt is counted and nothing is surfaced. The queue is
// the reason this app works on a plane.
outcome.offline = true;
return outcome;
}
Err(error) => {
let message = error.to_string();
let _ =
with_conn(store, |conn| write::mark_attempt_failed(conn, row.id, &message));
outcome.error.get_or_insert(message);
}
}
}
}
outcome
}
async fn push_one(
store: &Store,
transport: &impl Transport,
account_id: &str,
row: &OutboxRow,
) -> Result<(), ApiError> {
match row.op.as_str() {
"create" => {
let body = payload(row)?;
match transport
.events_insert(account_id, &row.calendar_id, &body)
.await
{
Ok(raw) => land(store, &row.calendar_id, account_id, &raw),
// The id was chosen locally, so a retry of a create whose response never arrived
// collides with itself. The event is there, which is what was wanted.
Err(ApiError::Other(message)) if message.contains("(409)") => clear(store, row),
Err(error) => Err(error),
}
}
"patch" => {
let body = payload(row)?;
let (event_id, etag) = resolve(transport, account_id, row).await?;
let raw = transport
.events_patch(
account_id,
&row.calendar_id,
&event_id,
&body,
etag.as_deref(),
)
.await?;
land(store, &row.calendar_id, account_id, &raw)?;
clear(store, row)
}
"delete" => {
let (event_id, etag) = resolve(transport, account_id, row).await?;
transport
.events_delete(account_id, &row.calendar_id, &event_id, etag.as_deref())
.await?;
with_conn(store, |conn| {
write::delete_event(conn, &row.calendar_id, &event_id)
})
.map_err(ApiError::Other)?;
clear(store, row)
}
other => Err(ApiError::Other(format!("unknown queued write: {other}"))),
}
}
/// `EditPlan::PatchInstance` carries the original start rather than an instance id because the id
/// is Google's to give. This is where it is asked for, at push time, rather than assembled by hand.
async fn resolve(
transport: &impl Transport,
account_id: &str,
row: &OutboxRow,
) -> Result<(String, Option<String>), ApiError> {
let event_id = row
.event_id
.clone()
.ok_or_else(|| ApiError::Other("a queued write with no event".to_string()))?;
let Some(original_start) = row.original_start.as_deref() else {
return Ok((event_id, row.etag.clone()));
};
let instances = transport
.events_instances(account_id, &row.calendar_id, &event_id, original_start)
.await?;
let instance = instances.into_iter().next().ok_or_else(|| {
ApiError::Other(format!(
"that occurrence of {event_id} is no longer in the series"
))
})?;
let etag = row.etag.clone().or_else(|| instance.etag.clone());
Ok((instance.id, etag))
}
fn payload(row: &OutboxRow) -> Result<Value, ApiError> {
let raw = row
.payload
.as_deref()
.ok_or_else(|| ApiError::Other(format!("the queued {} carries no body", row.op)))?;
serde_json::from_str(raw).map_err(|e| ApiError::Other(e.to_string()))
}
/// The response is the truth, so it replaces the optimistic row rather than merely clearing its
/// dirty flag: Google fills in the etag, the id normalisation and anything it rewrote.
fn land(
store: &Store,
calendar_id: &str,
account_id: &str,
raw: &crate::google::api::RawEvent,
) -> Result<(), ApiError> {
let row = model::event_row(raw, calendar_id, account_id);
with_conn(store, |conn| write::upsert_event(conn, &row)).map_err(ApiError::Other)
}
fn clear(store: &Store, row: &OutboxRow) -> Result<(), ApiError> {
let Some(event_id) = row.event_id.as_deref() else {
return Ok(());
};
with_conn(store, |conn| {
write::clear_dirty(conn, &row.calendar_id, event_id)
})
.map_err(ApiError::Other)
}
fn calendar_accounts(conn: &Connection) -> Result<HashMap<String, String>, String> {
Ok(read::calendars(conn)?
.into_iter()
.map(|calendar| (calendar.id, calendar.account_id))
.collect())
}
+802
View File
@@ -0,0 +1,802 @@
// The state machine against a stubbed transport and an in-memory database. Nothing here touches
// Google, and nothing here can: the engine only ever speaks through `Transport`.
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use rusqlite::Connection;
use tauri::async_runtime::block_on;
use crate::dto::SyncStatus;
use crate::google::api::{ApiError, CalendarListPage, EventDateTime, EventsPage, RawEvent};
use crate::store::write::{self, CalendarRow, OutboxRow};
use crate::store::{read, schema, Store};
use super::transport::Transport;
use super::{pull, push, with_conn, Sink};
// -- the stub ----------------------------------------------------------------------------------
#[derive(Default)]
struct Stub {
events: Mutex<HashMap<String, VecDeque<Result<EventsPage, ApiError>>>>,
calendars: Mutex<VecDeque<Result<CalendarListPage, ApiError>>>,
writes: Mutex<VecDeque<Result<RawEvent, ApiError>>>,
deletes: Mutex<VecDeque<Result<(), ApiError>>>,
instances: Mutex<VecDeque<Result<Vec<RawEvent>, ApiError>>>,
calls: Mutex<Vec<String>>,
}
impl Stub {
fn record(&self, call: String) {
self.calls.lock().expect("calls").push(call);
}
fn calls(&self) -> Vec<String> {
self.calls.lock().expect("calls").clone()
}
fn script_events(&self, calendar_id: &str, replies: Vec<Result<EventsPage, ApiError>>) {
self.events
.lock()
.expect("events")
.insert(calendar_id.to_string(), replies.into());
}
fn script_calendars(&self, replies: Vec<Result<CalendarListPage, ApiError>>) {
*self.calendars.lock().expect("calendars") = replies.into();
}
fn script_writes(&self, replies: Vec<Result<RawEvent, ApiError>>) {
*self.writes.lock().expect("writes") = replies.into();
}
}
fn shown(value: Option<&str>) -> &str {
value.unwrap_or("-")
}
impl Transport for Stub {
fn calendar_list(
&self,
account_id: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> impl std::future::Future<Output = Result<CalendarListPage, ApiError>> + Send {
async move {
self.record(format!(
"calendar_list {account_id} sync={} page={}",
shown(sync_token),
shown(page_token)
));
self.calendars
.lock()
.expect("calendars")
.pop_front()
.unwrap_or_else(|| Err(ApiError::Other("unscripted calendar_list".to_string())))
}
}
fn events_list(
&self,
_account_id: &str,
calendar_id: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> impl std::future::Future<Output = Result<EventsPage, ApiError>> + Send {
async move {
self.record(format!(
"events_list {calendar_id} sync={} page={}",
shown(sync_token),
shown(page_token)
));
self.events
.lock()
.expect("events")
.get_mut(calendar_id)
.and_then(|replies| replies.pop_front())
.unwrap_or_else(|| Err(ApiError::Other("unscripted events_list".to_string())))
}
}
fn events_insert(
&self,
_account_id: &str,
calendar_id: &str,
body: &serde_json::Value,
) -> impl std::future::Future<Output = Result<RawEvent, ApiError>> + Send {
async move {
let id = body.get("id").and_then(|v| v.as_str()).unwrap_or("-");
self.record(format!("events_insert {calendar_id} {id}"));
self.writes
.lock()
.expect("writes")
.pop_front()
.unwrap_or_else(|| Err(ApiError::Other("unscripted events_insert".to_string())))
}
}
fn events_patch(
&self,
_account_id: &str,
calendar_id: &str,
event_id: &str,
_body: &serde_json::Value,
etag: Option<&str>,
) -> impl std::future::Future<Output = Result<RawEvent, ApiError>> + Send {
async move {
self.record(format!(
"events_patch {calendar_id} {event_id} etag={}",
shown(etag)
));
self.writes
.lock()
.expect("writes")
.pop_front()
.unwrap_or_else(|| Err(ApiError::Other("unscripted events_patch".to_string())))
}
}
fn events_delete(
&self,
_account_id: &str,
calendar_id: &str,
event_id: &str,
etag: Option<&str>,
) -> impl std::future::Future<Output = Result<(), ApiError>> + Send {
async move {
self.record(format!(
"events_delete {calendar_id} {event_id} etag={}",
shown(etag)
));
self.deletes
.lock()
.expect("deletes")
.pop_front()
.unwrap_or(Ok(()))
}
}
fn events_instances(
&self,
_account_id: &str,
calendar_id: &str,
event_id: &str,
original_start: &str,
) -> impl std::future::Future<Output = Result<Vec<RawEvent>, ApiError>> + Send {
async move {
self.record(format!(
"events_instances {calendar_id} {event_id} at={original_start}"
));
self.instances
.lock()
.expect("instances")
.pop_front()
.unwrap_or_else(|| Ok(Vec::new()))
}
}
}
#[derive(Default)]
struct Recorder {
messages: Mutex<Vec<String>>,
reasons: Mutex<Vec<String>>,
statuses: Mutex<Vec<SyncStatus>>,
}
impl Sink for Recorder {
fn message(&self, text: &str) {
self.messages.lock().expect("messages").push(text.to_string());
}
fn status(&self, status: &SyncStatus) {
self.statuses.lock().expect("statuses").push(status.clone());
}
fn changed(&self, reason: &str) {
self.reasons.lock().expect("reasons").push(reason.to_string());
}
}
// -- fixtures ----------------------------------------------------------------------------------
fn db() -> Store {
let conn = Connection::open_in_memory().expect("in-memory database");
schema::migrate(&conn).expect("migrate");
Store {
conn: Mutex::new(conn),
}
}
/// The calendarList cursor is seeded so a pull runs incremental. A full list is the whole truth and
/// would prune calendars a test set up by hand.
fn account(store: &Store, id: &str) {
with_conn(store, |conn| {
write::upsert_account(conn, id, &format!("{id}@example.test"), Some("keychain"))?;
write::meta_set(
conn,
&write::account_meta_key(id, "calendar-list-token"),
"cl-0",
)
})
.expect("account");
}
fn calendar(store: &Store, id: &str, account_id: &str, sync_token: Option<&str>) {
with_conn(store, |conn| {
write::upsert_calendar(
conn,
&CalendarRow {
id: id.to_string(),
account_id: account_id.to_string(),
summary: id.to_string(),
color_hex: "#4285f4".to_string(),
access_role: "owner".to_string(),
time_zone: "Europe/London".to_string(),
selected: true,
..Default::default()
},
)?;
write::set_calendar_sync_token(conn, id, sync_token)
})
.expect("calendar");
}
fn stored_event(store: &Store, id: &str, calendar_id: &str) {
with_conn(store, |conn| {
write::upsert_event(
conn,
&crate::store::write::EventRow {
id: id.to_string(),
calendar_id: calendar_id.to_string(),
account_id: "acct".to_string(),
status: "confirmed".to_string(),
summary: id.to_string(),
start_at: "2026-08-10T09:00:00Z".to_string(),
end_at: "2026-08-10T10:00:00Z".to_string(),
etag: Some(format!("etag-{id}")),
..Default::default()
},
)
})
.expect("event");
}
fn when(value: &str) -> EventDateTime {
EventDateTime {
date_time: Some(value.to_string()),
..Default::default()
}
}
fn raw(id: &str) -> RawEvent {
RawEvent {
id: id.to_string(),
etag: Some(format!("etag-{id}")),
status: Some("confirmed".to_string()),
summary: Some(id.to_string()),
start: Some(when("2026-08-10T09:00:00Z")),
end: Some(when("2026-08-10T10:00:00Z")),
..Default::default()
}
}
fn page(items: Vec<RawEvent>, next_page: Option<&str>, next_sync: Option<&str>) -> EventsPage {
EventsPage {
items,
next_page_token: next_page.map(str::to_string),
next_sync_token: next_sync.map(str::to_string),
}
}
fn calendar_page(next_sync: &str) -> CalendarListPage {
CalendarListPage {
items: Vec::new(),
next_page_token: None,
next_sync_token: Some(next_sync.to_string()),
}
}
fn token_of(store: &Store, calendar_id: &str) -> Option<String> {
with_conn(store, |conn| write::calendar_sync_token(conn, calendar_id)).expect("token")
}
fn has_event(store: &Store, calendar_id: &str, event_id: &str) -> bool {
with_conn(store, |conn| read::event(conn, calendar_id, event_id))
.expect("read")
.is_some()
}
fn queued(store: &Store) -> Vec<OutboxRow> {
with_conn(store, |conn| write::peek_outbox(conn, 50)).expect("peek")
}
fn enqueue(store: &Store, op: &str, event_id: &str, payload: Option<&str>, etag: Option<&str>) {
with_conn(store, |conn| {
write::enqueue(
conn,
&OutboxRow {
op: op.to_string(),
calendar_id: "cal-a".to_string(),
event_id: Some(event_id.to_string()),
payload: payload.map(str::to_string),
etag: etag.map(str::to_string),
..Default::default()
},
)
})
.expect("enqueue");
}
fn drain(store: &Store, stub: &Stub) -> super::Outcome {
block_on(push::drain(
store,
stub,
Instant::now() + Duration::from_secs(30),
))
}
// -- the pull ----------------------------------------------------------------------------------
#[test]
fn pagination_walks_to_exhaustion_and_commits_the_token_from_the_final_page() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
let stub = Stub::default();
stub.script_calendars(vec![Ok(calendar_page("cl-1"))]);
stub.script_events(
"cal-a",
vec![
Ok(page(vec![raw("one")], Some("page-2"), None)),
Ok(page(vec![raw("two")], Some("page-3"), None)),
Ok(page(vec![raw("three")], None, Some("final"))),
],
);
let outcome = block_on(pull::sync_all(&store, &stub, &Recorder::default()));
assert!(outcome.error.is_none(), "{:?}", outcome.error);
assert!(has_event(&store, "cal-a", "one"));
assert!(has_event(&store, "cal-a", "three"));
assert_eq!(token_of(&store, "cal-a"), Some("final".to_string()));
assert_eq!(
stub.calls(),
vec![
"calendar_list acct sync=cl-0 page=-",
"events_list cal-a sync=start page=-",
"events_list cal-a sync=start page=page-2",
"events_list cal-a sync=start page=page-3",
]
);
}
#[test]
fn a_token_from_the_middle_of_a_chain_is_never_stored() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
let stub = Stub::default();
stub.script_calendars(vec![Ok(calendar_page("cl-1"))]);
stub.script_events(
"cal-a",
vec![
// A token on a page that is not the last one is not the end of the chain, whatever it
// says, and committing it would leave the cursor pointing at the middle of a page.
Ok(page(vec![raw("one")], Some("page-2"), Some("middle"))),
Err(ApiError::Other("Google events list failed (503)".to_string())),
],
);
let outcome = block_on(pull::sync_all(&store, &stub, &Recorder::default()));
assert!(outcome.error.is_some());
assert_eq!(token_of(&store, "cal-a"), Some("start".to_string()));
assert!(!has_event(&store, "cal-a", "one"), "a partial chain is not a commit");
}
#[test]
fn a_dead_token_clears_that_calendar_alone() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("dead"));
calendar(&store, "cal-b", "acct", Some("live"));
stored_event(&store, "stale-a", "cal-a");
stored_event(&store, "kept-b", "cal-b");
let stub = Stub::default();
stub.script_calendars(vec![Ok(calendar_page("cl-1"))]);
stub.script_events(
"cal-a",
vec![
Err(ApiError::SyncTokenExpired),
Ok(page(vec![raw("fresh-a")], None, Some("a-2"))),
],
);
stub.script_events("cal-b", vec![Ok(page(Vec::new(), None, Some("b-2")))]);
let outcome = block_on(pull::sync_all(&store, &stub, &Recorder::default()));
assert!(outcome.error.is_none(), "{:?}", outcome.error);
assert!(!has_event(&store, "cal-a", "stale-a"), "the dead calendar is dropped");
assert!(has_event(&store, "cal-a", "fresh-a"));
assert_eq!(token_of(&store, "cal-a"), Some("a-2".to_string()));
assert!(has_event(&store, "cal-b", "kept-b"), "the healthy calendar is untouched");
assert_eq!(token_of(&store, "cal-b"), Some("b-2".to_string()));
assert_eq!(
stub.calls()
.into_iter()
.filter(|call| call.starts_with("events_list"))
.collect::<Vec<_>>(),
vec![
"events_list cal-a sync=dead page=-",
"events_list cal-a sync=- page=-",
"events_list cal-b sync=live page=-",
]
);
}
#[test]
fn a_cancelled_single_is_dropped_while_a_cancelled_occurrence_is_kept() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
stored_event(&store, "single", "cal-a");
let mut cancelled = raw("single");
cancelled.status = Some("cancelled".to_string());
// Everything a cancelled occurrence is guaranteed to carry, and nothing else.
let occurrence = RawEvent {
id: "series_20260817T090000Z".to_string(),
status: Some("cancelled".to_string()),
recurring_event_id: Some("series".to_string()),
original_start_time: Some(when("2026-08-17T09:00:00Z")),
..Default::default()
};
let stub = Stub::default();
stub.script_calendars(vec![Ok(calendar_page("cl-1"))]);
stub.script_events(
"cal-a",
vec![Ok(page(vec![cancelled, occurrence], None, Some("next")))],
);
block_on(pull::sync_all(&store, &stub, &Recorder::default()));
assert!(!has_event(&store, "cal-a", "single"));
assert!(has_event(&store, "cal-a", "series_20260817T090000Z"));
}
// -- the outbox --------------------------------------------------------------------------------
#[test]
fn the_outbox_drains_in_the_order_it_was_filled() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
stored_event(&store, "two", "cal-a");
stored_event(&store, "three", "cal-a");
enqueue(&store, "create", "one", Some(r#"{"id":"one"}"#), None);
enqueue(&store, "patch", "two", Some(r#"{"summary":"moved"}"#), Some("etag-two"));
enqueue(&store, "delete", "three", None, Some("etag-three"));
let stub = Stub::default();
stub.script_writes(vec![Ok(raw("one")), Ok(raw("two"))]);
let outcome = drain(&store, &stub);
assert!(outcome.error.is_none(), "{:?}", outcome.error);
assert!(outcome.changed);
assert_eq!(
stub.calls(),
vec![
"events_insert cal-a one",
"events_patch cal-a two etag=etag-two",
"events_delete cal-a three etag=etag-three",
]
);
assert!(queued(&store).is_empty(), "everything landed");
assert!(has_event(&store, "cal-a", "one"), "the server's row replaces the local one");
assert!(!has_event(&store, "cal-a", "three"), "a delete that landed is gone locally");
let row = with_conn(&store, |conn| read::event(conn, "cal-a", "one"))
.expect("read")
.expect("row");
assert!(!row.dirty, "a landed write is no longer pending");
}
#[test]
fn a_lost_race_keeps_the_entry_and_surfaces_rather_than_clobbering() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
stored_event(&store, "two", "cal-a");
enqueue(&store, "patch", "two", Some(r#"{"summary":"mine"}"#), Some("etag-two"));
let stub = Stub::default();
stub.script_writes(vec![Err(ApiError::PreconditionFailed)]);
let outcome = drain(&store, &stub);
assert!(outcome.error.is_some(), "a 412 is surfaced");
let entries = queued(&store);
assert_eq!(entries.len(), 1, "the entry is kept");
assert_eq!(entries[0].attempts, 1);
assert_eq!(entries[0].etag.as_deref(), Some("etag-two"));
assert!(entries[0]
.last_error
.as_deref()
.expect("an error")
.contains("changed elsewhere"));
// One attempt, and never a second one with the etag quietly dropped.
assert_eq!(stub.calls(), vec!["events_patch cal-a two etag=etag-two"]);
}
#[test]
fn offline_keeps_the_entry_queued_without_recording_a_failure() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
stored_event(&store, "two", "cal-a");
enqueue(&store, "patch", "two", Some(r#"{"summary":"mine"}"#), Some("etag-two"));
let stub = Stub::default();
stub.script_writes(vec![Err(ApiError::Offline("no route to host".to_string()))]);
let outcome = drain(&store, &stub);
assert!(outcome.offline);
assert!(outcome.error.is_none(), "being on a plane is not a failure");
let entries = queued(&store);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].attempts, 0, "no attempt is spent on being offline");
assert_eq!(entries[0].last_error, None);
}
#[test]
fn a_write_google_keeps_rejecting_eventually_stops_being_retried() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
stored_event(&store, "two", "cal-a");
enqueue(&store, "patch", "two", Some(r#"{"summary":"mine"}"#), Some("etag-two"));
let stub = Stub::default();
stub.script_writes(
(0..20)
.map(|_| Err(ApiError::Other("Google event update failed (400)".to_string())))
.collect(),
);
for _ in 0..10 {
drain(&store, &stub);
}
assert_eq!(
stub.calls().len() as i64,
push::MAX_ATTEMPTS,
"the queue stops rather than spinning"
);
let entries = queued(&store);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].attempts, push::MAX_ATTEMPTS);
}
#[test]
fn one_entry_is_attempted_at_most_once_per_drain() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
stored_event(&store, "two", "cal-a");
stored_event(&store, "three", "cal-a");
enqueue(&store, "patch", "two", Some(r#"{"summary":"mine"}"#), Some("etag-two"));
enqueue(&store, "delete", "three", None, Some("etag-three"));
let stub = Stub::default();
stub.script_writes(vec![Err(ApiError::Other("Google event update failed (400)".to_string()))]);
drain(&store, &stub);
assert_eq!(
stub.calls(),
vec![
"events_patch cal-a two etag=etag-two",
"events_delete cal-a three etag=etag-three",
],
"a failure does not block the entries behind it, and is not retried in the same pass"
);
assert_eq!(queued(&store).len(), 1);
}
#[test]
fn a_scoped_edit_resolves_the_occurrence_at_push_time() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
with_conn(&store, |conn| {
write::enqueue(
conn,
&OutboxRow {
op: "patch".to_string(),
calendar_id: "cal-a".to_string(),
event_id: Some("series".to_string()),
original_start: Some("2026-08-17T09:00:00Z".to_string()),
scope: Some("this".to_string()),
payload: Some(r#"{"summary":"just this one"}"#.to_string()),
..Default::default()
},
)
})
.expect("enqueue");
let stub = Stub::default();
*stub.instances.lock().expect("instances") =
vec![Ok(vec![raw("series_20260817T090000Z")])].into();
stub.script_writes(vec![Ok(raw("series_20260817T090000Z"))]);
let outcome = drain(&store, &stub);
assert!(outcome.error.is_none(), "{:?}", outcome.error);
assert_eq!(
stub.calls(),
vec![
"events_instances cal-a series at=2026-08-17T09:00:00Z",
// The instance id came from Google rather than being assembled from the original start.
"events_patch cal-a series_20260817T090000Z etag=etag-series_20260817T090000Z",
]
);
assert!(queued(&store).is_empty());
}
// -- the local write ---------------------------------------------------------------------------
#[test]
fn a_local_create_renders_before_google_has_seen_it() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
let draft = crate::dto::EventDraft {
color_id: None,
calendar_id: "cal-a".to_string(),
summary: "Lunch".to_string(),
description: None,
location: None,
start: "2026-08-11T12:00:00Z".to_string(),
end: "2026-08-11T13:00:00Z".to_string(),
all_day: false,
recurrence: Vec::new(),
};
let instance = with_conn(&store, |conn| push::create(conn, &draft)).expect("create");
assert!(instance.pending);
assert_eq!(instance.color_hex, "#4285f4");
let row = with_conn(&store, |conn| read::event(conn, "cal-a", &instance.event_id))
.expect("read")
.expect("row");
assert!(row.dirty);
let entries = queued(&store);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].op, "create");
let body: serde_json::Value =
serde_json::from_str(entries[0].payload.as_deref().expect("a body")).expect("json");
assert_eq!(body["id"], instance.event_id);
assert_eq!(body["start"]["dateTime"], "2026-08-11T12:00:00Z");
}
/// The whole write path for a scoped edit: the rows the command gathers, the plan `recur` makes of
/// them, the queue entry, and the request that entry becomes.
#[test]
fn a_this_occurrence_edit_goes_from_plan_to_queue_to_request() {
let store = db();
account(&store, "acct");
calendar(&store, "cal-a", "acct", Some("start"));
with_conn(&store, |conn| {
write::upsert_event(
conn,
&crate::store::write::EventRow {
id: "series".to_string(),
calendar_id: "cal-a".to_string(),
account_id: "acct".to_string(),
status: "confirmed".to_string(),
summary: "Standup".to_string(),
start_at: "2026-08-03T09:00:00+01:00".to_string(),
start_tz: Some("Europe/London".to_string()),
end_at: "2026-08-03T09:15:00+01:00".to_string(),
end_tz: Some("Europe/London".to_string()),
recurrence: vec!["RRULE:FREQ=WEEKLY;BYDAY=MO".to_string()],
etag: Some("etag-series".to_string()),
..Default::default()
},
)
})
.expect("master");
let key = crate::dto::InstanceKey {
event_id: "series".to_string(),
original_start: Some("2026-08-17T09:00:00+01:00".to_string()),
};
let patch = crate::dto::EventPatch {
summary: Some("Standup, moved".to_string()),
..Default::default()
};
let rows = with_conn(&store, |conn| super::series_rows(conn, &key)).expect("rows");
let plans = crate::recur::plan_edit(&rows, &key, &patch, crate::dto::Scope::This).expect("plan");
with_conn(&store, |conn| push::apply_plans(conn, &rows, &plans)).expect("apply");
let entries = queued(&store);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].op, "patch");
assert_eq!(entries[0].scope.as_deref(), Some("this"));
assert_eq!(
entries[0].original_start.as_deref(),
Some("2026-08-17T09:00:00+01:00"),
"the occurrence is named by its original start, not by a hand-built instance id"
);
let stub = Stub::default();
*stub.instances.lock().expect("instances") = vec![Ok(vec![raw("series_20260817T080000Z")])].into();
stub.script_writes(vec![Ok(raw("series_20260817T080000Z"))]);
let outcome = drain(&store, &stub);
assert!(outcome.error.is_none(), "{:?}", outcome.error);
assert_eq!(
stub.calls(),
vec![
"events_instances cal-a series at=2026-08-17T09:00:00+01:00",
"events_patch cal-a series_20260817T080000Z etag=etag-series_20260817T080000Z",
]
);
assert!(queued(&store).is_empty());
}
#[test]
fn an_all_day_draft_never_becomes_a_timestamp() {
let draft = crate::dto::EventDraft {
color_id: None,
calendar_id: "cal-a".to_string(),
summary: "Holiday".to_string(),
description: None,
location: None,
start: "2026-08-11".to_string(),
end: "2026-08-12".to_string(),
all_day: true,
recurrence: Vec::new(),
};
let body = super::model::draft_body(&draft, "abcde", Some("Europe/London"), Some("Europe/London"));
assert_eq!(body["start"]["date"], "2026-08-11");
assert!(body["start"].get("dateTime").is_none());
assert!(
body["start"].get("timeZone").is_none(),
"an all-day date has no zone to be wrong about"
);
}
#[test]
fn a_timed_write_carries_the_zone_as_well_as_the_offset() {
let draft = crate::dto::EventDraft {
color_id: None,
calendar_id: "cal-a".to_string(),
summary: "Standup".to_string(),
description: None,
location: None,
start: "2026-08-11T09:00:00+01:00".to_string(),
end: "2026-08-11T09:15:00+01:00".to_string(),
all_day: false,
recurrence: vec!["RRULE:FREQ=DAILY".to_string()],
};
// An offset pins the instant but not the zone, and a series filed under the calendar's zone
// rather than its own drifts an hour at the next transition.
let body = super::model::draft_body(&draft, "abcde", Some("Europe/London"), Some("Europe/London"));
assert_eq!(body["start"]["dateTime"], "2026-08-11T09:00:00+01:00");
assert_eq!(body["start"]["timeZone"], "Europe/London");
}
+154
View File
@@ -0,0 +1,154 @@
// The seam between the sync state machine and Google. The engine never calls `api::*` directly, so
// pagination, 410 recovery and the outbox drain can be driven by a stub over an in-memory database
// in `cargo test`, which is the only way any of this is testable without credentials.
//
// Account resolution lives behind the trait as well: the engine names an account, the transport
// turns that into a bearer token. `auth::valid_access_token` is single-flight, so it is called per
// request rather than cached here.
use std::future::Future;
use tauri::Manager;
use crate::google::api::{self, ApiError, CalendarListPage, EventsPage, RawEvent};
pub trait Transport: Sync {
fn calendar_list(
&self,
account_id: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> impl Future<Output = Result<CalendarListPage, ApiError>> + Send;
fn events_list(
&self,
account_id: &str,
calendar_id: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> impl Future<Output = Result<EventsPage, ApiError>> + Send;
fn events_insert(
&self,
account_id: &str,
calendar_id: &str,
body: &serde_json::Value,
) -> impl Future<Output = Result<RawEvent, ApiError>> + Send;
fn events_patch(
&self,
account_id: &str,
calendar_id: &str,
event_id: &str,
body: &serde_json::Value,
etag: Option<&str>,
) -> impl Future<Output = Result<RawEvent, ApiError>> + Send;
fn events_delete(
&self,
account_id: &str,
calendar_id: &str,
event_id: &str,
etag: Option<&str>,
) -> impl Future<Output = Result<(), ApiError>> + Send;
fn events_instances(
&self,
account_id: &str,
calendar_id: &str,
event_id: &str,
original_start: &str,
) -> impl Future<Output = Result<Vec<RawEvent>, ApiError>> + Send;
}
pub struct Google {
pub app: tauri::AppHandle,
}
impl Google {
async fn token(&self, account_id: &str) -> Result<String, ApiError> {
let state = self.app.state::<crate::google::AuthState>();
crate::google::auth::valid_access_token(&self.app, &state, account_id)
.await
.map_err(ApiError::Other)
}
}
impl Transport for Google {
fn calendar_list(
&self,
account_id: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> impl Future<Output = Result<CalendarListPage, ApiError>> + Send {
async move {
let access = self.token(account_id).await?;
api::calendar_list(&access, sync_token, page_token).await
}
}
fn events_list(
&self,
account_id: &str,
calendar_id: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> impl Future<Output = Result<EventsPage, ApiError>> + Send {
async move {
let access = self.token(account_id).await?;
api::events_list(&access, calendar_id, sync_token, page_token).await
}
}
fn events_insert(
&self,
account_id: &str,
calendar_id: &str,
body: &serde_json::Value,
) -> impl Future<Output = Result<RawEvent, ApiError>> + Send {
async move {
let access = self.token(account_id).await?;
api::events_insert(&access, calendar_id, body).await
}
}
fn events_patch(
&self,
account_id: &str,
calendar_id: &str,
event_id: &str,
body: &serde_json::Value,
etag: Option<&str>,
) -> impl Future<Output = Result<RawEvent, ApiError>> + Send {
async move {
let access = self.token(account_id).await?;
api::events_patch(&access, calendar_id, event_id, body, etag).await
}
}
fn events_delete(
&self,
account_id: &str,
calendar_id: &str,
event_id: &str,
etag: Option<&str>,
) -> impl Future<Output = Result<(), ApiError>> + Send {
async move {
let access = self.token(account_id).await?;
api::events_delete(&access, calendar_id, event_id, etag).await
}
}
fn events_instances(
&self,
account_id: &str,
calendar_id: &str,
event_id: &str,
original_start: &str,
) -> impl Future<Output = Result<Vec<RawEvent>, ApiError>> + Send {
async move {
let access = self.token(account_id).await?;
api::events_instances(&access, calendar_id, event_id, original_start).await
}
}
}