commit 661100dfdcd34e7ff05f1b4c29d6a83361f5ec75 Author: PJ Date: Wed Aug 12 17:09:21 2026 +0530 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f86787 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Tauri build output +src-tauri/target/ +src-tauri/gen/schemas/ + +# Xcode build output. The project under src-tauri/gen/apple is generated but committed, the +# products of building it are not: Externals holds the static Rust library per architecture and +# runs to several hundred MB. +src-tauri/gen/apple/build/ +src-tauri/gen/apple/Externals/ +src-tauri/gen/apple/Pods/ +src-tauri/gen/apple/Podfile.lock +xcuserdata/ + +# Google OAuth desktop client (real values, never commit) +/google-credentials.json +/client_secret_*.json + +# Screenshots & Playwright MCP artifacts +.playwright-mcp/ +/*.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..115a696 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Margin Calendar + +A calendar for Google Calendar on macOS, Linux, Android and iOS. The grid owns the window, the day +fits without scrolling, and on a desktop the whole thing is drivable from the keyboard. + +It is a sibling to [margin](https://github.com/priyanshujain/margin) and shares its stack and its +visual language. + +Setup and the Google OAuth client it needs are in [docs/setup.md](docs/setup.md), and the extra +clients a phone build needs are in [docs/mobile.md](docs/mobile.md). The product decisions are in +[docs/design.md](docs/design.md), how it is built is in +[docs/architecture.md](docs/architecture.md), and the conventions it follows are in +[docs/conventions.md](docs/conventions.md). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0b7ae69 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,133 @@ +# Architecture + +Tauri 2, React 19, Vite, TypeScript and zustand on the front, Rust behind. Same stack as +margin, which means the OAuth flow, the token handling and the build and bundle setup carry +over rather than being invented again. + +The split is strict. Rust owns authentication, all HTTP to Google, the local store, the sync +loop, recurrence expansion and timezone arithmetic. TypeScript owns rendering and interaction +and nothing else. The frontend never talks to Google, which keeps the content security policy +locked to `ipc:` exactly as margin has it. + +## Authentication + +Lifted from `margin/src-tauri/src/gdrive.rs`, then split in two when mobile arrived. Both halves +build the same consent URL with PKCE S256 and a CSRF state parameter and open it in the system +browser through the opener plugin, never an in-app webview: Google blocks the embedded-webview +flow, and it deserves to be blocked, because a webview the app controls can read the password +typed into it. They differ only in how the answer comes back. + +Desktop binds a listener on `127.0.0.1:0` and catches the redirect on the loopback socket. The +verifier lives on that listener's stack for the two minutes it is alive. + +Mobile cannot do that. iOS will not keep a background listener alive dependably, and Google +rejects loopback redirects for Android and iOS client types outright, so the redirect is a custom +URI scheme the OS routes back to the app through `tauri-plugin-deep-link`. There is no listener to +hold the verifier, the browser is a separate app and this process may be backgrounded while the +user consents, so the verifier waits in `AuthState.pending` and `handle_redirect` picks it up +whenever the link lands. It is taken rather than read, so a replayed link cannot start a second +exchange. + +That split forces one more difference. A desktop client is confidential and has a secret; an +Android or iOS client is public and has none, so `google-credentials.json` carries up to three +clients and the build embeds the one for the platform it is compiling for. Details and the +console steps are in [mobile.md](mobile.md). + +The scope is `https://www.googleapis.com/auth/calendar` plus `openid email`. Calendar is a +sensitive scope, so an unverified client shows the unverified-app interstitial and caps at 100 +users. Irrelevant for personal use, relevant the moment this ships the way margin ships. + +Refresh tokens never go to disk in plaintext and never into SQLite. They are sealed with +XChaCha20-Poly1305 in the app data directory, the same way on all five platforms. + +There is no `keyring` here, and its absence is deliberate rather than an omission. It has no +Android backend at all; on macOS it ties an item to the code signature, so an ad-hoc signed build +gets a new identity on every compile and macOS re-asks for authorization every time; and on Linux +it is missing on exactly the minimal window managers that run no Secret Service daemon. That left +one real implementation behind four ways of reaching it. `src-tauri/src/google/secrets.rs` states +plainly what the file is worth on each platform, which is more on the mobile sandboxes than on a +desktop home directory. + +## Store + +SQLite through `rusqlite` with the bundled feature, so there is no system SQLite dependency on +either platform. + +Accounts hold an email and a reference naming the sealed token, never the token itself. The +column is still called `keychain_ref` from when there was a keychain; renaming it would cost a +migration and buy nothing. Calendars hold their account, summary, +colour, selection state, access role, default timezone and their own sync token. Events hold +the raw Google shape flattened: identity and etag, summary, description, location, start and +end with their separate timezones, an all-day flag, the recurrence rule set, the pointer back +to a master plus original start for exception instances, status, and attendees and conference +data as JSON blobs. An outbox table holds pending writes. + +Events are stored as Google returns them with `singleEvents=false`, meaning recurring series +are one row carrying an RRULE rather than thousands of rows. Expansion happens on read. + +## Sync + +Incremental sync through `events.list` with a stored `syncToken`, one cursor per calendar, +`singleEvents=false`, `showDeleted=true`, `maxResults=2500`. The parameter set has to be +byte-identical across every call in a chain, including the initial one. + +The constraint that shapes everything: `timeMin` and `timeMax` cannot be used with +`syncToken`, along with `q`, `orderBy`, `updatedMin`, `iCalUID` and the extended property +filters. There is no way to sync a window. The initial full sync pulls the entire calendar +history, which for an old personal calendar is a few thousand rows and a one-time cost of +seconds, and is only tolerable because recurring series collapse to single rows. + +`nextSyncToken` appears only on the final page, so pages must be walked to exhaustion with +`pageToken` before anything is persisted. A 410 means the token is dead: drop that calendar's +rows and its cursor and full-resync it alone, without touching the others. `calendarList.list` +carries its own sync token for the set of calendars itself. + +Polling, not webhooks. Push notifications need a publicly verified HTTPS callback and channels +that expire every few days, neither of which a desktop app has. Sixty seconds while the window +is focused, five minutes when it is not. An incremental poll with no changes is a single small +request against a million-a-day quota. + +## Writes + +Optimistic. A write lands in SQLite, is marked dirty, is enqueued in the outbox and renders +immediately; the push to Google happens behind it. Requests carry `If-Match` with the stored +etag, and a 412 means someone else changed it first, so refetch and surface the conflict +rather than clobbering. Offline writes queue and drain on reconnect. The outbox is the reason +the app works on a plane and the reason it never spins. + +## Recurrence + +The `rrule` crate, which is built on `chrono-tz`. Expansion takes a window and a set of +masters, generates occurrences, drops any occurrence whose original start matches a cancelled +or overridden instance, then merges the override events back in at their moved times. The +frontend asks for a range and receives a flat array of instances; it has no concept of a +recurrence rule. + +Editing a series is three distinct operations. Editing one instance resolves the real instance +through `events.instances` and patches that, rather than constructing the instance id by hand. +Editing this and all following truncates the master's rule with an UNTIL and creates a new +master from the split point. Editing the whole series patches the master. All three are worth +tests before they are worth UI. + +## Time + +Every event keeps its own timezone; the grid renders in the local zone. All-day events are +date-only and must never be shifted into a zone, which is the single most common bug in this +category of app and deserves a test that runs in a non-UTC zone. DST transitions mean a day is +sometimes 23 or 25 hours long, which the vertical fit calculation has to accept rather than +assume away. + +## Platforms + +macOS uses the overlay title bar with the header row padded for the traffic lights, as margin +does. Linux has no traffic lights, so that padding is conditional. Linux builds need +`libwebkit2gtk-4.1-dev` and ship as AppImage and deb. + +## Order of work + +The sync engine and the grid are the two hard things and neither is proven by the other, so +the first milestone is authentication, sync and a read-only grid with correct vertical fit. +Recurrence and timezone correctness come second, with tests, because everything after depends +on the instance stream being right. Create, edit and delete with the outbox come third. +Multiple accounts, the command palette and natural language parsing come last, since they are +the parts that are pleasant rather than load-bearing. diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 0000000..097e529 --- /dev/null +++ b/docs/conventions.md @@ -0,0 +1,89 @@ +# Conventions + +This project is a sibling to `../margin` and follows its conventions deliberately rather than +inventing new ones. When something here is unclear, the answer is almost always "do what margin +does", and the file to look at is named below. + +## Rust + +`Result` everywhere. No `anyhow`, no custom error enum except `google::api::ApiError`, +which exists only because the sync engine has to distinguish a 410 and a 412 from everything else. + +DTOs crossing the IPC boundary live in `src-tauri/src/dto.rs` and are marked +`#[serde(rename_all = "camelCase")]`. That file is the contract and is frozen: implementation +modules add bodies, not fields. Its mirror is `src/ipc.ts`. + +Read Google's responses through `google::auth::read_json`, which takes the body to a `String` +first so the error payload survives into the message. Ported from `margin/src-tauri/src/gdrive.rs:286`. + +Heavy synchronous work goes behind `#[tauri::command(async)]` on a synchronous fn, which is +margin's `pdf.rs:90` trick for getting off the main thread without hand-writing `spawn_blocking`. + +Comments are rare and explain why, never what. Match the density in `lib.rs`. + +## TypeScript + +One zustand store per domain in `src/store/`. No middleware. One selector call per field +(`useThing((s) => s.field)`, never a destructured object), actions as inline arrow properties, and +`set((s) => ...)` returning `{}` to no-op. See `margin/src/store/useBackup.ts`. + +Async actions use a string phase union (`"idle" | "syncing" | "error"`), never boolean loading +flags. Errors stringify with `String(e)` and surface as a toast. + +Side effects that touch disk, the DOM or Tauri live in a sibling module, never inside the store. + +The OAuth connect flow reuses the promise-holding-its-own-resolver pattern from +`margin/src/store/useBackup.ts:70`: `connect()` returns a promise whose `resolve` is stashed in +state for a later Tauri event to settle. + +Typed IPC wrappers live in `src/api/`, one module per domain, one thin function per command. They +are already written; add bodies to Rust, not new wrappers. + +## CSS + +Flat kebab-case class names, not BEM. State is a `data-*` attribute, never an `is-` class. + +Every colour, radius and size goes through a token in `src/styles/tokens.css`. If a value is not +in there, add a token rather than a literal. + +Dark mode is `data-theme` on ``, with both palettes defining an identical variable set. +Never a media query for theme. + +Transitions name explicit properties and use `var(--ease)`. Never `transition: all`. + +Responsiveness is JS-driven through `useCompact()`, which writes `data-compact` on the root. +Style against that attribute rather than adding media queries. + +`usePhone()` and `useTouch()` write `data-phone` and `data-touch` the same way, and they answer +different questions. `data-phone` is a window too narrow for the desktop chrome and it governs +layout; `data-touch` is a coarse pointer and it governs interaction. A tablet is touch and not a +phone, a narrow desktop window is a phone and not touch, and treating either as a proxy for the +other is how a hover-only control ends up unreachable. Both are also set by the boot script in +`index.html`, so the first paint is already the right shape. + +A rule that reads "you cannot hover here" belongs on `data-touch`. A rule that reads "there is no +room for this" belongs on `data-phone` or `data-compact`. + +The one exception is the event block, which uses a container query on its own inline size. That is +deliberate: `data-compact` describes the window, but what decides how many lines of a title fit is +the block's own width, and in a three-deep overlap cluster that is a quarter of a column. Nothing +else may reach for a container query without the same kind of reason. + +Overlays follow margin's `.overlay` and `.panel` idiom, already in `app.css`, and every one of +them registers with `useEscapeLayer` from `src/escape.ts` so Escape unwinds the layers in order. + +## Icons + +Inline Feather-style 24x24 stroke `d` strings passed to ``. There is no icon set +and no registry, and there will not be one. An icon-only button always carries a `title` with its +shortcut written in real glyphs. + +## Storage keys + +Anything in `localStorage` is prefixed `margincal-`, following margin's convention in `theme.ts` +and `panes.ts`. Keys read before first paint are restored by the blocking IIFE in `index.html`. + +## Never + +No CSS framework, no component library, no router, no zustand middleware, no directory trees in +any document, and no em dashes anywhere including code comments. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..c9c8877 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,135 @@ +# Design + +A desktop calendar for Google Calendar, macOS and Linux. It exists because Google Calendar +wastes vertical space you cannot reclaim and surrounds the grid with chrome you never asked +for. Everything below follows from those two complaints. + +## The grid owns the window + +There is no permanent sidebar. The mini-month, the calendar list, search and settings are all +overlays: summoned by a key or a hover, dismissed with Escape, never resident. The only +persistent chrome is a single header row carrying the date range, the view switcher and the +current time range. On macOS the traffic lights float over that row, so it costs no extra +height. + +This is the whole point. A 1440p monitor should show a week of your life, not a week of your +life inside a browser inside a Google header. + +## Vertical fit + +The grid never scrolls. Row height is derived from the window, so whatever range is visible +always fits exactly. + +The visible range is computed from the events in the span you are looking at: floor to the +hour before your earliest event, ceil to the hour after your latest, clamped to a minimum of +eight hours so a quiet week does not render four enormous rows. Empty bands at the top and +bottom fold into thin strips labelled with their range and a count of anything hiding inside. +Click or press `z` to unfold one. + +The range expands immediately when it needs to, but only contracts when it would shrink by two +hours or more. Without that hysteresis the axis flickers as you page through weeks, and a +flickering axis destroys the positional memory that makes a keyboard-driven calendar fast. + +Interior gaps stay at full scale. A three-hour hole on a Wednesday afternoon is the most +useful thing on the screen, because it is where work goes, and folding it automatically would +make a packed day look identical to an open one. But you can fold one deliberately with `z`, +and it stays folded across navigation until you unfold it. The fold is per range, remembered +in local state, not derived from the data. + +One consequence worth stating plainly: within the unfolded region the scale is strictly +linear, so a block twice as tall is an event twice as long, always. That property is why the +grid is worth having at all, and it is the thing automatic gap-folding would have cost. + +## Events on the grid + +Overlapping events use the standard constraint model. Sort by start, partition into collision +clusters, assign each event the leftmost free column within its cluster, then let every event +expand rightward into columns that stay free for its whole duration. Colliding events end up +the same width, nothing visually overlaps, and an event that only collides briefly still gets +most of the day's width. + +An event block is a washed neutral surface with a two-pixel coloured edge on the left +identifying its calendar. Colour never fills the block. This keeps the grid quiet enough to +read as a shape while still letting you tell work from personal at a glance, and it means the +app looks like a sibling of margin rather than a different product. + +All-day events sit in a fixed band under the day headers, one row tall, with anything that does +not fit behind a count that expands over the grid. The band's height has to be constant: the axis +below is solved from whatever height is left, so a band that grew with its contents would change +the row height as you paged and slide every hour on the grid, which is the same positional memory +the contraction hysteresis exists to protect. A taller fixed reserve would be the other sin, an +empty band eating space you cannot reclaim. + +## Keyboard + +Keyboard-first, mouse fully supported. Drag on empty grid to create, drag a block to move it, +drag its edge to resize. None of that is second class. + +Navigation is `h` and `l` for previous and next day, `H` and `L` for week, `t` for today. +`j` and `k` move the selection through the events of the focused day. `d`, `w` and `a` switch +view, and `m` summons the mini-month, since there is no month view for it to switch to. `z` +folds or unfolds the band under the cursor. + +Actions are `c` to create, `Enter` to open the selection, `e` to edit, `x` to delete, `/` to +search, `Escape` to dismiss whatever is open. `Cmd-K` opens the command palette, which is also +where creation happens. + +Every binding is listed in an overlay behind `?`. Nothing is modal and nothing is chorded. +Vimcal's ceiling is higher and its users find it confusing, which is a trade worth refusing. + +## On a phone + +Keyboard-first stops being a design when there is no keyboard, so a phone gets a different set of +affordances for the same commands rather than a shrunken copy of the desktop ones. The build steps +are in [mobile.md](mobile.md); the decisions are here. + +The chrome inverts. The desktop header holds three groups across a single row and there is no +arrangement of that which fits 390 points, so the date and the day arrows go to a top bar, the +views go to a bottom tab bar where a thumb can reach them, and the rest goes into an overflow +sheet. Overlays become bottom sheets for the same reason: a centred panel puts its buttons where +the hand is not. + +Interaction changes on touch, not on width. Anything that only appeared on hover is always visible +instead, because a finger cannot hover and an affordance nobody can reach is not an affordance. +Dragging out a new event waits for a long press, because the alternative is that every tap on an +empty afternoon starts creating something. A tablet gets these and keeps the week grid; a narrow +desktop window gets the layout and keeps its mouse behaviour. + +Navigation still moves one day at a time. A swipe is one day, never a week. + +The week view survives on a phone rather than being hidden, because an overview has value even at +fifty points a column, but it is an overview: no times on the blocks, one letter for the day name, +and tapping a day header drops into that day. The day view is what a phone opens on. + +What does not change is the premise. The day still fits without scrolling, the grid still owns +what is left after the two bars, and there is still no month view. + +## Creating an event + +Press `c` or `Cmd-K` and type. `lunch with sam tue 1pm 45m` creates a 45-minute event on +Tuesday. The parse is shown live underneath the input as you type, as a plain sentence with +the resolved date, time, duration and target calendar, so you can see what will be created +before you commit. If the parse is ambiguous the preview says so rather than guessing +silently. + +`#work` targets a calendar, `at ` sets the location, a bare time range like `9-10am` +sets both ends. Anything the parser does not recognise stays in the title. + +Dragging on the grid also creates: drag the range, type the title in place, Enter. Same event, +different mood. + +## Meetings + +Attendees and conference links are shown read-only on an event that has them, including who +has accepted. There is no RSVP flow, no availability finder and no scheduling links. That is +a deliberate cut, not an oversight, and it can be revisited once the calendar itself is good. + +## Visual language + +The token layer is lifted from margin unchanged: warm paper surfaces, ink and two softer ink +tones, hairline borders, a four-step type scale, three radii, one easing curve, light and dark +driven by `data-theme` on the root. The additions are grid-specific: hour and half-hour rule +colours, the now-line, the folded-band surface, and a set of eight muted calendar hues chosen +to sit on warm paper without shouting. + +No CSS framework and no component library, same as margin. Hand-written CSS on top of tokens. diff --git a/docs/mobile.md b/docs/mobile.md new file mode 100644 index 0000000..2d63440 --- /dev/null +++ b/docs/mobile.md @@ -0,0 +1,198 @@ +# Android and iOS + +The same Rust core and the same React app, with a different shape of chrome and a different way of +getting a token back from Google. Nothing is forked: the layout switches on a `data-phone` +attribute and the OAuth flow switches on `cfg(mobile)`. + +## Google will not let you reuse the desktop client + +This is the part that cannot be automated away, so do it first or nothing will sign in. + +Google issues OAuth clients per platform and refuses one in another's place. The desktop client +redirects to a loopback port; Google rejects loopback redirects for Android and iOS client types +outright, and iOS will not dependably keep a listener alive to catch one anyway. So a phone build +redirects to a custom URI scheme instead, and needs its own client to do it. + +Both mobile clients are **public**: no client secret exists, and PKCE is the only thing between an +intercepted authorization code and a token. That is why the verifier is not optional anywhere in +`auth.rs`. + +In the Google Cloud console, on the same project that already has the Calendar API enabled: + +1. Create an OAuth client of type **Android**. It wants the package name, which is + `studio.margin.calendar`, and the SHA-1 fingerprint of the certificate that signs the build. + For a debug build that is the shared debug keystore: + + ``` + keytool -list -v -keystore ~/.android/debug.keystore \ + -alias androiddebugkey -storepass android -keypass android + ``` + + A release build is signed with a different key and needs its fingerprint added too. An APK + signed by a key Google has not been told about fails at consent, not at build. + +2. Create an OAuth client of type **iOS**. It wants the bundle identifier, which is also + `studio.margin.calendar`. + +3. Put both client ids in `google-credentials.json` alongside the desktop one, matching + `google-credentials.example.json`: + + ```json + { + "installed": { "client_id": "...", "client_secret": "...", "...": "..." }, + "android": { "client_id": "YOUR_ANDROID_CLIENT_ID.apps.googleusercontent.com" }, + "ios": { "client_id": "YOUR_IOS_CLIENT_ID.apps.googleusercontent.com" } + } + ``` + + The file is gitignored and embedded at build time. A build with no `android` or `ios` block + compiles and then says so at runtime rather than failing to compile, which is the same bargain + the desktop client already had. + +## The redirect schemes + +Android redirects to `studio.margin.calendar:/oauth2redirect`. That is the package name, which is +Google's documented form for an Android client, and being known at build time means it can sit in +`AndroidManifest.xml` permanently rather than being pasted in per install. + +iOS gets no such choice. Google requires the reversed client id, so the scheme is only knowable +once your client id is: take the client id, drop the `.apps.googleusercontent.com` suffix, and +prefix `com.googleusercontent.apps.`. + +Register it in `src-tauri/tauri.conf.json`, as a second entry in the deep link plugin's scheme +list: + +```json +"deep-link": { + "desktop": { "schemes": ["studio.margin.calendar"] }, + "mobile": [{ "scheme": ["studio.margin.calendar", "com.googleusercontent.apps.YOUR_ID"] }] +} +``` + +Not in `Info.plist`. Editing that by hand looks like it works and then silently stops working: the +plugin's build script rewrites `CFBundleURLTypes` wholesale from this config on every build, and +when the `mobile` array is empty it deletes the key outright. That one empty array is why the +callback was dead on both platforms at first, so if a deep link ever stops arriving, look here +before anywhere else. + +If the console shows you something different from either default, put it in the client's block in +`google-credentials.json` as `redirect_uri` and it wins over both. Whatever you put there still +has to have its scheme registered above, or the OS has no reason to hand the link to this app. + +## Toolchain + +iOS needs Xcode and the iOS Rust targets: + +``` +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios +``` + +Android needs the SDK, an NDK of r25 or newer, a JDK that the generated Gradle build accepts (21 +works, 25 does not), and the four Android Rust targets: + +``` +rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android +export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools +export NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018 +export JAVA_HOME=/opt/homebrew/opt/openjdk@21 +``` + +## Building + +``` +pnpm tauri ios init # once, generates the Xcode project +pnpm tauri ios dev # simulator, with the Vite dev server +pnpm tauri ios build + +pnpm tauri android init # once, generates the Gradle project +pnpm tauri android dev # emulator or attached device +pnpm tauri android build +``` + +The generated projects are committed. Re-running `init` overwrites them, so check afterwards that +`MainActivity.kt` still publishes the window insets, since nothing else on Android does. + +Both `dev` commands start Vite themselves and fail outright if port 1430 is already taken, which +it will be if a browser dev server is still up. Kill it, or pass a different port through +`--config`. + +Three failures that look like bugs and are not. Xcode refusing to build with "Entitlements file +was modified during the build" is a stale mtime in DerivedData, fixed once by deleting +`~/Library/Developer/Xcode/DerivedData/margin-calendar-*`. An Android run panicking with "failed +to build WebSocket client, Connection refused" is a stale +`$TMPDIR/studio.margin.calendar-server-addr` pointing at a dead port; delete it. And an emulator +that will not boot by name usually means `avdmanager` and `emulator` disagree about where AVDs +live, which `ANDROID_AVD_HOME` settles. + +Finally, an empty calendar on a device is correct. The browser fixture is gated on not being +inside Tauri, so on a phone the real backend answers and there is nothing to show until an account +is connected. Events without signing in only ever happen in a browser. + +## What is different on a phone + +The desktop header carries three groups of controls across one row, which does not fit in 390 +points. Under `data-phone` it becomes a top bar with the date and the day arrows and a bottom tab +bar with the views, and everything the trailing icon row used to hold moves into an overflow +sheet. Overlays become bottom sheets. Both bars pad themselves out of the way of the notch and the +home indicator with `env(safe-area-inset-*)`. + +Under `data-touch`, which a tablet gets and a narrow desktop window does not, interaction changes +rather than layout. Anything that only appeared on hover is always visible instead, because a +finger cannot hover. Dragging out a new event waits for a long press, because on a touchscreen the +alternative is that every tap on an empty afternoon starts creating something. + +Navigation still moves one day at a time. A swipe is one day, not one week. + +Both attributes are also set by the boot script in `index.html` before first paint, so a phone +does not render the desktop layout for a frame and then jump. + +## Keeping out from under the system bars + +`--safe-top` and `--safe-bottom` are `env(safe-area-inset-*)` by default, which is correct on iOS +and wrong on Android in a way that is easy to miss. Android's WebView derives those values from the +display cutout alone and never from the system bars, so it reports 0 at the bottom while the +navigation bar really occupies 24dp of gesture pill or 48dp of buttons, and the tab bar renders +underneath it. `targetSdk` is 36, so edge-to-edge is mandatory and there is nothing to opt out of. +The top only looked right on a test device by luck, because that device had a cutout; a phone +without one would have tucked the top bar under the status bar for the same reason. + +So Android measures the bars natively. `MainActivity.kt` reads `systemBars() or displayCutout()` +from `WindowInsetsCompat`, exposes them over a JavaScript bridge, and re-fires on every inset +change, which covers rotation, the keyboard, and switching between gesture and button navigation +live. `src/safeArea.ts` converts device pixels to CSS pixels and writes the two variables onto the +root, where they beat the `env()` defaults. Off Android the bridge is simply absent and the +defaults stand, so iOS and desktop are untouched. + +Two consequences worth knowing. `tauri android init` regenerates `MainActivity.kt`, and nothing +else on Android supplies these values, so check the bridge is still there after re-running it. +And installing the inset listener makes `env(safe-area-inset-*)` read 0 inside that WebView, which +does not matter only because those two token lines are the sole consumers and the bridge overrides +both with better numbers. Anything new that reaches for `env()` directly on Android will get zero. + +iOS needed one line of native code for the same class of problem, in the opposite direction. + +UIKit hands a scroll view the safe areas as content insets unless told otherwise, and wry never +tells it otherwise: it touches the scroll view only to switch `bounces` off. WebKit then lays the +page out in what is left. On an iPhone 17 Pro that meant a layout viewport 778pt tall against an +874pt screen, still anchored at y 0, so the bottom 96pt of the display was outside the page +altogether and showed as a dead band of shell colour under the tab bar. `body` is +`position: fixed`, so nothing could paint down there whatever the stylesheet said. + +`stop_uikit_shrinking_the_viewport` in `lib.rs` sets `contentInsetAdjustmentBehavior` to `never` +through `with_webview`, which gives the page the whole screen back. The insets are not lost, they +arrive as `env(safe-area-inset-*)` instead, which is where both bars already read them from. + +Worth knowing before anyone tries to solve that in CSS, because it looks like a CSS problem and +the obvious fix is worse than the bug. While the viewport was short, `100dvh` was the only unit +reporting the real box; `vh`, `svh` and `lvh` all reported the full screen. Sizing the root +`100svh` therefore did not reclaim the bottom of the screen, it laid the tab bar out past the clip +where it was invisible and, less obviously, untappable. The root is `height: 100%`, which inherits +whatever the box is and cannot drift if the native line ever regresses. + +## Limits worth knowing + +There is no auto-updater and no process restart on mobile: the store is the update channel, and +both plugins are compiled out rather than merely hidden. + +The initial sync pulls the whole calendar history, because Google forbids `timeMin` alongside a +`syncToken`. That is already the desktop behaviour and it is slower on a phone radio. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..e476afe --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,55 @@ +# Setup + +## The Google OAuth client + +The app talks to Google with its own OAuth desktop client, which is not in this repo and cannot +be. You need to make one before the app can connect to anything. + +1. In the Google Cloud console, enable the Google Calendar API on a project. +2. On the OAuth consent screen, add the `https://www.googleapis.com/auth/calendar` scope. It is a + sensitive scope, so an unverified client shows an interstitial and caps at 100 users. That is + fine for personal use and matters the day this ships more widely. +3. Create an OAuth client ID of type **Desktop app**. That type permits the loopback redirect, and + Google allows installed clients any loopback port, so there is nothing to register. This client + is for macOS, Linux and Windows only; phones need their own, see [mobile.md](mobile.md). +4. Save the downloaded JSON as `google-credentials.json` in the repo root. It should match the + shape of `google-credentials.example.json`: an `installed` object with `client_id`, + `client_secret`, `auth_uri` and `token_uri`. + +The real file is gitignored. The build embeds it when it is present and embeds the example when it +is not, so a fresh clone builds and then tells you at runtime that Google Calendar is not set up +yet, rather than failing to compile. + +Building for a phone needs a second and third OAuth client, because Google will not accept a +desktop client from Android or iOS. That, and the toolchain, is in [mobile.md](mobile.md). + +Refresh tokens never go to disk in plaintext and never into the SQLite database. They are sealed +with XChaCha20-Poly1305 in the app data directory, identically on all five platforms, by +`src-tauri/src/google/secrets.rs`. That file states what the encryption is worth where: on iOS and +Android the app sandbox is the real boundary and this is defence behind it, while on a desktop +anyone who can read your home directory can read the token. There is no OS credential store in the +picture and nothing will ever prompt you for keychain access. + +## Building + +``` +pnpm install +pnpm tauri dev +``` + +`pnpm test` runs the frontend suites, `cargo test` inside `src-tauri` runs the Rust ones. Both +should be green before anything is considered done. + +## Linux + +Building needs `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `librsvg2-dev`, +`libayatana-appindicator3-dev` and `libxdo-dev`. Build on the oldest baseline you intend to +support, which is Ubuntu 22.04 or Debian 12, because the resulting binary will not run on anything +older than the glibc it was linked against. + +## Where the data lives + +`~/Library/Application Support/studio.margin.calendar/` on macOS and +`~/.local/share/studio.margin.calendar/` on Linux. The bundle identifier is deliberately distinct +from margin's, so the two apps never share a directory. Deleting that directory resets the app; +the accounts reconnect and sync pulls everything back. diff --git a/google-credentials.example.json b/google-credentials.example.json new file mode 100644 index 0000000..04a81ea --- /dev/null +++ b/google-credentials.example.json @@ -0,0 +1,19 @@ +{ + "installed": { + "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com", + "project_id": "your-project-id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "YOUR_CLIENT_SECRET", + "redirect_uris": ["http://127.0.0.1"] + }, + + "android": { + "client_id": "YOUR_ANDROID_CLIENT_ID.apps.googleusercontent.com" + }, + + "ios": { + "client_id": "YOUR_IOS_CLIENT_ID.apps.googleusercontent.com" + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..c6a6e07 --- /dev/null +++ b/index.html @@ -0,0 +1,47 @@ + + + + + + + + Margin Calendar + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..db3c8ee --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "margin-calendar", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "playwright test" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2", + "@tauri-apps/plugin-updater": "^2", + "chrono-node": "^2.7.7", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "~5.8.3", + "vite": "^7.0.4", + "vitest": "^3.2.4" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6387697 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,53 @@ +// The end-to-end suite drives the real UI in a browser, where `src/ipc.ts` routes every command to +// the dev fixture. One command starts everything: `pnpm test:ui` brings up Vite itself and reuses a +// dev server that is already running. +// +// The viewport is fixed, because half of what these tests assert is geometry: block heights, the +// solved row height, whether the axis moved. A test that measured a window of unknown size would +// be asserting nothing. + +import { defineConfig } from "@playwright/test"; + +const PORT = 1430; +const BASE_URL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + // No retries on purpose. A test that only passes on the second go is a test that is lying about + // something, and this suite exists because things that looked fine were not. + retries: 0, + reporter: [["list"]], + // Traces and failure screenshots go somewhere already ignored, so a run cannot leave anything + // behind for the next `git add` to pick up. The paths are printed with the failure that made + // them. + outputDir: "node_modules/.cache/playwright", + timeout: 30_000, + expect: { timeout: 5_000 }, + + use: { + baseURL: BASE_URL, + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + // The fixture is anchored to the browser's local day, so pinning the zone keeps a run on one + // machine comparable with a run on another. Nothing in the suite depends on the zone itself. + timezoneId: "Asia/Kolkata", + locale: "en-GB", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + + // Deliberately not `devices["Desktop Chrome"]`: that device pins a Windows user agent, and the + // keymap reads the platform off the user agent to decide whether the primary modifier is Command + // or Control. A faked platform would test the wrong half of every shortcut. + projects: [{ name: "chromium", use: { browserName: "chromium" } }], + + webServer: { + command: "pnpm dev", + url: BASE_URL, + reuseExistingServer: true, + timeout: 60_000, + stdout: "ignore", + stderr: "pipe", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f0e387b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1685 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tauri-apps/api': + specifier: ^2 + version: 2.11.1 + '@tauri-apps/plugin-opener': + specifier: ^2 + version: 2.5.4 + '@tauri-apps/plugin-process': + specifier: ^2 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2 + version: 2.10.1 + chrono-node: + specifier: ^2.7.7 + version: 2.10.1 + react: + specifier: ^19.1.0 + version: 19.2.8 + react-dom: + specifier: ^19.1.0 + version: 19.2.8(react@19.2.8) + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.18)(react@19.2.8) + devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@tauri-apps/cli': + specifier: ^2 + version: 2.11.4 + '@types/react': + specifier: ^19.1.8 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.6 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^4.6.0 + version: 4.7.0(vite@7.3.6) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^7.0.4 + version: 7.3.6 + vitest: + specifier: ^3.2.4 + version: 3.2.7 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + baseline-browser-mapping@2.11.13: + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chrono-node@2.10.1: + resolution: {integrity: sha512-tPOsBzYVAK5zth+CiHCgp5NGeqRc4mMD8FPthQ5IxkgjPWWxIFdM5odnmfw//IRAzl6C++Xy8olQW7wn5qBR1g==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + electron-to-chromium@1.5.403: + resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.3.0: + resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@7.3.6)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.6 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6)': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + baseline-browser-mapping@2.11.13: {} + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.13 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.403 + node-releases: 2.0.53 + update-browserslist-db: 1.3.0(browserslist@4.28.8) + + cac@6.7.14: {} + + caniuse-lite@1.0.30001809: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chrono-node@2.10.1: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + electron-to-chromium@1.5.403: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loupe@3.2.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-releases@2.0.53: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react@19.2.8: {} + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.8.3: {} + + update-browserslist-db@1.3.0(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6: + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.7: + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yallist@3.1.1: {} + + zustand@5.0.14(@types/react@19.2.18)(react@19.2.8): + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.8 diff --git a/public/fonts/HankenGrotesk-Italic-VF.ttf b/public/fonts/HankenGrotesk-Italic-VF.ttf new file mode 100644 index 0000000..eb28513 Binary files /dev/null and b/public/fonts/HankenGrotesk-Italic-VF.ttf differ diff --git a/public/fonts/HankenGrotesk-VF.ttf b/public/fonts/HankenGrotesk-VF.ttf new file mode 100644 index 0000000..65a8f7b Binary files /dev/null and b/public/fonts/HankenGrotesk-VF.ttf differ diff --git a/public/fonts/Literata-Italic-VF.ttf b/public/fonts/Literata-Italic-VF.ttf new file mode 100644 index 0000000..8690209 Binary files /dev/null and b/public/fonts/Literata-Italic-VF.ttf differ diff --git a/public/fonts/Literata-VF.ttf b/public/fonts/Literata-VF.ttf new file mode 100644 index 0000000..0a81b49 Binary files /dev/null and b/public/fonts/Literata-VF.ttf differ diff --git a/public/margincal-mark.png b/public/margincal-mark.png new file mode 100644 index 0000000..69b0d10 Binary files /dev/null and b/public/margincal-mark.png differ diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..2ff7ad2 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,6025 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "margin-calendar" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chacha20poly1305", + "chrono", + "chrono-tz", + "objc2", + "objc2-ui-kit", + "rand 0.8.7", + "reqwest 0.12.28", + "rrule", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-deep-link", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rrule" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74" +dependencies = [ + "chrono", + "chrono-tz", + "log", + "regex", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf 0.13.1", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf 0.13.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "769381976ea05c1f0abde0ca527b02e415bccbdbad148534a2b0c1901c9ab934" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..9140e39 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "margin-calendar" +version = "0.1.0" +description = "A calendar for Google Calendar" +authors = ["Margin"] +edition = "2021" + +[lib] +name = "margin_calendar_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" +# Mobile has no loopback listener to catch Google's redirect, so the OAuth answer comes back as a +# custom URI scheme the OS routes to this app. Registered on desktop too, so both flows are one +# code path with one difference in it rather than two. +tauri-plugin-deep-link = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +sha2 = "0.10" +rand = "0.8" +url = "2" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +rusqlite = { version = "0.37", features = ["bundled"] } +rrule = "0.14" +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.10" +tokio = { version = "1", features = ["sync", "time"] } + +# Refresh tokens are sealed with XChaCha20-Poly1305 and kept in the app data directory. There is no +# `keyring` here on purpose: it has no Android backend at all, and on macOS it ties the item to the +# code signature, so every rebuild re-prompts for authorization. src/google/secrets.rs states what +# the file is and is not worth on each platform. +chacha20poly1305 = "0.10" + +# There is no auto-updater and no process to restart on a phone: the store is the update channel. +# Gated here as well as behind cfg(desktop) in lib.rs so a mobile build does not compile them at all. +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-process = "2" +tauri-plugin-updater = "2" + +# One UIKit property that wry does not set for us; stop_uikit_shrinking_the_viewport in lib.rs says +# which one and why. These versions are the ones wry already resolves for its own iOS backend, so +# matching them keeps a single copy of objc2 in the build rather than a second incompatible one. +[target.'cfg(target_os = "ios")'.dependencies] +objc2 = "0.6" +objc2-ui-kit = { version = "0.3", default-features = false, features = [ + "std", + "UIResponder", + "UIView", + "UIScrollView", +] } + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..187e9d5 --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,29 @@ +use std::path::{Path, PathBuf}; + +fn main() { + embed_credentials(); + tauri_build::build() +} + +// The OAuth desktop client is baked into the binary, but a clone of this repo has no +// google-credentials.json. Embedding the example file instead of failing the build turns a +// confusing compile error into the runtime "not set up yet" message from load_credentials. +fn embed_credentials() { + let root: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("manifest dir has a parent") + .to_path_buf(); + let real = root.join("google-credentials.json"); + let example = root.join("google-credentials.example.json"); + + println!("cargo:rerun-if-changed={}", real.display()); + println!("cargo:rerun-if-changed={}", example.display()); + + let source = if real.exists() { &real } else { &example }; + let contents = std::fs::read(source) + .unwrap_or_else(|e| panic!("could not read {}: {e}", source.display())); + + let out = Path::new(&std::env::var("OUT_DIR").expect("OUT_DIR is set")) + .join("google-credentials.json"); + std::fs::write(&out, contents).expect("could not write embedded credentials"); +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..85d4009 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window, on every platform", + "windows": ["main"], + "permissions": ["core:default", "opener:default", "deep-link:default"] +} diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json new file mode 100644 index 0000000..b0aa0b6 --- /dev/null +++ b/src-tauri/capabilities/desktop.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "desktop", + "description": "Window control, the updater and process restart, none of which a phone has", + "platforms": ["macOS", "windows", "linux"], + "windows": ["main"], + "permissions": [ + "core:window:allow-destroy", + "core:window:allow-start-dragging", + "core:window:allow-toggle-maximize", + "updater:default", + "process:allow-restart" + ] +} diff --git a/src-tauri/gen/android/.editorconfig b/src-tauri/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/src-tauri/gen/android/.gitignore b/src-tauri/gen/android/.gitignore new file mode 100644 index 0000000..1c636c3 --- /dev/null +++ b/src-tauri/gen/android/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties +keystore.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/src-tauri/gen/android/app/.gitignore b/src-tauri/gen/android/app/.gitignore new file mode 100644 index 0000000..6c4d56b --- /dev/null +++ b/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/**/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..132aabe --- /dev/null +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "studio.margin.calendar" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "studio.margin.calendar" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/src-tauri/gen/android/app/proguard-rules.pro b/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d4225f6 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/java/studio/margin/calendar/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/studio/margin/calendar/MainActivity.kt new file mode 100644 index 0000000..9a1a506 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/studio/margin/calendar/MainActivity.kt @@ -0,0 +1,60 @@ +package studio.margin.calendar + +import android.os.Bundle +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.activity.enableEdgeToEdge +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +/** + * Android's WebView works out env(safe-area-inset-*) from the display cutout and from nothing else, + * so the status and navigation bars overlap the page while env() still reads 0 underneath them. A + * targetSdk of 36 makes edge to edge mandatory, so there is no overlap to opt out of: the only way + * out is to measure the bars and tell the page. That is what this does, and the page turns the + * numbers into --safe-top and --safe-bottom (src/safeArea.ts). + */ +class MainActivity : TauriActivity() { + // Device pixels. Written on the UI thread by the insets listener and read on the WebView's + // JavaScript bridge thread, which is a different one. + @Volatile private var topPx = 0 + @Volatile private var bottomPx = 0 + + inner class SafeArea { + @JavascriptInterface fun top(): Int = topPx + + @JavascriptInterface fun bottom(): Int = bottomPx + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } + + override fun onWebViewCreate(webView: WebView) { + // wry calls this between constructing the WebView and loading the URL, which is the only point + // at which an interface can be added and still be there for the first document. + webView.addJavascriptInterface(SafeArea(), "__androidSafeArea") + + ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets -> + // The cutout is folded in rather than trusted on its own: a landscape cutout down one side + // is not a system bar, and a hidden system bar is not a cutout, and the page has to clear + // whichever of the two is deeper. + val bars = + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + topPx = bars.top + bottomPx = bars.bottom + // The page reads the bridge itself as it boots. This is for everything after that: a + // rotation, or a switch between gesture and three button navigation, moves both numbers. + webView.evaluateJavascript("window.dispatchEvent(new Event('androidsafearea'))", null) + // Handed on rather than returned, because a listener stands in for the view's own handler + // instead of running alongside it, and the WebView would otherwise see no insets at all. + // It does not bring env(safe-area-inset-*) back on its own: that reads 0 here whatever we + // do, which is the whole reason the page takes its numbers from the bridge above. + ViewCompat.onApplyWindowInsets(view, insets) + } + ViewCompat.requestApplyInsets(webView) + } +} diff --git a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..85d0c88 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..13dd214 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a888b33 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a2a838e Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3f8a57f Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..bdd6fa8 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/values/colors.xml b/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/strings.xml b/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c5bc682 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + "Margin Calendar" + "Margin Calendar" + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/themes.xml b/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bdd6fa8 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..607240b --- /dev/null +++ b/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/src-tauri/gen/android/buildSrc/build.gradle.kts b/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..5c55bba --- /dev/null +++ b/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/src-tauri/gen/android/buildSrc/src/main/java/studio/margin/calendar/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/studio/margin/calendar/kotlin/BuildTask.kt new file mode 100644 index 0000000..f764e2a --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/studio/margin/calendar/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """pnpm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/buildSrc/src/main/java/studio/margin/calendar/kotlin/RustPlugin.kt b/src-tauri/gen/android/buildSrc/src/main/java/studio/margin/calendar/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/studio/margin/calendar/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/gradle.properties b/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..2a7ec69 --- /dev/null +++ b/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/src-tauri/gen/android/gradlew b/src-tauri/gen/android/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/src-tauri/gen/android/gradlew.bat b/src-tauri/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src-tauri/gen/android/settings.gradle b/src-tauri/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/src-tauri/gen/apple/.gitignore b/src-tauri/gen/apple/.gitignore new file mode 100644 index 0000000..6726e2f --- /dev/null +++ b/src-tauri/gen/apple/.gitignore @@ -0,0 +1,3 @@ +xcuserdata/ +build/ +Externals/ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png new file mode 100644 index 0000000..a6ac2a8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png new file mode 100644 index 0000000..cf265a4 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png new file mode 100644 index 0000000..29c9746 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png new file mode 100644 index 0000000..e4adcbc Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..704c929 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png new file mode 100644 index 0000000..2a9fbc2 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png new file mode 100644 index 0000000..2cdf184 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png new file mode 100644 index 0000000..4723e4b Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..f26fee4 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..90eea7e --- /dev/null +++ b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "AppIcon-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "AppIcon-512@2x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/src-tauri/gen/apple/Assets.xcassets/Contents.json b/src-tauri/gen/apple/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/src-tauri/gen/apple/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/src-tauri/gen/apple/ExportOptions.plist b/src-tauri/gen/apple/ExportOptions.plist new file mode 100644 index 0000000..0428a17 --- /dev/null +++ b/src-tauri/gen/apple/ExportOptions.plist @@ -0,0 +1,8 @@ + + + + + method + debugging + + diff --git a/src-tauri/gen/apple/LaunchScreen.storyboard b/src-tauri/gen/apple/LaunchScreen.storyboard new file mode 100644 index 0000000..81b5f90 --- /dev/null +++ b/src-tauri/gen/apple/LaunchScreen.storyboard @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/apple/Podfile b/src-tauri/gen/apple/Podfile new file mode 100644 index 0000000..f7f066d --- /dev/null +++ b/src-tauri/gen/apple/Podfile @@ -0,0 +1,21 @@ +# Uncomment the next line to define a global platform for your project + +target 'margin-calendar_iOS' do +platform :ios, '14.0' + # Pods for margin-calendar_iOS +end + +target 'margin-calendar_macOS' do +platform :osx, '11.0' + # Pods for margin-calendar_macOS +end + +# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' + config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' + end + end +end diff --git a/src-tauri/gen/apple/Sources/margin-calendar/bindings/bindings.h b/src-tauri/gen/apple/Sources/margin-calendar/bindings/bindings.h new file mode 100644 index 0000000..5152200 --- /dev/null +++ b/src-tauri/gen/apple/Sources/margin-calendar/bindings/bindings.h @@ -0,0 +1,8 @@ +#pragma once + +namespace ffi { + extern "C" { + void start_app(); + } +} + diff --git a/src-tauri/gen/apple/Sources/margin-calendar/main.mm b/src-tauri/gen/apple/Sources/margin-calendar/main.mm new file mode 100644 index 0000000..7793a9d --- /dev/null +++ b/src-tauri/gen/apple/Sources/margin-calendar/main.mm @@ -0,0 +1,6 @@ +#include "bindings/bindings.h" + +int main(int argc, char * argv[]) { + ffi::start_app(); + return 0; +} diff --git a/src-tauri/gen/apple/margin-calendar.xcodeproj/project.pbxproj b/src-tauri/gen/apple/margin-calendar.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9bc76d1 --- /dev/null +++ b/src-tauri/gen/apple/margin-calendar.xcodeproj/project.pbxproj @@ -0,0 +1,518 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 027A8ABDBEF8BBD53B7567E0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE35A9DF9BD78C3583E7FC5A /* CoreGraphics.framework */; }; + 301C79BAC9680139BDA2B1B9 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481146F91F640D48193D38B /* Metal.framework */; }; + 6EBA510B4387F78D53CD3E07 /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = AE625022A492F5C76B797AC2 /* libapp.a */; }; + 742DD5E8169EE4583B151E73 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6DA270EEE52796CFD8EC281A /* Security.framework */; }; + 7E481312148309B8AA94B95C /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = 65E80ECEB0844651FB03158B /* main.mm */; }; + 89071E2C472ACC0F43105CEA /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C9E2CB1CFED1CED2BECCBB94 /* QuartzCore.framework */; }; + 9191DAC879BEE67BC864E01D /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C57E62E8D422566DF7D919FD /* WebKit.framework */; }; + 92277B81E1BC594DEDA75316 /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 245C8245C8B384E2FF25C48B /* MetalKit.framework */; }; + CC28802C3A0B94C1DDA4C5C4 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 8879346700D62B7DDE676FB9 /* assets */; }; + D3611CD2631DCF0504874997 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 42AC5D9341EC41579150A559 /* LaunchScreen.storyboard */; }; + ECFE37538824122085E847BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 463516468F6D0DFDE911E397 /* Assets.xcassets */; }; + F93C0964D01061FFB2C16E2B /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4B92AAC82F707F343DF3F9F /* UIKit.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 00DFE6DD99BE0C3AEAB97944 /* recur.rs */ = {isa = PBXFileReference; path = recur.rs; sourceTree = ""; }; + 0481146F91F640D48193D38B /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; + 04B2978EA3C716D6EB74712D /* transport.rs */ = {isa = PBXFileReference; path = transport.rs; sourceTree = ""; }; + 06C647F836A02A8532C617DA /* read.rs */ = {isa = PBXFileReference; path = read.rs; sourceTree = ""; }; + 11EB0C96B4964DD8EB696D63 /* api.rs */ = {isa = PBXFileReference; path = api.rs; sourceTree = ""; }; + 1578DA0D4DFD83FCFC7CD967 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = ""; }; + 19E9713B598286F9A58F6524 /* auth.rs */ = {isa = PBXFileReference; path = auth.rs; sourceTree = ""; }; + 245C8245C8B384E2FF25C48B /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; + 42975F72473A262C8DC12B2D /* model.rs */ = {isa = PBXFileReference; path = model.rs; sourceTree = ""; }; + 42AC5D9341EC41579150A559 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 463516468F6D0DFDE911E397 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 482B1BACC9A878BD6000F332 /* tests.rs */ = {isa = PBXFileReference; path = tests.rs; sourceTree = ""; }; + 5F2BD57CDF2418F2114AC00F /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 601DA7E7CE0314ED76601AB8 /* library.rs */ = {isa = PBXFileReference; path = library.rs; sourceTree = ""; }; + 63440E6A8AA7263282FF0880 /* schema.rs */ = {isa = PBXFileReference; path = schema.rs; sourceTree = ""; }; + 65E80ECEB0844651FB03158B /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; + 6DA270EEE52796CFD8EC281A /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 75B8603218CF087545CA23FE /* margin-calendar_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "margin-calendar_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7C24690CAAB5FC9E1015052A /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = ""; }; + 7DF7225718DEED80CC685D70 /* secrets.rs */ = {isa = PBXFileReference; path = secrets.rs; sourceTree = ""; }; + 83A2962170C125E12A298599 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 8879346700D62B7DDE676FB9 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; }; + 9FDB21551CF4959451A03485 /* pull.rs */ = {isa = PBXFileReference; path = pull.rs; sourceTree = ""; }; + A19747E3FB043C2FC441866B /* margin-calendar_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "margin-calendar_iOS.entitlements"; sourceTree = ""; }; + A2B433E2EAB81F688739BD72 /* push.rs */ = {isa = PBXFileReference; path = push.rs; sourceTree = ""; }; + A4B92AAC82F707F343DF3F9F /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + AE625022A492F5C76B797AC2 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; + AF43A8B9F86EC0EE625D5AC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + BB370CF51946E1086F725448 /* dto.rs */ = {isa = PBXFileReference; path = dto.rs; sourceTree = ""; }; + C2072E3C608C4C15356BFD25 /* sync.rs */ = {isa = PBXFileReference; path = sync.rs; sourceTree = ""; }; + C57E62E8D422566DF7D919FD /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + C9E2CB1CFED1CED2BECCBB94 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + D903EBABA83CD7E1EFE1DAF5 /* write.rs */ = {isa = PBXFileReference; path = write.rs; sourceTree = ""; }; + DE35A9DF9BD78C3583E7FC5A /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + E8E713A134B354F21DEF913B /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 763B09C9EE617B71EF7F11D4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6EBA510B4387F78D53CD3E07 /* libapp.a in Frameworks */, + 027A8ABDBEF8BBD53B7567E0 /* CoreGraphics.framework in Frameworks */, + 301C79BAC9680139BDA2B1B9 /* Metal.framework in Frameworks */, + 92277B81E1BC594DEDA75316 /* MetalKit.framework in Frameworks */, + 89071E2C472ACC0F43105CEA /* QuartzCore.framework in Frameworks */, + 742DD5E8169EE4583B151E73 /* Security.framework in Frameworks */, + F93C0964D01061FFB2C16E2B /* UIKit.framework in Frameworks */, + 9191DAC879BEE67BC864E01D /* WebKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 08BDFB2F8332AB5AE4C89430 /* store */ = { + isa = PBXGroup; + children = ( + 83A2962170C125E12A298599 /* mod.rs */, + 06C647F836A02A8532C617DA /* read.rs */, + 63440E6A8AA7263282FF0880 /* schema.rs */, + D903EBABA83CD7E1EFE1DAF5 /* write.rs */, + ); + path = store; + sourceTree = ""; + }; + 1DFBF6D830071E5D1FE33720 /* margin-calendar_iOS */ = { + isa = PBXGroup; + children = ( + AF43A8B9F86EC0EE625D5AC0 /* Info.plist */, + A19747E3FB043C2FC441866B /* margin-calendar_iOS.entitlements */, + ); + path = "margin-calendar_iOS"; + sourceTree = ""; + }; + 2717CB47409E376228915D92 = { + isa = PBXGroup; + children = ( + 8879346700D62B7DDE676FB9 /* assets */, + 463516468F6D0DFDE911E397 /* Assets.xcassets */, + 42AC5D9341EC41579150A559 /* LaunchScreen.storyboard */, + 701F7017030A43C5B1F02E43 /* Externals */, + 1DFBF6D830071E5D1FE33720 /* margin-calendar_iOS */, + F2D7B8DA6405BBC1A306E0F5 /* Sources */, + C66369FCC7BD73AD6D6B329D /* src */, + 7B0CEBED082C2704D1AE2349 /* Frameworks */, + FD095DBA118C3E02B31F8572 /* Products */, + ); + sourceTree = ""; + }; + 31E412A61C3C36BBEA22CAB4 /* sync */ = { + isa = PBXGroup; + children = ( + 42975F72473A262C8DC12B2D /* model.rs */, + 9FDB21551CF4959451A03485 /* pull.rs */, + A2B433E2EAB81F688739BD72 /* push.rs */, + 482B1BACC9A878BD6000F332 /* tests.rs */, + 04B2978EA3C716D6EB74712D /* transport.rs */, + ); + path = sync; + sourceTree = ""; + }; + 701F7017030A43C5B1F02E43 /* Externals */ = { + isa = PBXGroup; + children = ( + ); + path = Externals; + sourceTree = ""; + }; + 7B0CEBED082C2704D1AE2349 /* Frameworks */ = { + isa = PBXGroup; + children = ( + DE35A9DF9BD78C3583E7FC5A /* CoreGraphics.framework */, + AE625022A492F5C76B797AC2 /* libapp.a */, + 0481146F91F640D48193D38B /* Metal.framework */, + 245C8245C8B384E2FF25C48B /* MetalKit.framework */, + C9E2CB1CFED1CED2BECCBB94 /* QuartzCore.framework */, + 6DA270EEE52796CFD8EC281A /* Security.framework */, + A4B92AAC82F707F343DF3F9F /* UIKit.framework */, + C57E62E8D422566DF7D919FD /* WebKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 819B89450E4EF4F878332BE3 /* bindings */ = { + isa = PBXGroup; + children = ( + 1578DA0D4DFD83FCFC7CD967 /* bindings.h */, + ); + path = bindings; + sourceTree = ""; + }; + 8465EC9E3B20D359B37ED5CC /* google */ = { + isa = PBXGroup; + children = ( + 11EB0C96B4964DD8EB696D63 /* api.rs */, + 19E9713B598286F9A58F6524 /* auth.rs */, + 5F2BD57CDF2418F2114AC00F /* mod.rs */, + 7DF7225718DEED80CC685D70 /* secrets.rs */, + ); + path = google; + sourceTree = ""; + }; + C66369FCC7BD73AD6D6B329D /* src */ = { + isa = PBXGroup; + children = ( + BB370CF51946E1086F725448 /* dto.rs */, + E8E713A134B354F21DEF913B /* lib.rs */, + 601DA7E7CE0314ED76601AB8 /* library.rs */, + 7C24690CAAB5FC9E1015052A /* main.rs */, + 00DFE6DD99BE0C3AEAB97944 /* recur.rs */, + C2072E3C608C4C15356BFD25 /* sync.rs */, + 8465EC9E3B20D359B37ED5CC /* google */, + 08BDFB2F8332AB5AE4C89430 /* store */, + 31E412A61C3C36BBEA22CAB4 /* sync */, + ); + name = src; + path = ../../src; + sourceTree = ""; + }; + C7CA186F2513338D60A6B00E /* margin-calendar */ = { + isa = PBXGroup; + children = ( + 65E80ECEB0844651FB03158B /* main.mm */, + 819B89450E4EF4F878332BE3 /* bindings */, + ); + path = "margin-calendar"; + sourceTree = ""; + }; + F2D7B8DA6405BBC1A306E0F5 /* Sources */ = { + isa = PBXGroup; + children = ( + C7CA186F2513338D60A6B00E /* margin-calendar */, + ); + path = Sources; + sourceTree = ""; + }; + FD095DBA118C3E02B31F8572 /* Products */ = { + isa = PBXGroup; + children = ( + 75B8603218CF087545CA23FE /* margin-calendar_iOS.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 44343353A6DEDC69656FAA54 /* margin-calendar_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = E8ACAA0021FD841CDD504F34 /* Build configuration list for PBXNativeTarget "margin-calendar_iOS" */; + buildPhases = ( + D2BB0B9B44B3A2E1C386BFAD /* Build Rust Code */, + DEF597EC6F9C166B9E68C45C /* Sources */, + 066AEAE8FD82D64732953ED0 /* Resources */, + 763B09C9EE617B71EF7F11D4 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "margin-calendar_iOS"; + packageProductDependencies = ( + ); + productName = "margin-calendar_iOS"; + productReference = 75B8603218CF087545CA23FE /* margin-calendar_iOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + FBBBFD542B155C58C96B76B5 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = 94C3379210AAB44F71F32C5E /* Build configuration list for PBXProject "margin-calendar" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 2717CB47409E376228915D92; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = FD095DBA118C3E02B31F8572 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 44343353A6DEDC69656FAA54 /* margin-calendar_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 066AEAE8FD82D64732953ED0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ECFE37538824122085E847BB /* Assets.xcassets in Resources */, + D3611CD2631DCF0504874997 /* LaunchScreen.storyboard in Resources */, + CC28802C3A0B94C1DDA4C5C4 /* assets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + D2BB0B9B44B3A2E1C386BFAD /* Build Rust Code */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust Code"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DEF597EC6F9C166B9E68C45C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E481312148309B8AA94B95C /* main.mm in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 68D6BB2FB85DAC1C8BAB279F /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = debug; + }; + 7E114EA455562314CA5392E8 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = release; + }; + C7BFE7ABB61BEFEA5FB5615D /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "margin-calendar_iOS/margin-calendar_iOS.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = "margin-calendar_iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = studio.margin.calendar; + PRODUCT_NAME = "Margin Calendar"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = debug; + }; + ED0652ADBD2F428F7C930D66 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "margin-calendar_iOS/margin-calendar_iOS.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = "margin-calendar_iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = studio.margin.calendar; + PRODUCT_NAME = "Margin Calendar"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 94C3379210AAB44F71F32C5E /* Build configuration list for PBXProject "margin-calendar" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 68D6BB2FB85DAC1C8BAB279F /* debug */, + 7E114EA455562314CA5392E8 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; + E8ACAA0021FD841CDD504F34 /* Build configuration list for PBXNativeTarget "margin-calendar_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C7BFE7ABB61BEFEA5FB5615D /* debug */, + ED0652ADBD2F428F7C930D66 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = FBBBFD542B155C58C96B76B5 /* Project object */; +} diff --git a/src-tauri/gen/apple/margin-calendar.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/src-tauri/gen/apple/margin-calendar.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/src-tauri/gen/apple/margin-calendar.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/src-tauri/gen/apple/margin-calendar.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/src-tauri/gen/apple/margin-calendar.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..ac90d5a --- /dev/null +++ b/src-tauri/gen/apple/margin-calendar.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,10 @@ + + + + + BuildSystemType + Original + DisableBuildSystemDeprecationDiagnostic + + + diff --git a/src-tauri/gen/apple/margin-calendar.xcodeproj/xcshareddata/xcschemes/margin-calendar_iOS.xcscheme b/src-tauri/gen/apple/margin-calendar.xcodeproj/xcshareddata/xcschemes/margin-calendar_iOS.xcscheme new file mode 100644 index 0000000..c027c60 --- /dev/null +++ b/src-tauri/gen/apple/margin-calendar.xcodeproj/xcshareddata/xcschemes/margin-calendar_iOS.xcscheme @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/apple/margin-calendar_iOS/Info.plist b/src-tauri/gen/apple/margin-calendar_iOS/Info.plist new file mode 100644 index 0000000..67848fa --- /dev/null +++ b/src-tauri/gen/apple/margin-calendar_iOS/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + metal + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CFBundleURLTypes + + + CFBundleURLSchemes + + studio.margin.calendar + + CFBundleURLName + studio.margin.calendar + + + + \ No newline at end of file diff --git a/src-tauri/gen/apple/margin-calendar_iOS/margin-calendar_iOS.entitlements b/src-tauri/gen/apple/margin-calendar_iOS/margin-calendar_iOS.entitlements new file mode 100644 index 0000000..21e2f8d --- /dev/null +++ b/src-tauri/gen/apple/margin-calendar_iOS/margin-calendar_iOS.entitlements @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/gen/apple/project.yml b/src-tauri/gen/apple/project.yml new file mode 100644 index 0000000..764b1d5 --- /dev/null +++ b/src-tauri/gen/apple/project.yml @@ -0,0 +1,88 @@ +name: margin-calendar +options: + bundleIdPrefix: studio.margin.calendar + deploymentTarget: + iOS: 14.0 +fileGroups: [../../src] +configs: + debug: debug + release: release +settingGroups: + app: + base: + PRODUCT_NAME: Margin Calendar + PRODUCT_BUNDLE_IDENTIFIER: studio.margin.calendar +targetTemplates: + app: + type: application + sources: + - path: Sources + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + groups: [app] +targets: + margin-calendar_iOS: + type: application + platform: iOS + sources: + - path: Sources + - path: Assets.xcassets + - path: Externals + - path: margin-calendar_iOS + - path: assets + buildPhase: resources + type: folder + - path: LaunchScreen.storyboard + info: + path: margin-calendar_iOS/Info.plist + properties: + LSRequiresIPhoneOS: true + UILaunchStoryboardName: LaunchScreen + UIRequiredDeviceCapabilities: [arm64, metal] + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + CFBundleShortVersionString: 0.1.0 + CFBundleVersion: "0.1.0" + entitlements: + path: margin-calendar_iOS/margin-calendar_iOS.entitlements + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + base: + ENABLE_BITCODE: false + ARCHS: [arm64] + VALID_ARCHS: arm64 + LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME) + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 + groups: [app] + dependencies: + - framework: libapp.a + embed: false + - sdk: CoreGraphics.framework + - sdk: Metal.framework + - sdk: MetalKit.framework + - sdk: QuartzCore.framework + - sdk: Security.framework + - sdk: UIKit.framework + - sdk: WebKit.framework + preBuildScripts: + - script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + name: Build Rust Code + basedOnDependencyAnalysis: false + outputFiles: + - $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a + - $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a \ No newline at end of file diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..524fcb6 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..d2add17 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..69b0d10 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..3090309 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..d979342 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..5ff81f0 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..8a28e37 Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..1a0d3d1 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..3df5413 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..09e4f2a Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..cbaf50a Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..e52c529 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..c62d237 Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..616dfa0 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..5aa8d86 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..508787d Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..7e3449f Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/dto.rs b/src-tauri/src/dto.rs new file mode 100644 index 0000000..ea3590d --- /dev/null +++ b/src-tauri/src/dto.rs @@ -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, + 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, + /// 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, + pub label: Option, +} + +/// 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, +} + +/// 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, + pub start: String, + pub end: String, + pub start_ms: i64, + pub end_ms: i64, + pub all_day: bool, + pub summary: String, + pub description: Option, + pub location: Option, + /// 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, + pub etag: Option, + pub organizer: Option, + pub attendees: Vec, + pub conference: Option, + 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, + pub location: Option, + 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, + /// Google's per-event colour id, 1 to 11. Absent means follow the calendar. + #[serde(default)] + pub color_id: Option, +} + +/// 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, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub location: Option, + #[serde(default)] + pub start: Option, + #[serde(default)] + pub end: Option, + #[serde(default)] + pub all_day: Option, + #[serde(default)] + pub calendar_id: Option, + #[serde(default)] + pub recurrence: Option>, + /// An empty string clears it back to the calendar's colour. + #[serde(default)] + pub color_id: Option, +} + +#[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, + pub error: Option, + pub pending_writes: u32, + pub message: Option, +} + +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, + pub account_id: Option, + pub email: Option, +} diff --git a/src-tauri/src/google/api.rs b/src-tauri/src/google/api.rs new file mode 100644 index 0000000..6a27e8a --- /dev/null +++ b/src-tauri/src/google/api.rs @@ -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 +// +// 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, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub location: Option, + #[serde(default)] + pub start: Option, + #[serde(default)] + pub end: Option, + #[serde(default)] + pub recurrence: Option>, + #[serde(default)] + pub recurring_event_id: Option, + #[serde(default)] + pub original_start_time: Option, + #[serde(default)] + pub organizer: Option, + #[serde(default)] + pub attendees: Option, + #[serde(default)] + pub conference_data: Option, + #[serde(default)] + pub updated: Option, + #[serde(default)] + pub transparency: Option, + #[serde(default)] + pub color_id: Option, +} + +#[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, + /// RFC3339 with an offset. + #[serde(default)] + pub date_time: Option, + #[serde(default)] + pub time_zone: Option, +} + +/// 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, + #[serde(default)] + pub next_page_token: Option, + #[serde(default)] + pub next_sync_token: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawCalendar { + pub id: String, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub background_color: Option, + #[serde(default)] + pub access_role: Option, + #[serde(default)] + pub time_zone: Option, + #[serde(default)] + pub primary: Option, + #[serde(default)] + pub selected: Option, + #[serde(default)] + pub deleted: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CalendarListPage { + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub next_page_token: Option, + #[serde(default)] + pub next_sync_token: Option, +} + +/// One page of `events.instances`. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InstancesPage { + #[serde(default)] + items: Vec, +} + +/// 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 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 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::(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(resp: reqwest::Response, context: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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, 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("a.person@example.com"), + "a.person%40example.com" + ); + assert_eq!( + path_segment("en.uk#holiday@group.v.calendar.google.com"), + "en.uk%23holiday%40group.v.calendar.google.com" + ); + assert_eq!(path_segment("primary"), "primary"); + } +} diff --git a/src-tauri/src/google/auth.rs b/src-tauri/src/google/auth.rs new file mode 100644 index 0000000..c12a441 --- /dev/null +++ b/src-tauri/src/google/auth.rs @@ -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 = 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, + #[serde(default)] + ios: Option, +} + +#[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, + #[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, +} + +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 { + 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( + resp: reqwest::Response, + context: &str, +) -> Result { + 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, + pub access_expiry: u64, + pub email: Option, +} + +/// 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>, + #[cfg(mobile)] + pub pending: Mutex>, +} + +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!( + "Margin Calendar\ + \ +

{message}

" + ); + 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 { + 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, + #[serde(default)] + expires_in: u64, + #[serde(default)] + id_token: Option, +} + +#[derive(Deserialize)] +struct UserInfo { + #[serde(default)] + email: Option, +} + +#[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, + #[serde(default)] + email: Option, +} + +/// 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 { + 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 { + 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 { + 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 { + 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( + app: &tauri::AppHandle, + f: impl FnOnce(&rusqlite::Connection) -> Result, +) -> Result { + let store = app + .try_state::() + .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, 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 { + 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 { + 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::(); + 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::(); + 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::(); + 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 { + 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::(); + 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":"a@b.test","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("a@b.test")); + } + + #[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()); + } +} diff --git a/src-tauri/src/google/mod.rs b/src-tauri/src/google/mod.rs new file mode 100644 index 0000000..ae19ad8 --- /dev/null +++ b/src-tauri/src/google/mod.rs @@ -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, 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 { + 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 +} diff --git a/src-tauri/src/google/secrets.rs b/src-tauri/src/google/secrets.rs new file mode 100644 index 0000000..6de1724 --- /dev/null +++ b/src-tauri/src/google/secrets.rs @@ -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 = 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, 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 { + 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, 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) -> 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 { + 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, 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"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..76922a6 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -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(handle: &tauri::AppHandle) -> tauri::Result> { + 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 = + unsafe { objc2::msg_send![webview.inner().cast::(), 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"); +} diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs new file mode 100644 index 0000000..4a41745 --- /dev/null +++ b/src-tauri/src/library.rs @@ -0,0 +1,9 @@ +use std::fs; +use std::path::PathBuf; +use tauri::Manager; + +pub fn app_data_dir(app: &tauri::AppHandle) -> Result { + 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) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..64ac5a0 --- /dev/null +++ b/src-tauri/src/main.rs @@ -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() +} diff --git a/src-tauri/src/recur.rs b/src-tauri/src/recur.rs new file mode 100644 index 0000000..2e7f8aa --- /dev/null +++ b/src-tauri/src/recur.rs @@ -0,0 +1,1898 @@ +// Owns expansion, exception merging and the three edit scopes. +// +// Expansion takes a window and the masters overlapping it, runs `rrule` to generate occurrences, +// drops any occurrence whose original start matches an exception, then merges the exception +// events back in at their moved times. +// +// A cancelled instance is only guaranteed to carry id, recurringEventId, originalStartTime and +// status, so expansion must key on (recurring_event_id, original_start) and must never depend on +// another field being populated. +// +// All-day events are date-only and must never be shifted into a timezone. DST makes a local day +// 23 or 25 hours; the grid is a wall-clock axis, so positions come from local wall-clock time and +// an event spanning a transition renders at a height that does not match its true duration. That +// is correct for this kind of grid. + +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Duration, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, SecondsFormat, + TimeZone}; +use rrule::{RRuleSet, Tz}; + +use crate::dto::{Attendee, Conference, EventDraft, EventPatch, Instance, InstanceKey, Scope}; +use crate::store::write::{epoch_ms, EventRow}; + +/// Per series, per window. The widest view is a month, so this only bites a rule no human wrote. +const MAX_OCCURRENCES: u16 = 10_000; + +/// The seam between recurrence and the write path. The recurrence agent decides what a scoped +/// edit costs in API calls; the sync agent performs them and enqueues them in the outbox. +#[derive(Debug, Clone)] +pub enum EditPlan { + /// `This`. The concrete instance is resolved through `events.instances` with `originalStart` + /// rather than by constructing the instance id by hand. + PatchInstance { + calendar_id: String, + event_id: String, + original_start: Option, + patch: EventPatch, + }, + /// `All`. + PatchMaster { + calendar_id: String, + event_id: String, + patch: EventPatch, + }, + /// `Following`. The API has no native split, so truncate the master's RRULE with an UNTIL at + /// the split point and create a new master from there. + /// + /// Two calls, in this order: patch `event_id` with `truncate_recurrence(master.recurrence, + /// until)`, then insert `draft`. `EventDraft` carries an offset but no zone, so the insert has + /// to send the master's own `start_tz`/`end_tz` as `start.timeZone`/`end.timeZone`. Without + /// them Google files the new series under the calendar's zone and the tail drifts an hour away + /// from the head at the next DST transition. + Split { + calendar_id: String, + event_id: String, + until: String, + draft: EventDraft, + }, + CancelInstance { + calendar_id: String, + event_id: String, + original_start: Option, + }, + TruncateMaster { + calendar_id: String, + event_id: String, + until: String, + }, + DeleteMaster { + calendar_id: String, + event_id: String, + }, +} + +/// What a calendar contributes to every instance on it. None of it lives on `EventRow` and all of +/// it is on `Instance`, so `instances_range` joins the calendars in and hands this down. +#[derive(Debug, Clone, Default)] +pub struct CalendarFacts { + pub color_hex: String, + pub time_zone: String, + pub read_only: bool, +} + +impl CalendarFacts { + pub fn from_calendar(calendar: &crate::dto::Calendar) -> CalendarFacts { + CalendarFacts { + color_hex: calendar.color_hex.clone(), + time_zone: calendar.time_zone.clone(), + read_only: !matches!(calendar.access_role.as_str(), "writer" | "owner"), + } + } +} + +/// Google's per-event palette, by `colorId`. These are Google's own hex values, so a colour set +/// here shows the same in both apps; the eight-hue mapping in the UI is what keeps the grid calm. +const EVENT_COLORS: [(&str, &str); 11] = [ + ("1", "#7986cb"), + ("2", "#33b679"), + ("3", "#8e24aa"), + ("4", "#e67c73"), + ("5", "#f6bf26"), + ("6", "#f4511e"), + ("7", "#039be5"), + ("8", "#616161"), + ("9", "#3f51b5"), + ("10", "#0b8043"), + ("11", "#d50000"), +]; + +pub fn event_color(color_id: Option<&str>) -> Option<&'static str> { + let id = color_id?.trim(); + EVENT_COLORS + .iter() + .find(|(key, _)| *key == id) + .map(|(_, hex)| *hex) +} + +/// Expand `rows` into concrete occurrences overlapping `[from_ms, to_ms)`, in the local zone. +/// +/// `instances_range` goes through `expand_in` instead, so it can pass the calendar facts and a +/// pinned zone down. This stays as the plain entry point, which is what the tests drive. +#[allow(dead_code)] +pub fn expand(rows: &[EventRow], from_ms: i64, to_ms: i64) -> Result, String> { + expand_in(rows, from_ms, to_ms, Tz::LOCAL, &HashMap::new()) +} + +/// The body of `expand` with the machine's own zone made an argument. A test that cannot pin the +/// local zone cannot prove an all-day event does not drift, and `TZ` is process-global while tests +/// are not. +fn expand_in( + rows: &[EventRow], + from_ms: i64, + to_ms: i64, + local: Tz, + calendars: &HashMap, +) -> Result, String> { + // Keyed on the instant, never on the string: two spellings of one moment have to collide, and a + // cancelled row carries nothing else to key on. + let mut overridden: HashSet<(&str, &str, i64)> = HashSet::new(); + for row in rows { + if let (Some(master), Some(original)) = (&row.recurring_event_id, &row.original_start) { + if let Some(at) = epoch_ms(original, false) { + overridden.insert((row.calendar_id.as_str(), master.as_str(), at)); + } + } + } + + let mut out: Vec = Vec::new(); + for row in rows { + if row.status == "cancelled" { + continue; + } + let facts = calendars.get(&row.calendar_id).cloned().unwrap_or_default(); + + // An exception, at whatever time it was dragged to. Its own row carries the times, so this + // path never touches the series' zone. + if row.recurring_event_id.is_some() { + if let Some(spot) = placement(row) { + push(&mut out, row, &row.id, &facts, true, row.original_start.clone(), spot, + local, from_ms, to_ms); + } + continue; + } + + if row.recurrence.is_empty() { + if let Some(spot) = placement(row) { + push(&mut out, row, &row.id, &facts, false, None, spot, local, from_ms, to_ms); + } + continue; + } + + let zone = series_zone(row, &facts, local); + // A rule the crate will not read costs one series. Returning an error here would cost the + // whole window, and a blank month is a worse answer than a missing series. + let Ok(series) = build_series(row, zone) else { continue }; + for at in occurrences(&series, from_ms, to_ms) { + let (spot, original, key) = spot_of(&series, at); + if overridden.contains(&(row.calendar_id.as_str(), row.id.as_str(), key)) { + continue; + } + push(&mut out, row, &row.id, &facts, true, Some(original), spot, local, from_ms, to_ms); + } + } + + out.sort_by(|a, b| { + a.start_ms + .cmp(&b.start_ms) + .then_with(|| a.event_id.cmp(&b.event_id)) + .then_with(|| a.original_start.cmp(&b.original_start)) + }); + Ok(out) +} + +#[allow(clippy::too_many_arguments)] +fn push( + out: &mut Vec, + row: &EventRow, + event_id: &str, + facts: &CalendarFacts, + recurring: bool, + original_start: Option, + spot: Spot, + local: Tz, + from_ms: i64, + to_ms: i64, +) { + let (start_ms, end_ms) = spot.millis(local); + if !overlaps(start_ms, end_ms, from_ms, to_ms) { + return; + } + let (start, end) = spot.strings(local); + out.push(Instance { + event_id: event_id.to_string(), + calendar_id: row.calendar_id.clone(), + account_id: row.account_id.clone(), + original_start, + start, + end, + start_ms, + end_ms, + all_day: matches!(spot, Spot::Day { .. }), + summary: row.summary.clone(), + description: row.description.clone(), + location: row.location.clone(), + status: row.status.clone(), + recurring, + // The event's own colour wins over its calendar's, which is what Google does. + color_hex: event_color(row.color_id.as_deref()) + .unwrap_or(&facts.color_hex) + .to_string(), + color_id: row.color_id.clone(), + etag: row.etag.clone(), + organizer: organizer(row.attendees.as_deref()), + attendees: attendees(row.attendees.as_deref()), + conference: conference(row.conference.as_deref()), + read_only: facts.read_only, + pending: row.dirty, + }); +} + +/// Half-open at both ends, with a zero-length event kept when it sits on the opening bound. +fn overlaps(start_ms: i64, end_ms: i64, from_ms: i64, to_ms: i64) -> bool { + start_ms < to_ms && (end_ms > from_ms || (end_ms <= start_ms && start_ms >= from_ms)) +} + +// -- placement --------------------------------------------------------------------------------- + +/// Where something sits on the axis. `Day` is date-only and never carries a zone; its `end` is +/// exclusive, exactly as Google sends it. +#[derive(Debug, Clone, Copy)] +enum Spot { + Day { start: NaiveDate, end: NaiveDate }, + Timed { start: DateTime, end: DateTime }, +} + +impl Spot { + /// All-day events pin to local midnight, which is the one place a date is allowed to meet a + /// zone, and only so the grid has a number to lay it out against. + fn millis(&self, local: Tz) -> (i64, i64) { + match self { + Spot::Day { start, end } => ( + resolve(local, start.and_time(NaiveTime::MIN)).timestamp_millis(), + resolve(local, end.and_time(NaiveTime::MIN)).timestamp_millis(), + ), + Spot::Timed { start, end } => (start.timestamp_millis(), end.timestamp_millis()), + } + } + + fn strings(&self, local: Tz) -> (String, String) { + match self { + Spot::Day { start, end } => ( + start.format("%Y-%m-%d").to_string(), + end.format("%Y-%m-%d").to_string(), + ), + Spot::Timed { start, end } => (rfc3339(*start, local), rfc3339(*end, local)), + } + } +} + +fn rfc3339(at: DateTime, zone: Tz) -> String { + at.with_timezone(&zone) + .to_rfc3339_opts(SecondsFormat::Secs, false) +} + +/// A row's own times, whatever produced the row. A moved exception and a one-off are both read +/// straight off the row, so neither needs a recurrence rule or a series zone to be placed. +fn placement(row: &EventRow) -> Option { + let start_at = row.start_at.trim(); + if start_at.is_empty() { + return None; + } + if row.all_day || (start_at.len() == 10 && !start_at.contains('T')) { + let start = NaiveDate::parse_from_str(start_at, "%Y-%m-%d").ok()?; + let end = NaiveDate::parse_from_str(row.end_at.trim(), "%Y-%m-%d") + .unwrap_or(start + Duration::days(1)); + let end = if end <= start { start + Duration::days(1) } else { end }; + return Some(Spot::Day { start, end }); + } + let start = instant(start_at)?; + let end = instant(row.end_at.trim()).unwrap_or(start); + Some(Spot::Timed { + start, + end: if end < start { start } else { end }, + }) +} + +fn instant(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value.trim()) + .ok() + .map(|at| at.with_timezone(&Tz::UTC)) +} + +/// A wall clock into an instant. The repeated hour takes the earlier reading; the skipped hour +/// lands on the transition itself, because a series must not lose an occurrence to a clock change. +fn resolve(zone: Tz, naive: NaiveDateTime) -> DateTime { + match zone.from_local_datetime(&naive) { + LocalResult::Single(at) => at, + LocalResult::Ambiguous(first, _) => first, + LocalResult::None => match zone.from_local_datetime(&(naive + Duration::hours(1))) { + LocalResult::Single(at) | LocalResult::Ambiguous(at, _) => at - Duration::hours(1), + LocalResult::None => Tz::UTC.from_utc_datetime(&naive).with_timezone(&zone), + }, + } +} + +fn zone_named(name: Option<&str>, fallback: Tz) -> Tz { + match name.map(str::trim) { + Some(name) if !name.is_empty() => name + .parse::() + .map(Tz::Tz) + .unwrap_or(fallback), + _ => fallback, + } +} + +/// The series' own zone, then the calendar's, then the machine's. An all-day series has no zone at +/// all: expanding it in UTC keeps every occurrence on the date the rule names and keeps DST out of +/// an arithmetic that has no business seeing it. +fn series_zone(row: &EventRow, facts: &CalendarFacts, local: Tz) -> Tz { + if row.all_day || (row.start_at.trim().len() == 10 && !row.start_at.contains('T')) { + return Tz::UTC; + } + zone_named( + row.start_tz.as_deref(), + zone_named(Some(facts.time_zone.as_str()), local), + ) +} + +/// The zone a plan is made in, where there is no calendar to fall back through. +fn row_zone(row: &EventRow) -> Tz { + series_zone(row, &CalendarFacts::default(), Tz::LOCAL) +} + +// -- expansion --------------------------------------------------------------------------------- + +struct Series { + zone: Tz, + all_day: bool, + /// Start to end as wall clock, applied to every occurrence's start in wall-clock terms. + span: Duration, + set: RRuleSet, +} + +fn build_series(row: &EventRow, zone: Tz) -> Result { + let spot = placement(row).ok_or_else(|| format!("{}: no usable start", row.id))?; + let (start, span, all_day) = match spot { + Spot::Day { start, end } => ( + Tz::UTC.from_utc_datetime(&start.and_time(NaiveTime::MIN)), + Duration::days((end - start).num_days().max(1)), + true, + ), + Spot::Timed { start, end } => { + let start = start.with_timezone(&zone); + let end = end.with_timezone(&zone); + let span = end.naive_local().signed_duration_since(start.naive_local()); + (start, span, false) + } + }; + // DTSTART is built, not parsed: a series that starts inside a DST gap or a repeated hour is a + // parse error to the crate, and we already know the exact instant it starts at. + let set = RRuleSet::new(start) + .set_from_string(&normalise(&row.recurrence, zone, all_day)?) + .map_err(|e| format!("{}: {e}", row.id))?; + Ok(Series { + zone, + all_day, + span, + set, + }) +} + +/// Bounds are padded by the event's own span at the front, so an occurrence that starts before the +/// window but runs into it survives, and by a day either side because an all-day occurrence sits at +/// UTC midnight here and at local midnight by the time it is measured. +fn occurrences(series: &Series, from_ms: i64, to_ms: i64) -> Vec> { + let slack = Duration::days(1) + series.span.max(Duration::zero()); + let (Some(lower), Some(upper)) = ( + Tz::UTC.timestamp_millis_opt(from_ms).single(), + Tz::UTC.timestamp_millis_opt(to_ms).single(), + ) else { + return Vec::new(); + }; + series + .set + .clone() + .after(lower - slack) + .before(upper + Duration::days(1)) + .all(MAX_OCCURRENCES) + .dates +} + +/// An occurrence's placement, the string form of its unmoved start, and the instant that start is +/// keyed by when it is matched against an exception. +fn spot_of(series: &Series, at: DateTime) -> (Spot, String, i64) { + if series.all_day { + let start = at.with_timezone(&Tz::UTC).date_naive(); + let end = start + Duration::days(series.span.num_days().max(1)); + let key = Tz::UTC + .from_utc_datetime(&start.and_time(NaiveTime::MIN)) + .timestamp_millis(); + ( + Spot::Day { start, end }, + start.format("%Y-%m-%d").to_string(), + key, + ) + } else { + let start = at.with_timezone(&series.zone); + let end = resolve(series.zone, start.naive_local() + series.span); + ( + Spot::Timed { start, end }, + rfc3339(start, series.zone), + start.timestamp_millis(), + ) + } +} + +// -- rule text --------------------------------------------------------------------------------- + +/// Splits a content line into its property name, its parameters and its value. A line with no +/// colon is an RRULE body, which is what the crate assumes too. +fn split_line(line: &str) -> (String, Option<&str>, &str) { + let Some(colon) = line.find(':') else { + return ("RRULE".to_string(), None, line); + }; + let (head, value) = (&line[..colon], &line[colon + 1..]); + match head.find(';') { + Some(semi) => ( + head[..semi].to_uppercase(), + Some(&head[semi + 1..]), + value, + ), + None => (head.to_uppercase(), None, value), + } +} + +fn param<'a>(params: Option<&'a str>, key: &str) -> Option<&'a str> { + params?.split(';').find_map(|part| { + let (name, value) = part.split_once('=')?; + name.trim().eq_ignore_ascii_case(key).then(|| value.trim()) + }) +} + +/// Rewrites a master's rule lines into the form the crate reads the way RFC5545 means it. +/// +/// Two of its defaults are wrong for this data. A floating UNTIL or EXDATE is read in the machine's +/// zone rather than the series', and once DTSTART carries a named zone an UNTIL in anything but UTC +/// is rejected outright, which is exactly what an all-day series from Google looks like +/// (`UNTIL=20260301`). Everything datelike therefore leaves here as a UTC instant. +fn normalise(lines: &[String], zone: Tz, all_day: bool) -> Result { + let mut out: Vec = Vec::new(); + for raw in lines { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let (name, params, value) = split_line(line); + match name.as_str() { + // Ours wins: it came from the row, where the offset is unambiguous. + "DTSTART" => continue, + "RRULE" | "EXRULE" => out.push(format!("{name}:{}", rewrite_until(value, zone)?)), + "EXDATE" | "RDATE" => { + let value_zone = value_zone(params, zone, all_day); + let mut dates = Vec::new(); + for part in value.split(',') { + if part.trim().is_empty() { + continue; + } + dates.push(ical_utc(part, value_zone)?); + } + if !dates.is_empty() { + out.push(format!("{name}:{}", dates.join(","))); + } + } + _ => out.push(line.to_string()), + } + } + Ok(out.join("\n")) +} + +/// A date-only value on an all-day series is that date whatever TZID rides along with it, and an +/// all-day series expands in UTC, so the parameter is deliberately ignored there. +fn value_zone(params: Option<&str>, zone: Tz, all_day: bool) -> Tz { + if all_day { + zone + } else { + zone_named(param(params, "TZID"), zone) + } +} + +fn rewrite_until(value: &str, zone: Tz) -> Result { + let mut parts: Vec = Vec::new(); + for part in value.split(';') { + let part = part.trim(); + if part.is_empty() { + continue; + } + match part.split_once('=') { + Some((key, raw)) if key.trim().eq_ignore_ascii_case("UNTIL") => { + parts.push(format!("UNTIL={}", ical_utc(raw, zone)?)); + } + _ => parts.push(part.to_string()), + } + } + Ok(parts.join(";")) +} + +fn ical_instant(raw: &str, zone: Tz) -> Result, String> { + let raw = raw.trim(); + let (body, zulu) = match raw.strip_suffix('Z').or_else(|| raw.strip_suffix('z')) { + Some(body) => (body, true), + None => (raw, false), + }; + let naive = if body.len() == 8 { + NaiveDate::parse_from_str(body, "%Y%m%d") + .map_err(|_| format!("not a date: {raw}"))? + .and_time(NaiveTime::MIN) + } else { + NaiveDateTime::parse_from_str(body, "%Y%m%dT%H%M%S") + .map_err(|_| format!("not a date-time: {raw}"))? + }; + Ok(if zulu { + Tz::UTC.from_utc_datetime(&naive) + } else { + resolve(zone, naive) + }) +} + +fn ical_utc(raw: &str, zone: Tz) -> Result { + Ok(ical_instant(raw, zone)? + .with_timezone(&Tz::UTC) + .format("%Y%m%dT%H%M%SZ") + .to_string()) +} + +/// The head of a split: the master's lines with an UNTIL that stops the series before the split. +/// The `until` on `EditPlan::Split` and `EditPlan::TruncateMaster` is a value, not a rule, and this +/// is how it becomes one. +/// +/// COUNT goes, because RFC5545 forbids it alongside UNTIL. An RDATE past the cut goes too, because +/// UNTIL bounds a rule and not a date list, so one left behind would put an occurrence back after +/// the split. An EXDATE past the cut stays: it excludes nothing either way, and leaving it alone is +/// one fewer thing to get wrong. +pub fn truncate_recurrence(recurrence: &[String], until: &str) -> Vec { + let cut = ical_instant(until, Tz::UTC) + .map(|at| at.timestamp_millis()) + .ok(); + let mut out: Vec = Vec::new(); + for raw in recurrence { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let (name, params, value) = split_line(line); + match name.as_str() { + "DTSTART" => continue, + "RRULE" | "EXRULE" => { + let mut parts: Vec = value + .split(';') + .map(str::trim) + .filter(|part| !part.is_empty()) + .filter(|part| !starts_with_key(part, "UNTIL") && !starts_with_key(part, "COUNT")) + .map(str::to_string) + .collect(); + parts.push(format!("UNTIL={until}")); + out.push(format!("{name}:{}", parts.join(";"))); + } + "RDATE" => { + let kept = filter_dates(value, params, Tz::UTC, |at| { + cut.is_none_or(|cut| at <= cut) + }); + if !kept.is_empty() { + out.push(rebuild(&name, params, &kept)); + } + } + _ => out.push(line.to_string()), + } + } + out +} + +fn starts_with_key(part: &str, key: &str) -> bool { + part.split_once('=') + .is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case(key)) +} + +fn filter_dates( + value: &str, + params: Option<&str>, + zone: Tz, + keep: impl Fn(i64) -> bool, +) -> String { + let value_zone = zone_named(param(params, "TZID"), zone); + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .filter(|part| { + ical_instant(part, value_zone) + .map(|at| keep(at.timestamp_millis())) + .unwrap_or(true) + }) + .collect::>() + .join(",") +} + +fn rebuild(name: &str, params: Option<&str>, value: &str) -> String { + match params { + Some(params) => format!("{name};{params}:{value}"), + None => format!("{name}:{value}"), + } +} + +/// The tail of a split: the same rule from the split point on. UNTIL stays, because the tail keeps +/// the original end; COUNT comes down by whatever the head consumed; anything dated before the +/// split goes, because it belongs to the head. +fn tail_recurrence(row: &EventRow, zone: Tz, split_ms: i64) -> Vec { + let counted = row + .recurrence + .iter() + .any(|line| line.to_uppercase().contains("COUNT=")); + let consumed = if counted { count_before(row, zone, split_ms) } else { 0 }; + + let mut out: Vec = Vec::new(); + for raw in &row.recurrence { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let (name, params, value) = split_line(line); + match name.as_str() { + "DTSTART" => continue, + "RRULE" | "EXRULE" => { + let parts: Vec = value + .split(';') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| match part.split_once('=') { + Some((key, total)) if key.trim().eq_ignore_ascii_case("COUNT") => { + let total: usize = total.trim().parse().unwrap_or(0); + format!("COUNT={}", total.saturating_sub(consumed).max(1)) + } + _ => part.to_string(), + }) + .collect(); + out.push(format!("{name}:{}", parts.join(";"))); + } + "RDATE" => { + let kept = filter_dates(value, params, zone, |at| at >= split_ms); + if !kept.is_empty() { + out.push(rebuild(&name, params, &kept)); + } + } + _ => out.push(line.to_string()), + } + } + out +} + +fn count_before(row: &EventRow, zone: Tz, split_ms: i64) -> usize { + let Ok(series) = build_series(row, zone) else { return 0 }; + let Some(upper) = Tz::UTC.timestamp_millis_opt(split_ms - 1).single() else { return 0 }; + series.set.before(upper).all(MAX_OCCURRENCES).dates.len() +} + +// -- scoped edits ------------------------------------------------------------------------------ + +/// The row a plan is made against, which is always the master, plus the occurrence it applies to. +struct Target<'a> { + row: &'a EventRow, + original_start: Option, +} + +fn target<'a>(rows: &'a [EventRow], key: &InstanceKey) -> Result, String> { + let row = rows + .iter() + .find(|row| row.id == key.event_id) + .ok_or_else(|| format!("no such event: {}", key.event_id))?; + let Some(master_id) = row.recurring_event_id.as_deref() else { + return Ok(Target { + row, + original_start: key.original_start.clone(), + }); + }; + // Expansion emits the master's id for every occurrence of a series, so landing here means the + // caller held a concrete instance id. Either way the plan is made against the master. + match rows + .iter() + .find(|other| other.id == master_id && other.calendar_id == row.calendar_id) + { + Some(master) => Ok(Target { + row: master, + original_start: key + .original_start + .clone() + .or_else(|| row.original_start.clone()), + }), + // An exception whose master is not in the window is still a real event; patch it by id. + None => Ok(Target { + row, + original_start: None, + }), + } +} + +/// Where a series is cut and what the tail starts as. +struct Split { + /// UTC `YYYYMMDDTHHMMSSZ`, or `YYYYMMDD` for an all-day series. Exclusive of the occurrence. + until: String, + spot: Spot, + span: Duration, + at_ms: i64, + /// The cut lands on or before the series' own start, so there is no head to keep. + whole_series: bool, +} + +fn split_at(row: &EventRow, original_start: Option<&str>, zone: Tz) -> Result { + let original = original_start + .ok_or_else(|| "splitting a series needs the occurrence to split at".to_string())?; + let spot = placement(row).ok_or_else(|| format!("{}: no usable start", row.id))?; + match spot { + Spot::Day { start: first, end } => { + let at = NaiveDate::parse_from_str(original.trim(), "%Y-%m-%d") + .or_else(|_| { + DateTime::parse_from_rfc3339(original.trim()).map(|at| at.date_naive()) + }) + .map_err(|_| format!("cannot read an original start: {original}"))?; + let span = Duration::days((end - first).num_days().max(1)); + Ok(Split { + until: (at - Duration::days(1)).format("%Y%m%d").to_string(), + spot: Spot::Day { + start: at, + end: at + span, + }, + span, + at_ms: Tz::UTC + .from_utc_datetime(&at.and_time(NaiveTime::MIN)) + .timestamp_millis(), + whole_series: at <= first, + }) + } + Spot::Timed { start: first, end } => { + let at = instant(original) + .ok_or_else(|| format!("cannot read an original start: {original}"))? + .with_timezone(&zone); + let first = first.with_timezone(&zone); + let span = end + .with_timezone(&zone) + .naive_local() + .signed_duration_since(first.naive_local()); + Ok(Split { + until: (at - Duration::seconds(1)) + .with_timezone(&Tz::UTC) + .format("%Y%m%dT%H%M%SZ") + .to_string(), + spot: Spot::Timed { + start: at, + end: resolve(zone, at.naive_local() + span), + }, + span, + at_ms: at.timestamp_millis(), + whole_series: at <= first, + }) + } + } +} + +/// The tail master. Everything the patch does not name is inherited, and a patched start with no +/// patched end keeps the original span rather than collapsing the event. +fn tail_draft(row: &EventRow, split: &Split, patch: &EventPatch, zone: Tz) -> EventDraft { + let all_day = matches!(split.spot, Spot::Day { .. }); + let (inherited_start, inherited_end) = split.spot.strings(zone); + let start = patch.start.clone().unwrap_or(inherited_start); + let end = match (&patch.end, &patch.start) { + (Some(end), _) => end.clone(), + (None, Some(_)) => shifted(&start, split.span, all_day, zone).unwrap_or(inherited_end), + (None, None) => inherited_end, + }; + EventDraft { + color_id: patch.color_id.clone().or_else(|| row.color_id.clone()), + calendar_id: patch + .calendar_id + .clone() + .unwrap_or_else(|| row.calendar_id.clone()), + summary: patch.summary.clone().unwrap_or_else(|| row.summary.clone()), + description: patch + .description + .clone() + .or_else(|| row.description.clone()), + location: patch.location.clone().or_else(|| row.location.clone()), + start, + end, + all_day: patch.all_day.unwrap_or(all_day), + recurrence: patch + .recurrence + .clone() + .unwrap_or_else(|| tail_recurrence(row, zone, split.at_ms)), + } +} + +fn shifted(start: &str, span: Duration, all_day: bool, zone: Tz) -> Option { + if all_day { + let start = NaiveDate::parse_from_str(start.trim(), "%Y-%m-%d").ok()?; + return Some( + (start + Duration::days(span.num_days().max(1))) + .format("%Y-%m-%d") + .to_string(), + ); + } + let at = instant(start)?.with_timezone(&zone); + Some(rfc3339(resolve(zone, at.naive_local() + span), zone)) +} + +pub fn plan_edit( + rows: &[EventRow], + key: &InstanceKey, + patch: &EventPatch, + scope: Scope, +) -> Result, String> { + let target = target(rows, key)?; + let row = target.row; + let calendar_id = row.calendar_id.clone(); + let patch_master = || EditPlan::PatchMaster { + calendar_id: calendar_id.clone(), + event_id: row.id.clone(), + patch: patch.clone(), + }; + + // A one-off has no scopes to choose between. + if row.recurrence.is_empty() { + return Ok(vec![patch_master()]); + } + + match scope { + Scope::All => Ok(vec![patch_master()]), + Scope::This => { + let original_start = target.original_start.ok_or_else(|| { + "editing one occurrence needs the occurrence it applies to".to_string() + })?; + Ok(vec![EditPlan::PatchInstance { + calendar_id, + event_id: row.id.clone(), + original_start: Some(original_start), + patch: patch.clone(), + }]) + } + Scope::Following => { + let zone = row_zone(row); + let split = split_at(row, target.original_start.as_deref(), zone)?; + // Splitting at the first occurrence leaves no head, which is an edit to the whole + // series however the user got there. + if split.whole_series { + return Ok(vec![patch_master()]); + } + Ok(vec![EditPlan::Split { + calendar_id, + event_id: row.id.clone(), + until: split.until.clone(), + draft: tail_draft(row, &split, patch, zone), + }]) + } + } +} + +pub fn plan_delete( + rows: &[EventRow], + key: &InstanceKey, + scope: Scope, +) -> Result, String> { + let target = target(rows, key)?; + let row = target.row; + let calendar_id = row.calendar_id.clone(); + let delete_master = || EditPlan::DeleteMaster { + calendar_id: calendar_id.clone(), + event_id: row.id.clone(), + }; + + if row.recurrence.is_empty() { + return Ok(vec![delete_master()]); + } + + match scope { + Scope::All => Ok(vec![delete_master()]), + Scope::This => { + let original_start = target.original_start.ok_or_else(|| { + "deleting one occurrence needs the occurrence it applies to".to_string() + })?; + Ok(vec![EditPlan::CancelInstance { + calendar_id, + event_id: row.id.clone(), + original_start: Some(original_start), + }]) + } + Scope::Following => { + let zone = row_zone(row); + let split = split_at(row, target.original_start.as_deref(), zone)?; + if split.whole_series { + return Ok(vec![delete_master()]); + } + Ok(vec![EditPlan::TruncateMaster { + calendar_id, + event_id: row.id.clone(), + until: split.until, + }]) + } + } +} + +// -- blobs ------------------------------------------------------------------------------------- + +/// Google's attendee array, read field by field. Deriving it would demand every optional key be +/// present, and Google sends whichever ones apply. +fn attendees(json: Option<&str>) -> Vec { + let Some(items) = json + .and_then(|json| serde_json::from_str::(json).ok()) + .and_then(|value| value.as_array().cloned()) + else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + Some(Attendee { + email: item.get("email")?.as_str()?.to_string(), + display_name: item + .get("displayName") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + response_status: item + .get("responseStatus") + .and_then(serde_json::Value::as_str) + .unwrap_or("needsAction") + .to_string(), + organizer: item + .get("organizer") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + is_self: item + .get("self") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + optional: item + .get("optional") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + }) + }) + .collect() +} + +/// The store keeps no organizer column, so the attendee flagged as one is the only source there is. +fn organizer(json: Option<&str>) -> Option { + attendees(json) + .into_iter() + .find(|attendee| attendee.organizer) + .map(|attendee| attendee.email) +} + +fn conference(json: Option<&str>) -> Option { + let value: serde_json::Value = serde_json::from_str(json?).ok()?; + let kind = value + .pointer("/conferenceSolution/key/type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let entry = value + .get("entryPoints") + .and_then(serde_json::Value::as_array) + .and_then(|items| { + items + .iter() + .find(|item| { + item.get("entryPointType").and_then(serde_json::Value::as_str) + == Some("video") + }) + .or_else(|| items.first()) + }); + let uri = entry + .and_then(|entry| entry.get("uri")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); + if kind.is_empty() && uri.is_none() { + return None; + } + let label = entry + .and_then(|entry| entry.get("label")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| { + value + .pointer("/conferenceSolution/name") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }); + Some(Conference { kind, uri, label }) +} + +/// Synchronous body behind an async command, which is margin's pdf.rs:90 trick for getting heavy +/// work off the main thread without hand-writing spawn_blocking. +#[tauri::command(async)] +pub fn instances_range( + store: tauri::State<'_, crate::store::Store>, + from_utc: i64, + to_utc: i64, +) -> Result, String> { + let conn = store.conn.lock().map_err(|e| e.to_string())?; + let rows = crate::store::read::masters_overlapping(&conn, from_utc, to_utc)?; + let calendars = crate::store::read::calendars(&conn)?; + drop(conn); + let facts: HashMap = calendars + .iter() + .map(|calendar| (calendar.id.clone(), CalendarFacts::from_calendar(calendar))) + .collect(); + expand_in(&rows, from_utc, to_utc, Tz::LOCAL, &facts) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LONDON: Tz = Tz::Europe__London; + const NEW_YORK: Tz = Tz::America__New_York; + + fn ms(rfc3339: &str) -> i64 { + DateTime::parse_from_rfc3339(rfc3339) + .expect("a timestamp") + .timestamp_millis() + } + + fn master(id: &str, start: &str, end: &str, zone: &str, rule: &[&str]) -> EventRow { + EventRow { + id: id.to_string(), + calendar_id: "cal".to_string(), + account_id: "acct".to_string(), + status: "confirmed".to_string(), + summary: id.to_string(), + start_at: start.to_string(), + start_tz: Some(zone.to_string()), + end_at: end.to_string(), + end_tz: Some(zone.to_string()), + recurrence: rule.iter().map(|line| line.to_string()).collect(), + ..Default::default() + } + } + + fn all_day_master(id: &str, start: &str, end: &str, rule: &[&str]) -> EventRow { + EventRow { + all_day: true, + ..master(id, start, end, "America/New_York", rule) + } + } + + /// A moved occurrence, which carries its own times. + fn moved(id: &str, of: &str, original_start: &str, start: &str, end: &str) -> EventRow { + EventRow { + recurring_event_id: Some(of.to_string()), + original_start: Some(original_start.to_string()), + ..master(id, start, end, "Europe/London", &[]) + } + } + + /// Everything Google guarantees on a cancelled instance and not one field more. Any code that + /// reads a summary or a start off one of these will fail here rather than in November. + fn cancelled(id: &str, of: &str, original_start: &str) -> EventRow { + EventRow { + id: id.to_string(), + calendar_id: "cal".to_string(), + recurring_event_id: Some(of.to_string()), + original_start: Some(original_start.to_string()), + status: "cancelled".to_string(), + ..Default::default() + } + } + + fn starts(instances: &[Instance]) -> Vec<&str> { + instances.iter().map(|i| i.start.as_str()).collect() + } + + fn expand_at(rows: &[EventRow], from: &str, to: &str, local: Tz) -> Vec { + expand_in(rows, ms(from), ms(to), local, &HashMap::new()).expect("expand") + } + + #[test] + fn a_weekly_series_drops_only_the_excluded_occurrence() { + let rows = [master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &[ + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "EXDATE;TZID=Europe/London:20260615T090000", + ], + )]; + + let found = expand_at(&rows, "2026-06-01T00:00:00+01:00", "2026-07-01T00:00:00+01:00", LONDON); + + assert_eq!( + starts(&found), + vec![ + "2026-06-01T09:00:00+01:00", + "2026-06-08T09:00:00+01:00", + "2026-06-22T09:00:00+01:00", + "2026-06-29T09:00:00+01:00", + ], + "the 15th is excluded and its neighbours are not" + ); + assert!(found.iter().all(|i| i.recurring)); + assert_eq!( + found[1].original_start.as_deref(), + Some("2026-06-08T09:00:00+01:00") + ); + } + + #[test] + fn an_exdate_without_a_zone_still_matches_its_occurrence() { + // A floating EXDATE means the series' own zone, not the machine's, and the machine here is + // eleven hours away from the series. + let rows = [master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO", "EXDATE:20260615T090000"], + )]; + + let found = expand_at( + &rows, + "2026-06-01T00:00:00+01:00", + "2026-07-01T00:00:00+01:00", + Tz::Pacific__Auckland, + ); + assert_eq!(found.len(), 4, "{:?}", starts(&found)); + assert!(!found + .iter() + .any(|i| i.original_start.as_deref() == Some("2026-06-15T09:00:00+01:00"))); + } + + #[test] + fn a_monthly_by_day_series_lands_on_the_right_dates() { + let rows = [master( + "standup", + "2026-01-20T09:00:00Z", + "2026-01-20T09:30:00Z", + "Europe/London", + &["RRULE:FREQ=MONTHLY;BYDAY=3TU"], + )]; + + let found = expand_at(&rows, "2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z", LONDON); + + let dates: Vec = found.iter().map(|i| i.start[..10].to_string()).collect(); + assert_eq!( + dates, + vec!["2026-01-20", "2026-02-17", "2026-03-17", "2026-04-21", "2026-05-19"], + ); + // The third Tuesday of April is in BST and the one in March is not, and both are 09:00. + assert_eq!(found[2].start, "2026-03-17T09:00:00+00:00"); + assert_eq!(found[3].start, "2026-04-21T09:00:00+01:00"); + } + + #[test] + fn a_weekly_series_stays_at_nine_across_a_spring_forward() { + // New York moves to EDT at 02:00 on 8 March 2026. + let rows = [master( + "review", + "2026-02-27T09:00:00-05:00", + "2026-02-27T10:00:00-05:00", + "America/New_York", + &["RRULE:FREQ=WEEKLY;BYDAY=FR"], + )]; + + let found = expand_at( + &rows, + "2026-02-27T00:00:00-05:00", + "2026-03-21T00:00:00-04:00", + NEW_YORK, + ); + + assert_eq!( + starts(&found), + vec![ + "2026-02-27T09:00:00-05:00", + "2026-03-06T09:00:00-05:00", + "2026-03-13T09:00:00-04:00", + "2026-03-20T09:00:00-04:00", + ], + "nine local on both sides of the transition" + ); + // The week that swallows an hour is a real hour shorter, which is the point. + assert_eq!( + found[2].start_ms - found[1].start_ms, + 7 * 86_400_000 - 3_600_000 + ); + // Every occurrence keeps its hour, which is what an RRULE means. + for instance in &found { + assert_eq!(instance.end_ms - instance.start_ms, 3_600_000); + } + } + + #[test] + fn a_spring_forward_series_holds_its_wall_clock_from_a_distant_machine() { + let rows = [master( + "review", + "2026-02-27T09:00:00-05:00", + "2026-02-27T10:00:00-05:00", + "America/New_York", + &["RRULE:FREQ=WEEKLY;BYDAY=FR"], + )]; + + let found = expand_at( + &rows, + "2026-02-27T00:00:00-05:00", + "2026-03-21T00:00:00-04:00", + Tz::Asia__Tokyo, + ); + + // 09:00 EST is 14:00 UTC and 09:00 EDT is 13:00 UTC, whatever the machine thinks. + let utc: Vec = found + .iter() + .map(|i| { + Tz::UTC + .timestamp_millis_opt(i.start_ms) + .single() + .expect("an instant") + .format("%Y-%m-%dT%H:%M") + .to_string() + }) + .collect(); + assert_eq!( + utc, + vec![ + "2026-02-27T14:00", + "2026-03-06T14:00", + "2026-03-13T13:00", + "2026-03-20T13:00", + ] + ); + } + + #[test] + fn a_weekly_series_stays_at_nine_across_a_fall_back() { + // New York goes back to EST at 02:00 on 1 November 2026. This is the November hour. + let rows = [master( + "review", + "2026-10-30T09:00:00-04:00", + "2026-10-30T10:00:00-04:00", + "America/New_York", + &["RRULE:FREQ=WEEKLY;BYDAY=FR"], + )]; + + let found = expand_at( + &rows, + "2026-10-30T00:00:00-04:00", + "2026-11-14T00:00:00-05:00", + NEW_YORK, + ); + + assert_eq!( + starts(&found), + vec![ + "2026-10-30T09:00:00-04:00", + "2026-11-06T09:00:00-05:00", + "2026-11-13T09:00:00-05:00", + ] + ); + // The week that gains an hour is a real hour longer. + assert_eq!( + found[1].start_ms - found[0].start_ms, + 7 * 86_400_000 + 3_600_000 + ); + } + + #[test] + fn an_occurrence_inside_the_repeated_hour_is_not_lost() { + let rows = [master( + "nightly", + "2026-10-25T01:30:00-04:00", + "2026-10-25T02:00:00-04:00", + "America/New_York", + &["RRULE:FREQ=WEEKLY;BYDAY=SU"], + )]; + + let found = expand_at( + &rows, + "2026-10-25T00:00:00-04:00", + "2026-11-09T00:00:00-05:00", + NEW_YORK, + ); + + // 1 November has two 01:30s. The occurrence takes the first and stays on the calendar. + assert_eq!( + starts(&found), + vec![ + "2026-10-25T01:30:00-04:00", + "2026-11-01T01:30:00-04:00", + "2026-11-08T01:30:00-05:00", + ] + ); + } + + #[test] + fn an_occurrence_spanning_a_transition_keeps_its_wall_clock_and_its_true_length() { + let rows = [master( + "vigil", + "2026-10-25T00:30:00-04:00", + "2026-10-25T02:00:00-04:00", + "America/New_York", + &["RRULE:FREQ=WEEKLY;BYDAY=SU;COUNT=2"], + )]; + + let found = expand_at( + &rows, + "2026-10-25T00:00:00-04:00", + "2026-11-02T00:00:00-05:00", + NEW_YORK, + ); + + assert_eq!(found.len(), 2); + // Both read 00:30 to 02:00 on the wall, which is what the grid lays out against. + for instance in &found { + assert!(instance.start.contains("T00:30:00"), "{}", instance.start); + assert!(instance.end.contains("T02:00:00"), "{}", instance.end); + } + // The one that swallows the repeated hour really is an hour longer, and the grid is + // expected to render it at the height its wall clock says rather than compensate. + assert_eq!(found[0].end_ms - found[0].start_ms, 90 * 60_000); + assert_eq!(found[1].end_ms - found[1].start_ms, 150 * 60_000); + } + + #[test] + fn an_all_day_series_does_not_shift_a_day_from_any_machine() { + let rows = [all_day_master( + "holiday", + "2026-03-05", + "2026-03-06", + &["RRULE:FREQ=DAILY;COUNT=5"], + )]; + + for local in [Tz::Pacific__Kiritimati, Tz::Pacific__Midway, Tz::UTC, NEW_YORK] { + let found = expand_in( + &rows, + resolve(local, NaiveDate::from_ymd_opt(2026, 3, 1).unwrap().and_time(NaiveTime::MIN)) + .timestamp_millis(), + resolve(local, NaiveDate::from_ymd_opt(2026, 3, 15).unwrap().and_time(NaiveTime::MIN)) + .timestamp_millis(), + local, + &HashMap::new(), + ) + .expect("expand"); + + assert_eq!( + starts(&found), + vec!["2026-03-05", "2026-03-06", "2026-03-07", "2026-03-08", "2026-03-09"], + "dates must not move in {local}" + ); + assert!(found.iter().all(|i| i.all_day)); + assert_eq!(found[0].end, "2026-03-06", "the end date stays exclusive"); + + // Pinned to local midnight, and exclusive at the end. + for instance in &found { + let start = resolve( + local, + NaiveDate::parse_from_str(&instance.start, "%Y-%m-%d") + .unwrap() + .and_time(NaiveTime::MIN), + ); + assert_eq!(instance.start_ms, start.timestamp_millis(), "in {local}"); + assert!(instance.end_ms > instance.start_ms); + } + } + } + + #[test] + fn an_all_day_series_with_a_date_valued_until_still_expands() { + // Google writes UNTIL without a zone for an all-day series, which the crate reads in the + // machine's zone and then rejects against a zoned DTSTART unless it is normalised first. + let rows = [all_day_master( + "sprint", + "2026-03-02", + "2026-03-03", + &["RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20260316"], + )]; + + let found = expand_at(&rows, "2026-03-01T00:00:00Z", "2026-04-01T00:00:00Z", Tz::Asia__Kolkata); + assert_eq!(starts(&found), vec!["2026-03-02", "2026-03-09", "2026-03-16"]); + } + + #[test] + fn an_overridden_instance_appears_once_and_a_cancelled_one_never() { + let rows = [ + master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO"], + ), + moved( + "weekly_20260608T080000Z", + "weekly", + "2026-06-08T09:00:00+01:00", + "2026-06-09T14:00:00+01:00", + "2026-06-09T15:00:00+01:00", + ), + cancelled( + "weekly_20260615T080000Z", + "weekly", + "2026-06-15T09:00:00+01:00", + ), + ]; + + let found = expand_at(&rows, "2026-06-01T00:00:00+01:00", "2026-07-01T00:00:00+01:00", LONDON); + + assert_eq!( + starts(&found), + vec![ + "2026-06-01T09:00:00+01:00", + "2026-06-09T14:00:00+01:00", + "2026-06-22T09:00:00+01:00", + "2026-06-29T09:00:00+01:00", + ], + "the moved one shows at its new time, at neither the old one nor the cancelled one" + ); + let overridden = &found[1]; + assert!(overridden.recurring); + assert_eq!( + overridden.original_start.as_deref(), + Some("2026-06-08T09:00:00+01:00") + ); + } + + #[test] + fn an_override_dragged_into_the_window_from_outside_it_still_shows() { + let rows = [ + master( + "weekly", + "2026-05-04T09:00:00+01:00", + "2026-05-04T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4"], + ), + moved( + "weekly_20260504T080000Z", + "weekly", + "2026-05-04T09:00:00+01:00", + "2026-06-10T09:00:00+01:00", + "2026-06-10T10:00:00+01:00", + ), + ]; + + let found = expand_at(&rows, "2026-06-01T00:00:00+01:00", "2026-07-01T00:00:00+01:00", LONDON); + assert_eq!(starts(&found), vec!["2026-06-10T09:00:00+01:00"]); + } + + #[test] + fn a_following_split_leaves_no_gap_and_no_duplicate() { + let rows = [master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=5"], + )]; + let key = InstanceKey { + event_id: "weekly".to_string(), + original_start: Some("2026-06-15T09:00:00+01:00".to_string()), + }; + + let plans = plan_edit(&rows, &key, &EventPatch::default(), Scope::Following).expect("plan"); + let (until, draft) = match &plans[..] { + [EditPlan::Split { until, draft, .. }] => (until.clone(), draft.clone()), + other => panic!("expected one split, got {other:?}"), + }; + + // 09:00 London on 15 June is 08:00 UTC, and the UNTIL is a second short of it. + assert_eq!(until, "20260615T075959Z"); + assert_eq!(draft.start, "2026-06-15T09:00:00+01:00"); + assert_eq!(draft.end, "2026-06-15T10:00:00+01:00"); + assert_eq!(draft.recurrence, vec!["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3"]); + + // The two halves as the sync agent will write them back. + let mut head = rows[0].clone(); + head.recurrence = truncate_recurrence(&head.recurrence, &until); + assert_eq!( + head.recurrence, + vec!["RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20260615T075959Z"] + ); + let mut tail = master( + "weekly-2", + &draft.start, + &draft.end, + "Europe/London", + &draft.recurrence.iter().map(String::as_str).collect::>(), + ); + tail.id = "weekly-2".to_string(); + + let found = expand_at( + &[head, tail], + "2026-06-01T00:00:00+01:00", + "2026-07-06T00:00:00+01:00", + LONDON, + ); + assert_eq!( + starts(&found), + vec![ + "2026-06-01T09:00:00+01:00", + "2026-06-08T09:00:00+01:00", + "2026-06-15T09:00:00+01:00", + "2026-06-22T09:00:00+01:00", + "2026-06-29T09:00:00+01:00", + ], + "every occurrence exactly once across the boundary" + ); + } + + #[test] + fn a_following_split_of_an_all_day_series_is_date_valued() { + let rows = [all_day_master( + "sprint", + "2026-03-02", + "2026-03-03", + &["RRULE:FREQ=WEEKLY;BYDAY=MO"], + )]; + let key = InstanceKey { + event_id: "sprint".to_string(), + original_start: Some("2026-03-16".to_string()), + }; + + let plans = plan_edit(&rows, &key, &EventPatch::default(), Scope::Following).expect("plan"); + match &plans[..] { + [EditPlan::Split { until, draft, .. }] => { + assert_eq!(until, "20260315"); + assert!(draft.all_day); + assert_eq!(draft.start, "2026-03-16"); + assert_eq!(draft.end, "2026-03-17"); + } + other => panic!("expected one split, got {other:?}"), + } + } + + #[test] + fn a_following_split_at_the_first_occurrence_is_the_whole_series() { + let rows = [master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO"], + )]; + let key = InstanceKey { + event_id: "weekly".to_string(), + original_start: Some("2026-06-01T09:00:00+01:00".to_string()), + }; + + let plans = plan_edit(&rows, &key, &EventPatch::default(), Scope::Following).expect("plan"); + assert!(matches!(plans[..], [EditPlan::PatchMaster { .. }])); + let plans = plan_delete(&rows, &key, Scope::Following).expect("plan"); + assert!(matches!(plans[..], [EditPlan::DeleteMaster { .. }])); + } + + #[test] + fn the_three_scopes_are_three_different_calls() { + let rows = [master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO"], + )]; + let key = InstanceKey { + event_id: "weekly".to_string(), + original_start: Some("2026-06-15T09:00:00+01:00".to_string()), + }; + let patch = EventPatch { + summary: Some("moved".to_string()), + ..Default::default() + }; + + match &plan_edit(&rows, &key, &patch, Scope::This).expect("plan")[..] { + [EditPlan::PatchInstance { + event_id, + original_start, + .. + }] => { + assert_eq!(event_id, "weekly", "the master, resolved through events.instances"); + assert_eq!(original_start.as_deref(), Some("2026-06-15T09:00:00+01:00")); + } + other => panic!("expected a patched instance, got {other:?}"), + } + assert!(matches!( + plan_edit(&rows, &key, &patch, Scope::All).expect("plan")[..], + [EditPlan::PatchMaster { .. }] + )); + assert!(matches!( + plan_delete(&rows, &key, Scope::This).expect("plan")[..], + [EditPlan::CancelInstance { .. }] + )); + assert!(matches!( + plan_delete(&rows, &key, Scope::Following).expect("plan")[..], + [EditPlan::TruncateMaster { .. }] + )); + } + + #[test] + fn an_edit_reached_through_an_exception_is_planned_against_the_master() { + let rows = [ + master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO"], + ), + moved( + "weekly_20260608T080000Z", + "weekly", + "2026-06-08T09:00:00+01:00", + "2026-06-09T14:00:00+01:00", + "2026-06-09T15:00:00+01:00", + ), + ]; + let key = InstanceKey { + event_id: "weekly_20260608T080000Z".to_string(), + original_start: None, + }; + + match &plan_edit(&rows, &key, &EventPatch::default(), Scope::This).expect("plan")[..] { + [EditPlan::PatchInstance { + event_id, + original_start, + .. + }] => { + assert_eq!(event_id, "weekly"); + assert_eq!(original_start.as_deref(), Some("2026-06-08T09:00:00+01:00")); + } + other => panic!("expected a patched instance, got {other:?}"), + } + } + + #[test] + fn a_one_off_ignores_the_scope_it_was_given() { + let rows = [master( + "lunch", + "2026-06-01T12:00:00+01:00", + "2026-06-01T13:00:00+01:00", + "Europe/London", + &[], + )]; + let key = InstanceKey { + event_id: "lunch".to_string(), + original_start: None, + }; + + for scope in [Scope::This, Scope::Following, Scope::All] { + assert!(matches!( + plan_edit(&rows, &key, &EventPatch::default(), scope).expect("plan")[..], + [EditPlan::PatchMaster { .. }] + )); + assert!(matches!( + plan_delete(&rows, &key, scope).expect("plan")[..], + [EditPlan::DeleteMaster { .. }] + )); + } + } + + #[test] + fn a_scoped_edit_without_an_occurrence_is_refused() { + let rows = [master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO"], + )]; + let key = InstanceKey { + event_id: "weekly".to_string(), + original_start: None, + }; + assert!(plan_edit(&rows, &key, &EventPatch::default(), Scope::This).is_err()); + assert!(plan_delete(&rows, &key, Scope::Following).is_err()); + assert!(plan_edit( + &[], + &key, + &EventPatch::default(), + Scope::All + ) + .is_err()); + } + + #[test] + fn truncating_drops_a_count_and_an_rdate_past_the_cut() { + let lines = vec![ + "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10".to_string(), + "RDATE:20260610T080000Z,20260710T080000Z".to_string(), + "EXDATE;TZID=Europe/London:20260817T090000".to_string(), + ]; + assert_eq!( + truncate_recurrence(&lines, "20260615T075959Z"), + vec![ + "RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20260615T075959Z", + // The RDATE UNTIL cannot reach is gone; the EXDATE past the cut is harmless. + "RDATE:20260610T080000Z", + "EXDATE;TZID=Europe/London:20260817T090000", + ] + ); + } + + #[test] + fn a_single_event_and_its_window_edges() { + let rows = [master( + "lunch", + "2026-06-01T12:00:00+01:00", + "2026-06-01T13:00:00+01:00", + "Europe/London", + &[], + )]; + + let inside = expand_at(&rows, "2026-06-01T00:00:00+01:00", "2026-06-02T00:00:00+01:00", LONDON); + assert_eq!(inside.len(), 1); + assert!(!inside[0].recurring); + assert_eq!(inside[0].original_start, None); + + // The window opens exactly when the event ends, so it is over. + let after = expand_at(&rows, "2026-06-01T13:00:00+01:00", "2026-06-02T00:00:00+01:00", LONDON); + assert!(after.is_empty()); + + // The window closes exactly when the event starts, so it has not begun. + let before = expand_at(&rows, "2026-06-01T00:00:00+01:00", "2026-06-01T12:00:00+01:00", LONDON); + assert!(before.is_empty()); + } + + #[test] + fn an_event_running_into_the_window_from_before_it_is_kept() { + let rows = [master( + "overnight", + "2026-06-01T22:00:00+01:00", + "2026-06-02T07:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=DAILY"], + )]; + + let found = expand_at(&rows, "2026-06-03T00:00:00+01:00", "2026-06-04T00:00:00+01:00", LONDON); + assert_eq!( + starts(&found), + vec!["2026-06-02T22:00:00+01:00", "2026-06-03T22:00:00+01:00"] + ); + } + + #[test] + fn a_calendar_lends_its_colour_its_zone_and_its_access_role() { + let mut row = master( + "weekly", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=1"], + ); + row.start_tz = None; + row.end_tz = None; + let calendars = HashMap::from([( + "cal".to_string(), + CalendarFacts { + color_hex: "#4285f4".to_string(), + time_zone: "Europe/London".to_string(), + read_only: true, + }, + )]); + + let found = expand_in( + &[row], + ms("2026-06-01T00:00:00+01:00"), + ms("2026-07-01T00:00:00+01:00"), + Tz::Asia__Tokyo, + &calendars, + ) + .expect("expand"); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].color_hex, "#4285f4"); + assert!(found[0].read_only); + // The calendar's zone stands in for the missing one, so this is still 09:00 in London. + assert_eq!(found[0].start_ms, ms("2026-06-01T09:00:00+01:00")); + } + + #[test] + fn attendees_survive_google_leaving_most_of_the_keys_out() { + let json = r#"[ + {"email":"a@example.com","displayName":"A","responseStatus":"accepted","organizer":true}, + {"email":"b@example.com","responseStatus":"needsAction","self":true,"optional":true}, + {"displayName":"no address"} + ]"#; + let parsed = attendees(Some(json)); + assert_eq!(parsed.len(), 2, "an attendee with no address is not one"); + assert!(parsed[0].organizer && !parsed[0].optional); + assert!(parsed[1].is_self && parsed[1].optional); + assert_eq!(organizer(Some(json)).as_deref(), Some("a@example.com")); + assert!(attendees(None).is_empty()); + assert!(attendees(Some("not json")).is_empty()); + } + + #[test] + fn a_conference_is_read_down_to_its_video_entry_point() { + let json = r#"{ + "conferenceSolution":{"key":{"type":"hangoutsMeet"},"name":"Google Meet"}, + "entryPoints":[ + {"entryPointType":"more","uri":"https://tel.meet/x"}, + {"entryPointType":"video","uri":"https://meet.google.com/abc","label":"meet.google.com/abc"} + ] + }"#; + let parsed = conference(Some(json)).expect("a conference"); + assert_eq!(parsed.kind, "hangoutsMeet"); + assert_eq!(parsed.uri.as_deref(), Some("https://meet.google.com/abc")); + assert_eq!(parsed.label.as_deref(), Some("meet.google.com/abc")); + assert!(conference(Some("{}")).is_none()); + assert!(conference(None).is_none()); + } + + /// The public entry point, on whatever zone the machine running this happens to be in. Only + /// facts that hold in every zone are asserted, which is the point: an instant is an instant and + /// a date is a date. + #[test] + fn the_public_entry_point_agrees_wherever_it_runs() { + let rows = [ + master( + "review", + "2026-03-06T09:00:00-05:00", + "2026-03-06T10:00:00-05:00", + "America/New_York", + &["RRULE:FREQ=WEEKLY;BYDAY=FR;COUNT=2"], + ), + all_day_master("holiday", "2026-03-09", "2026-03-10", &["RRULE:FREQ=DAILY;COUNT=2"]), + ]; + + let found = expand(&rows, ms("2026-03-01T00:00:00Z"), ms("2026-03-20T00:00:00Z")) + .expect("expand"); + + let timed: Vec = found + .iter() + .filter(|i| !i.all_day) + .map(|i| i.start_ms) + .collect(); + assert_eq!( + timed, + vec![ms("2026-03-06T09:00:00-05:00"), ms("2026-03-13T09:00:00-04:00")], + "nine in New York on both sides of the transition, read from anywhere" + ); + + let dates: Vec<&str> = found + .iter() + .filter(|i| i.all_day) + .map(|i| i.start.as_str()) + .collect(); + assert_eq!(dates, vec!["2026-03-09", "2026-03-10"]); + } + + #[test] + fn a_rule_the_crate_cannot_read_costs_its_own_series_and_nothing_else() { + let rows = [ + master( + "broken", + "2026-06-01T09:00:00+01:00", + "2026-06-01T10:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=NEVER"], + ), + master( + "fine", + "2026-06-01T11:00:00+01:00", + "2026-06-01T12:00:00+01:00", + "Europe/London", + &["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=1"], + ), + ]; + + let found = expand_at(&rows, "2026-06-01T00:00:00+01:00", "2026-06-08T00:00:00+01:00", LONDON); + assert_eq!(starts(&found), vec!["2026-06-01T11:00:00+01:00"]); + } +} diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs new file mode 100644 index 0000000..1e2eae4 --- /dev/null +++ b/src-tauri/src/store/mod.rs @@ -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, +} + +impl Store { + pub fn open(app: &tauri::AppHandle) -> Result { + 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, 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", "a@example.com", Some("ref")).expect("account"); + write::upsert_account(&conn, "other", "b@example.com", 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![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()); + } +} diff --git a/src-tauri/src/store/read.rs b/src-tauri/src/store/read.rs new file mode 100644 index 0000000..4204cc2 --- /dev/null +++ b/src-tauri/src/store/read.rs @@ -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, 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 = 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::, _>>().map_err(|e| e.to_string()) +} + +pub fn calendars(conn: &Connection) -> Result, 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::, _>>().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, +} + +pub fn sync_targets(conn: &Connection) -> Result, 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::, _>>().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, 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 { + 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, String> { + let from = from_ms.saturating_sub(DAY_MS); + let to = to_ms.saturating_add(DAY_MS); + + let mut out: Vec = 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 { + 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>(8)?.unwrap_or_default(), + start_tz: r.get(9)?, + end_at: r.get::<_, Option>(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)?, + }) +} diff --git a/src-tauri/src/store/schema.rs b/src-tauri/src/store/schema.rs new file mode 100644 index 0000000..829c784 --- /dev/null +++ b/src-tauri/src/store/schema.rs @@ -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 { + let raw: Option = 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::().ok()).unwrap_or(0)) +} diff --git a/src-tauri/src/store/write.rs b/src-tauri/src/store/write.rs new file mode 100644 index 0000000..cf92822 --- /dev/null +++ b/src-tauri/src/store/write.rs @@ -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, + pub status: String, + pub summary: String, + pub description: Option, + pub location: Option, + /// 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, + pub end_at: String, + pub end_tz: Option, + pub all_day: bool, + pub recurrence: Vec, + pub recurring_event_id: Option, + pub original_start: Option, + pub attendees: Option, + pub conference: Option, + pub updated_at: Option, + /// Google's per-event colour id, 1 to 11, overriding the calendar's colour when set. + pub color_id: Option, + 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, + pub original_start: Option, + /// this | following | all + pub scope: Option, + pub payload: Option, + pub etag: Option, + 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, +} + +/// 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, 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 { + 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 { + if recurrence.is_empty() { + return None; + } + let mut latest: Option = 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 { + 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, 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, + 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, String> { + let token: Option> = 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 { + 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, 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::, _>>().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(()) +} diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs new file mode 100644 index 0000000..b1d597f --- /dev/null +++ b/src-tauri/src/sync.rs @@ -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, + /// 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, +} + +/// 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, + 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::() { + 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::() { + 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( + store: &Store, + f: impl FnOnce(&Connection) -> Result, +) -> Result { + 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 { + 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::() { + 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::() else { + return; + }; + let state = app.state::(); + // 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::() { + 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::() 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::() else { + return Ok(()); + }; + let state = app.state::(); + // 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 { + let _guard = state.running.lock().await; + let store = app + .try_state::() + .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 { + 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 { + let store = app + .try_state::() + .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::() + .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::() + .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, String> { + let mut rows: Vec = 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) +} diff --git a/src-tauri/src/sync/model.rs b/src-tauri/src/sync/model.rs new file mode 100644 index 0000000..2715559 --- /dev/null +++ b/src-tauri/src/sync/model.rs @@ -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 { + value.date_time.clone().or_else(|| value.date.clone()) +} + +fn split(value: Option<&EventDateTime>) -> (String, Option) { + 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() +} diff --git a/src-tauri/src/sync/pull.rs b/src-tauri/src/sync/pull.rs new file mode 100644 index 0000000..9293925 --- /dev/null +++ b/src-tauri/src/sync/pull.rs @@ -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 = 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 = 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 { + 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, String), ApiError> { + let mut items: Vec = Vec::new(); + let mut page_token: Option = 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 { + 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 { + 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, String), ApiError> { + let mut items: Vec = Vec::new(); + let mut page_token: Option = 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 { + 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) +} diff --git a/src-tauri/src/sync/push.rs b/src-tauri/src/sync/push.rs new file mode 100644 index 0000000..6153064 --- /dev/null +++ b/src-tauri/src/sync/push.rs @@ -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 { + 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, 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 { + 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, + payload: Option, + 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 = 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 = 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), 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 { + 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, String> { + Ok(read::calendars(conn)? + .into_iter() + .map(|calendar| (calendar.id, calendar.account_id)) + .collect()) +} diff --git a/src-tauri/src/sync/tests.rs b/src-tauri/src/sync/tests.rs new file mode 100644 index 0000000..96287b7 --- /dev/null +++ b/src-tauri/src/sync/tests.rs @@ -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>>>, + calendars: Mutex>>, + writes: Mutex>>, + deletes: Mutex>>, + instances: Mutex, ApiError>>>, + calls: Mutex>, +} + +impl Stub { + fn record(&self, call: String) { + self.calls.lock().expect("calls").push(call); + } + + fn calls(&self) -> Vec { + self.calls.lock().expect("calls").clone() + } + + fn script_events(&self, calendar_id: &str, replies: Vec>) { + self.events + .lock() + .expect("events") + .insert(calendar_id.to_string(), replies.into()); + } + + fn script_calendars(&self, replies: Vec>) { + *self.calendars.lock().expect("calendars") = replies.into(); + } + + fn script_writes(&self, replies: Vec>) { + *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> + 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> + 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> + 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> + 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> + 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, 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>, + reasons: Mutex>, + statuses: Mutex>, +} + +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, 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 { + 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 { + 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![ + "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"); +} diff --git a/src-tauri/src/sync/transport.rs b/src-tauri/src/sync/transport.rs new file mode 100644 index 0000000..9729070 --- /dev/null +++ b/src-tauri/src/sync/transport.rs @@ -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> + Send; + + fn events_list( + &self, + account_id: &str, + calendar_id: &str, + sync_token: Option<&str>, + page_token: Option<&str>, + ) -> impl Future> + Send; + + fn events_insert( + &self, + account_id: &str, + calendar_id: &str, + body: &serde_json::Value, + ) -> impl Future> + Send; + + fn events_patch( + &self, + account_id: &str, + calendar_id: &str, + event_id: &str, + body: &serde_json::Value, + etag: Option<&str>, + ) -> impl Future> + Send; + + fn events_delete( + &self, + account_id: &str, + calendar_id: &str, + event_id: &str, + etag: Option<&str>, + ) -> impl Future> + Send; + + fn events_instances( + &self, + account_id: &str, + calendar_id: &str, + event_id: &str, + original_start: &str, + ) -> impl Future, ApiError>> + Send; +} + +pub struct Google { + pub app: tauri::AppHandle, +} + +impl Google { + async fn token(&self, account_id: &str) -> Result { + let state = self.app.state::(); + 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> + 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> + 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> + 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> + 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> + 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, ApiError>> + Send { + async move { + let access = self.token(account_id).await?; + api::events_instances(&access, calendar_id, event_id, original_start).await + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..b0d8847 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Margin Calendar", + "version": "0.1.0", + "identifier": "studio.margin.calendar", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:1430", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "", + "width": 1360, + "height": 900, + "minWidth": 880, + "minHeight": 560, + "titleBarStyle": "Overlay" + } + ], + "security": { + "csp": "default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost" + } + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["studio.margin.calendar"] + }, + "mobile": [{ "scheme": ["studio.margin.calendar"] }] + } + }, + "bundle": { + "active": true, + "targets": ["app", "dmg", "appimage", "deb"], + "category": "Productivity", + "shortDescription": "A calendar for Google Calendar", + "longDescription": "A calendar where the grid owns the window and the day always fits without scrolling.", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "linux": { + "deb": { + "depends": ["libwebkit2gtk-4.1-0", "libgtk-3-0"] + } + }, + "macOS": { + "minimumSystemVersion": "10.15" + } + } +} diff --git a/src-tauri/tauri.release.conf.json b/src-tauri/tauri.release.conf.json new file mode 100644 index 0000000..d50663d --- /dev/null +++ b/src-tauri/tauri.release.conf.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "createUpdaterArtifacts": true + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..88959ac --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,133 @@ +import { useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +import { Header } from "./components/Header"; +import { PhoneMenu, PhoneTabBar, PhoneTopBar } from "./components/PhoneBar"; +import { GridView } from "./components/GridView"; +import { AgendaView } from "./components/AgendaView"; +import { MiniMonthOverlay } from "./components/MiniMonth"; +import { CalendarList } from "./components/CalendarList"; +import { EventEditor } from "./components/EventEditor"; +import { EventDetails } from "./components/EventDetails"; +import { Settings } from "./components/Settings"; +import { Accounts } from "./components/Accounts"; +import { CommandPalette } from "./palette/CommandPalette"; +import { ShortcutsSheet } from "./keys/Shortcuts"; +import { Toast } from "./components/Toast"; +import { SearchOverlay } from "./components/SearchOverlay"; + +import { useKeymap } from "./keys/keymap"; +import { handleMenuAction } from "./keys/menu"; +import { useAccounts } from "./store/useAccounts"; +import { useCalendarView } from "./store/useCalendarView"; +import { useSearch } from "./store/useSearch"; +import { useSync } from "./store/useSync"; +import { notify } from "./store/useToast"; +import { useCompact, usePhone, useTouch } from "./useMedia"; +import { syncFlush } from "./api/sync"; +import { isDesktop, isTauri, live, type AuthEvent, type SyncStatus } from "./ipc"; + +/** Long enough for sync_flush's own 1s lock wait plus its 4s drain budget. */ +const FLUSH_TIMEOUT_MS = 5500; + +function App() { + useCompact(); + const phone = usePhone(); + useTouch(); + useKeymap(); + + useEffect(() => { + if (!live()) return; + void useAccounts.getState().refresh(); + void useCalendarView.getState().load(); + void useSync.getState().refresh(); + }, []); + + // `isTauri`, not `isDesktop`. A phone emits every one of these and needs `auth` most of all: the + // mobile consent flow ends with a deep link into Rust, and this listener is the only thing that + // ever learns it worked. Gating on `isDesktop` here left sign-in hanging on the spinner forever. + useEffect(() => { + if (!isTauri) return; + const menu = listen("menu-action", (event) => handleMenuAction(event.payload)); + const auth = listen("auth", (event) => { + void useAccounts.getState().handleAuthEvent(event.payload.ok, event.payload.error); + }); + // A pass that fails in the background used to set the error and say nothing, so a calendar + // that never arrived looked like a calendar you do not have. Surface it once per distinct + // message rather than every sixty seconds. + let lastError: string | null = null; + const progress = listen("sync-progress", (event) => { + useSync.getState().apply(event.payload); + const error = event.payload.error; + if (error && error !== lastError) notify(`Sync failed: ${error}`); + if (error !== lastError) lastError = error; + }); + // Purely an invalidation signal: re-request the visible range rather than trying to read the + // reason. The account list is only worth refetching when the accounts themselves moved. + const changed = listen("store-changed", (event) => { + void useCalendarView.getState().load(); + void useSync.getState().refresh(); + useSearch.getState().invalidate(); + const reason = event.payload ?? ""; + if (reason.includes("account") || reason.includes("calendar")) { + void useAccounts.getState().refresh(); + } + }); + return () => { + void menu.then((stop) => stop()); + void auth.then((stop) => stop()); + void progress.then((stop) => stop()); + void changed.then((stop) => stop()); + }; + }, []); + + // Drain the outbox before quitting, racing a timeout so a deep queue or a dead network cannot + // hold the window open. Same shape as margin's close-request hook. + // + // `isDesktop` genuinely means desktop here: there is no close to intercept on a phone, and the + // window commands live in a capability that platform does not get, so this would be a rejected + // IPC call rather than a no-op. The outbox still drains on the next launch either way. + useEffect(() => { + if (!isDesktop) return; + const win = getCurrentWindow(); + const unlisten = win.onCloseRequested(async (event) => { + if (useSync.getState().pendingWrites === 0) return; + event.preventDefault(); + await Promise.race([ + syncFlush().catch(() => {}), + new Promise((resolve) => setTimeout(resolve, FLUSH_TIMEOUT_MS)), + ]); + void win.destroy(); + }); + return () => { + void unlisten.then((stop) => stop()); + }; + }, []); + + return ( +
+ {/* One row of chrome or two, never both: the phone's bars replace the header rather than + hiding it, so nothing off screen is still holding a drag region or the keyboard. */} + {phone ? :
} +
+ + +
+ {phone ? : null} + + + + + + + + + + + +
+ ); +} + +export default App; diff --git a/src/api/accounts.ts b/src/api/accounts.ts new file mode 100644 index 0000000..a5d4034 --- /dev/null +++ b/src/api/accounts.ts @@ -0,0 +1,9 @@ +import { call, type Account } from "../ipc"; + +export const accountsList = () => call("accounts_list"); + +/** Returns the consent URL. Completion arrives as the `auth` event. */ +export const accountConnect = () => call("account_connect"); + +export const accountDisconnect = (accountId: string) => + call("account_disconnect", { accountId }); diff --git a/src/api/calendars.ts b/src/api/calendars.ts new file mode 100644 index 0000000..7eb2322 --- /dev/null +++ b/src/api/calendars.ts @@ -0,0 +1,6 @@ +import { call, type Calendar } from "../ipc"; + +export const calendarsList = () => call("calendars_list"); + +export const calendarSetSelected = (calendarId: string, selected: boolean) => + call("calendar_set_selected", { calendarId, selected }); diff --git a/src/api/events.ts b/src/api/events.ts new file mode 100644 index 0000000..dbab331 --- /dev/null +++ b/src/api/events.ts @@ -0,0 +1,9 @@ +import { call, type EventDraft, type EventPatch, type Instance, type InstanceKey, type Scope } from "../ipc"; + +export const eventCreate = (draft: EventDraft) => call("event_create", { draft }); + +export const eventUpdate = (key: InstanceKey, patch: EventPatch, scope: Scope) => + call("event_update", { key, patch, scope }); + +export const eventDelete = (key: InstanceKey, scope: Scope) => + call("event_delete", { key, scope }); diff --git a/src/api/instances.ts b/src/api/instances.ts new file mode 100644 index 0000000..6439c34 --- /dev/null +++ b/src/api/instances.ts @@ -0,0 +1,5 @@ +import { call, type Instance } from "../ipc"; + +/** Both bounds are epoch milliseconds; `toUtc` is exclusive. */ +export const instancesRange = (fromUtc: number, toUtc: number) => + call("instances_range", { fromUtc, toUtc }); diff --git a/src/api/sync.ts b/src/api/sync.ts new file mode 100644 index 0000000..72046a3 --- /dev/null +++ b/src/api/sync.ts @@ -0,0 +1,8 @@ +import { call, type SyncStatus } from "../ipc"; + +export const syncNow = () => call("sync_now"); + +export const syncStatus = () => call("sync_status"); + +/** Drains the outbox. The close-request hook races this against a timeout. */ +export const syncFlush = () => call("sync_flush"); diff --git a/src/components/Accounts.tsx b/src/components/Accounts.tsx new file mode 100644 index 0000000..3931990 --- /dev/null +++ b/src/components/Accounts.tsx @@ -0,0 +1,159 @@ +// Connecting and disconnecting Google accounts. The connect flow lives entirely in `useAccounts`: +// this panel calls it and then shows what is happening, including the two escape hatches for when +// the system browser did not open by itself. +// +// Disconnecting is the destructive one. It revokes the token and wipes every row belonging to the +// account, so it asks first and says what goes. + +import { useEffect, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import type { Account } from "../ipc"; +import { useAccounts } from "../store/useAccounts"; +import { useCalendarView } from "../store/useCalendarView"; +import { useOverlays } from "../store/useOverlays"; +import { Icon } from "./Icon"; +import { Confirm, Sheet } from "./overlayShell"; + +const COPY = "M9 9h10v11H9zM5 15H4V4h11v1"; +const OPEN = "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3"; + +export function Accounts() { + const open = useOverlays((s) => s.open); + const close = useOverlays((s) => s.close); + const accounts = useAccounts((s) => s.accounts); + const calendars = useAccounts((s) => s.calendars); + const phase = useAccounts((s) => s.phase); + const error = useAccounts((s) => s.error); + const authUrl = useAccounts((s) => s.authUrl); + const connect = useAccounts((s) => s.connect); + const cancelConnect = useAccounts((s) => s.cancelConnect); + const openAuthUrl = useAccounts((s) => s.openAuthUrl); + const copyAuthUrl = useAccounts((s) => s.copyAuthUrl); + const disconnect = useAccounts((s) => s.disconnect); + const refresh = useAccounts((s) => s.refresh); + + const [confirming, setConfirming] = useState(null); + const showing = open === "accounts"; + const connecting = phase === "connecting"; + + // While the browser has the flow, Escape belongs to the flow rather than to the panel. + useEscapeLayer(showing && connecting, cancelConnect); + useEscapeLayer(showing && confirming !== null, () => setConfirming(null)); + + useEffect(() => { + if (showing) void refresh(); + }, [showing, refresh]); + + const remove = async (account: Account) => { + setConfirming(null); + await disconnect(account.id); + void useCalendarView.getState().load(); + }; + + const countFor = (accountId: string) => calendars.filter((c) => c.accountId === accountId).length; + + return ( + void connect()} + > + Connect an account + + ) + } + > + {confirming ? ( + + The token is revoked and every calendar, event and pending write stored on this + computer for that account is deleted. Nothing changes in Google Calendar itself, and + you can connect the account again afterwards. +

+ } + confirmLabel="Disconnect" + busy={phase === "working"} + onConfirm={() => void remove(confirming)} + onCancel={() => setConfirming(null)} + /> + ) : ( + <> + {connecting && ( +
+ Waiting for Google in your browser. + + {authUrl + ? "If nothing opened, use the link. Finish there and this comes back on its own." + : "Building the consent link."} + +
+ + + +
+
+ )} + + {phase === "error" && error &&

{error}

} + + {accounts.length === 0 && !connecting ? ( +

+ No account is connected, so there is nothing to show on the grid yet. +

+ ) : ( + accounts.map((account) => ( +
+ + {account.email} + + {account.connected ? `${countFor(account.id)} calendar${countFor(account.id) === 1 ? "" : "s"}` : "Needs reconnecting"} + + + +
+ )) + )} + + )} +
+ ); +} + +export default Accounts; diff --git a/src/components/AgendaList.tsx b/src/components/AgendaList.tsx new file mode 100644 index 0000000..61a4221 --- /dev/null +++ b/src/components/AgendaList.tsx @@ -0,0 +1,95 @@ +// The list itself, shared by the agenda view and by search results, which are the same thing shown +// for a different set of instances. +// +// No virtualisation and no measurement: thirty days is a few hundred rows and the DOM is fine with +// that. The only thing this component does beyond render is keep the selection on screen, because +// the selection moves from the keyboard and the list is the one part of the app that scrolls. + +import { useEffect, useRef } from "react"; +import type { InstanceKey } from "../ipc"; +import { addDays, dayName, monthName, today } from "../time"; +import { keyId, keyOf, sameKey } from "./GridModel"; +import { AgendaRow } from "./AgendaRow"; +import { gapDates, gapLabel, type AgendaSection } from "./AgendaModel"; + +const NO_TERMS: readonly string[] = []; + +interface AgendaListProps { + sections: readonly AgendaSection[]; + selected: InstanceKey | null; + onSelect: (key: InstanceKey) => void; + onOpen?: (key: InstanceKey) => void; + /** Lowercased search terms to mark in each summary. */ + terms?: readonly string[]; + /** A quiet line above the list: loading, an error, or why there is nothing to show. */ + note?: string | null; +} + +export function AgendaList({ + sections, + selected, + onSelect, + onOpen, + terms = NO_TERMS, + note, +}: AgendaListProps) { + const scrollRef = useRef(null); + const selectedId = selected ? keyId(selected) : null; + const todayMs = today(); + + useEffect(() => { + if (!selectedId) return; + const row = scrollRef.current?.querySelector(`[data-id="${CSS.escape(selectedId)}"]`); + row?.scrollIntoView({ block: "nearest" }); + }, [selectedId]); + + return ( +
+
+ {note &&

{note}

} + {sections.map((section) => + section.kind === "gap" ? ( +
+ {gapDates(section)} + + {gapLabel(section)} +
+ ) : ( +
+

+ {dayName(section.dayStart)} + {new Date(section.dayStart).getDate()} + {section.showMonth && ( + {monthName(section.dayStart)} + )} + + {section.items.length} +

+
+ {section.items.map((item) => ( + + ))} +
+
+ ), + )} +
+
+ ); +} diff --git a/src/components/AgendaModel.test.ts b/src/components/AgendaModel.test.ts new file mode 100644 index 0000000..164bead --- /dev/null +++ b/src/components/AgendaModel.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import type { Instance } from "../ipc"; +import { addDays, startOfDay, toDateOnly, toOffsetIso } from "../time"; +import { + buildAgenda, + buildSearch, + countItems, + gapDates, + gapLabel, + instanceRange, + matchesQuery, + queryTerms, + splitMatch, + timeLabel, + type AgendaDay, + type AgendaGap, +} from "./AgendaModel"; + +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min).getTime(); + +function timed(id: string, startMs: number, endMs: number, over: Partial = {}): Instance { + return { + colorId: null, + eventId: id, + calendarId: "cal", + accountId: "acc", + originalStart: null, + start: toOffsetIso(startMs), + end: toOffsetIso(endMs), + startMs, + endMs, + allDay: false, + summary: id, + description: null, + location: null, + status: "confirmed", + recurring: false, + colorHex: "#7986cb", + etag: null, + organizer: null, + attendees: [], + conference: null, + readOnly: false, + pending: false, + ...over, + }; +} + +function whole(id: string, from: number, days = 1, over: Partial = {}): Instance { + const startMs = startOfDay(from); + const endMs = addDays(startMs, days); + return timed(id, startMs, endMs, { + allDay: true, + start: toDateOnly(startMs), + end: toDateOnly(endMs), + ...over, + }); +} + +const days = (sections: readonly { kind: string }[]) => sections.filter((s) => s.kind === "day") as AgendaDay[]; +const gaps = (sections: readonly { kind: string }[]) => sections.filter((s) => s.kind === "gap") as AgendaGap[]; + +describe("buildAgenda", () => { + const monday = at(2026, 8, 10); + + it("gives every day of the span a section, in order", () => { + const sections = buildAgenda([timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10))], monday, addDays(monday, 3)); + expect(sections.map((s) => s.kind)).toEqual(["day", "gap", "gap"]); + expect(gaps(sections).map((g) => g.days)).toEqual([1, 1]); + }); + + it("collapses a run of three or more empty days into one row", () => { + const sections = buildAgenda( + [ + timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10)), + timed("b", at(2026, 8, 16, 9), at(2026, 8, 16, 10)), + ], + monday, + addDays(monday, 7), + ); + expect(sections.map((s) => s.kind)).toEqual(["day", "gap", "day"]); + const [gap] = gaps(sections); + expect(gap.days).toBe(5); + expect(gap.from).toBe(addDays(monday, 1)); + expect(gap.to).toBe(addDays(monday, 6)); + expect(gap.next).toBe(addDays(monday, 6)); + expect(gapLabel(gap)).toBe("nothing until Sunday"); + }); + + it("leaves a run that reaches the end of the span with nowhere to point at", () => { + const sections = buildAgenda([timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10))], monday, addDays(monday, 5)); + const [gap] = gaps(sections); + expect(gap.next).toBeNull(); + expect(gap.days).toBe(4); + expect(gapLabel(gap)).toBe("nothing for 4 days"); + }); + + it("sorts all-day before timed, then by start, then longest first", () => { + const sections = buildAgenda( + [ + timed("noon", at(2026, 8, 10, 12), at(2026, 8, 10, 13)), + timed("morning", at(2026, 8, 10, 9), at(2026, 8, 10, 10)), + timed("long", at(2026, 8, 10, 9), at(2026, 8, 10, 17)), + whole("holiday", monday), + ], + monday, + addDays(monday, 1), + ); + expect(days(sections)[0].items.map((i) => i.instance.eventId)).toEqual([ + "holiday", + "long", + "morning", + "noon", + ]); + }); + + it("repeats a multi-day event under each day it covers, clipped", () => { + const sections = buildAgenda([whole("trip", monday, 3)], monday, addDays(monday, 5)); + expect(sections.map((s) => s.kind)).toEqual(["day", "day", "day", "gap", "gap"]); + const middle = days(sections)[1].items[0]; + expect(middle.continued).toBe(true); + expect(middle.continues).toBe(true); + expect(middle.startMs).toBe(addDays(monday, 1)); + expect(timeLabel(middle)).toBe("all day"); + }); + + it("puts an event that crosses midnight on both days", () => { + const sections = buildAgenda( + [timed("late", at(2026, 8, 10, 22), at(2026, 8, 11, 1))], + monday, + addDays(monday, 2), + ); + const [first, second] = days(sections); + expect(timeLabel(first.items[0])).toBe("from 10pm"); + expect(timeLabel(second.items[0])).toBe("until 1am"); + }); + + it("drops cancelled instances, so a day holding only one reads as empty", () => { + const sections = buildAgenda( + [timed("gone", at(2026, 8, 10, 9), at(2026, 8, 10, 10), { status: "cancelled" })], + monday, + addDays(monday, 1), + ); + expect(sections.map((s) => s.kind)).toEqual(["gap"]); + }); + + it("names the month on the first section and again when it changes", () => { + const start = at(2026, 8, 30); + const sections = buildAgenda( + [ + timed("a", at(2026, 8, 30, 9), at(2026, 8, 30, 10)), + timed("b", at(2026, 9, 1, 9), at(2026, 9, 1, 10)), + ], + start, + addDays(start, 3), + ); + expect(sections.map((s) => s.showMonth)).toEqual([true, false, true]); + }); + + it("survives an empty span", () => { + expect(buildAgenda([], monday, monday)).toEqual([]); + }); +}); + +describe("labels", () => { + const monday = at(2026, 8, 10); + + it("says when without an axis", () => { + const sections = buildAgenda( + [ + timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10, 30)), + timed("point", at(2026, 8, 10, 14), at(2026, 8, 10, 14)), + ], + monday, + addDays(monday, 1), + ); + const items = days(sections)[0].items; + expect(timeLabel(items[0])).toBe("9am to 10:30am"); + expect(timeLabel(items[1])).toBe("2pm"); + }); + + it("writes a gap's dates the way a heading would", () => { + const start = at(2026, 8, 30); + const sections = buildAgenda([timed("a", at(2026, 9, 3, 9), at(2026, 9, 3, 10))], start, addDays(start, 5)); + const [gap] = gaps(sections); + expect(gapDates(gap)).toBe("Sun 30 August to Wed 2 September"); + }); + + it("drops the month from a single quiet day already under its heading", () => { + const sections = buildAgenda( + [timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10))], + monday, + addDays(monday, 2), + ); + const [gap] = gaps(sections); + expect(gap.showMonth).toBe(false); + expect(gapDates(gap)).toBe("Tue 11"); + expect(gapLabel(gap)).toBe("nothing"); + }); +}); + +describe("instanceRange", () => { + it("reads an all-day boundary out of the date-only strings", () => { + const instance = whole("holiday", at(2026, 8, 10), 2); + expect(instanceRange(instance)).toEqual({ startMs: at(2026, 8, 10), endMs: at(2026, 8, 12) }); + }); + + it("falls back to the epoch fields when the date-only strings are not there", () => { + const instance = timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10), { allDay: true, start: "", end: "" }); + expect(instanceRange(instance)).toEqual({ startMs: at(2026, 8, 10, 9), endMs: at(2026, 8, 10, 10) }); + }); +}); + +describe("search", () => { + const monday = at(2026, 8, 10); + const set = [ + timed("a", at(2026, 8, 10, 9), at(2026, 8, 10, 10), { summary: "Lunch with Sam" }), + timed("b", at(2026, 8, 12, 9), at(2026, 8, 12, 10), { summary: "Standup", location: "Sam's desk" }), + timed("c", at(2026, 8, 12, 11), at(2026, 8, 12, 12), { summary: "Retro", description: "with sam" }), + timed("d", at(2026, 8, 13, 9), at(2026, 8, 13, 10), { summary: "Dentist" }), + timed("e", at(2026, 8, 14, 9), at(2026, 8, 14, 10), { summary: "Sam", status: "cancelled" }), + ]; + + it("matches summary, location and description, and groups by day", () => { + const sections = buildSearch(set, "sam"); + expect(sections.map((s) => (s as AgendaDay).dayStart)).toEqual([monday, at(2026, 8, 12)]); + expect(countItems(sections)).toBe(3); + }); + + it("narrows with a second term rather than widening", () => { + expect(countItems(buildSearch(set, "sam lunch"))).toBe(1); + expect(countItems(buildSearch(set, "sam dentist"))).toBe(0); + }); + + it("has nothing to show for an empty query", () => { + expect(buildSearch(set, " ")).toEqual([]); + expect(matchesQuery(set[0], queryTerms(""))).toBe(false); + }); + + it("shows a multi-day hit once, on the day it starts", () => { + const sections = buildSearch([whole("trip", monday, 4, { summary: "Trip to Rome" })], "rome"); + expect(sections).toHaveLength(1); + expect(countItems(sections)).toBe(1); + }); +}); + +describe("splitMatch", () => { + it("cuts around every occurrence, keeping the original case", () => { + expect(splitMatch("Sam and sam", ["sam"])).toEqual([ + { text: "Sam", hit: true }, + { text: " and ", hit: false }, + { text: "sam", hit: true }, + ]); + }); + + it("prefers the longest term at a position", () => { + expect(splitMatch("standup", ["stand", "standup"])).toEqual([{ text: "standup", hit: true }]); + }); + + it("leaves text alone when nothing matches", () => { + expect(splitMatch("Dentist", ["sam"])).toEqual([{ text: "Dentist", hit: false }]); + expect(splitMatch("Dentist", [])).toEqual([{ text: "Dentist", hit: false }]); + expect(splitMatch("", ["sam"])).toEqual([{ text: "", hit: false }]); + }); +}); diff --git a/src/components/AgendaModel.tsx b/src/components/AgendaModel.tsx new file mode 100644 index 0000000..a078f96 --- /dev/null +++ b/src/components/AgendaModel.tsx @@ -0,0 +1,304 @@ +// Pure model for the agenda: cutting a span into day sections, collapsing runs of empty days into +// one line, and the substring matching that search results are marked with. +// +// Nothing here holds state or touches the DOM. It is a `.tsx` file only because the agenda owns +// `Agenda*.tsx` and nothing else, which is the same reason `GridModel.tsx` is one. + +import type { Instance } from "../ipc"; +import { addDays, dayName, formatTime, monthName, parseDateOnly, startOfDay } from "../time"; +import { keyId, keyOf } from "./GridModel"; + +/** + * A run of empty days this long or longer collapses into a single line. One and two day runs stay + * as a row each, because a quiet weekend is a shape you want to keep seeing; a fortnight of + * nothing is one fact and deserves one row. + */ +export const COLLAPSE_DAYS = 3; + +/** One instance as it appears on one day, clipped to that day. */ +export interface AgendaItem { + instance: Instance; + id: string; + /** Clipped to the day this row sits under. */ + startMs: number; + endMs: number; + /** It started before this day. */ + continued: boolean; + /** It runs past the end of this day. */ + continues: boolean; +} + +export interface AgendaDay { + kind: "day"; + key: string; + dayStart: number; + items: AgendaItem[]; + /** The month changed here, so the heading says which one. */ + showMonth: boolean; +} + +/** A run of days with nothing in them. A run is information, so it gets a row rather than a skip. */ +export interface AgendaGap { + kind: "gap"; + key: string; + from: number; + /** Exclusive, at local midnight. */ + to: number; + days: number; + /** The next day in the span that has something, or null when the span simply ends. */ + next: number | null; + showMonth: boolean; +} + +export type AgendaSection = AgendaDay | AgendaGap; + +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; + +/** + * The half-open span an instance covers, in epoch milliseconds. All-day boundaries are read back + * out of the date-only strings, which are the authoritative ones and must never be shifted into a + * zone; anything that is not date-only falls back to the epoch fields. + */ +export function instanceRange(i: Instance): { startMs: number; endMs: number } { + if (i.allDay && DATE_ONLY.test(i.start)) { + const startMs = parseDateOnly(i.start); + const endMs = DATE_ONLY.test(i.end) ? parseDateOnly(i.end) : startMs; + return { startMs, endMs: endMs > startMs ? endMs : addDays(startMs, 1) }; + } + return { startMs: i.startMs, endMs: i.endMs }; +} + +/** All-day first, then anything covering the whole day, then by start, longest first. */ +function rank(item: AgendaItem): number { + return item.instance.allDay || (item.continued && item.continues) ? 0 : 1; +} + +function compareItems(a: AgendaItem, b: AgendaItem): number { + return ( + rank(a) - rank(b) || + a.startMs - b.startMs || + b.endMs - b.startMs - (a.endMs - a.startMs) || + a.instance.summary.localeCompare(b.instance.summary) || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) + ); +} + +const monthKey = (ms: number): string => { + const d = new Date(ms); + return `${d.getFullYear()}-${d.getMonth()}`; +}; + +/** A heading names its month only when it is not the month the line above was already in. */ +function withMonths(sections: AgendaSection[]): AgendaSection[] { + let last = ""; + for (const section of sections) { + const first = section.kind === "day" ? section.dayStart : section.from; + const end = section.kind === "day" ? section.dayStart : addDays(section.to, -1); + section.showMonth = monthKey(first) !== last; + last = monthKey(end); + } + return sections; +} + +function itemFor(instance: Instance, dayStart: number, dayEnd: number): AgendaItem { + const range = instanceRange(instance); + return { + instance, + id: keyId(keyOf(instance)), + startMs: Math.max(range.startMs, dayStart), + endMs: Math.min(range.endMs, dayEnd), + continued: range.startMs < dayStart, + continues: range.endMs > dayEnd, + }; +} + +/** + * Every day of `[from, to)` in order, days with something in them carrying their events and runs + * of empty days collapsed. An event covering several days appears under each of them, clipped, + * because a week-long holiday does not make the days it covers empty. + */ +export function buildAgenda( + instances: readonly Instance[], + from: number, + to: number, +): AgendaSection[] { + const days: number[] = []; + for (let d = startOfDay(from); d < to; d = addDays(d, 1)) days.push(d); + if (days.length === 0) return []; + + const ends = days.map((d) => addDays(d, 1)); + const buckets: AgendaItem[][] = days.map(() => []); + for (const instance of instances) { + if (instance.status === "cancelled") continue; + const range = instanceRange(instance); + // A zero length event still happens somewhere, so it counts as covering its own instant. + const covered = Math.max(range.endMs, range.startMs + 1); + for (let i = 0; i < days.length; i++) { + if (range.startMs >= ends[i]) continue; + if (covered <= days[i]) break; + buckets[i].push(itemFor(instance, days[i], ends[i])); + } + } + for (const bucket of buckets) bucket.sort(compareItems); + + const sections: AgendaSection[] = []; + let i = 0; + while (i < days.length) { + if (buckets[i].length > 0) { + sections.push({ + kind: "day", + key: `d${days[i]}`, + dayStart: days[i], + items: buckets[i], + showMonth: false, + }); + i++; + continue; + } + let j = i; + while (j < days.length && buckets[j].length === 0) j++; + const next = j < days.length ? days[j] : null; + if (j - i >= COLLAPSE_DAYS) { + sections.push({ + kind: "gap", + key: `g${days[i]}`, + from: days[i], + to: ends[j - 1], + days: j - i, + next, + showMonth: false, + }); + } else { + for (let k = i; k < j; k++) { + sections.push({ + kind: "gap", + key: `g${days[k]}`, + from: days[k], + to: ends[k], + days: 1, + next, + showMonth: false, + }); + } + } + i = j; + } + return withMonths(sections); +} + +/** The matches, grouped by the day they start on. No gap rows: a gap between hits says nothing. */ +export function buildSearch(instances: readonly Instance[], query: string): AgendaSection[] { + const terms = queryTerms(query); + if (terms.length === 0) return []; + + const buckets = new Map(); + for (const instance of instances) { + if (instance.status === "cancelled") continue; + if (!matchesQuery(instance, terms)) continue; + // A hit appears once, on the day it starts, so a long event does not fill the results. + const dayStart = startOfDay(instanceRange(instance).startMs); + const item = itemFor(instance, dayStart, addDays(dayStart, 1)); + const bucket = buckets.get(dayStart); + if (bucket) bucket.push(item); + else buckets.set(dayStart, [item]); + } + + const sections: AgendaSection[] = [...buckets.keys()] + .sort((a, b) => a - b) + .map((dayStart) => ({ + kind: "day" as const, + key: `d${dayStart}`, + dayStart, + items: (buckets.get(dayStart) as AgendaItem[]).sort(compareItems), + showMonth: false, + })); + return withMonths(sections); +} + +export const countItems = (sections: readonly AgendaSection[]): number => + sections.reduce((n, section) => n + (section.kind === "day" ? section.items.length : 0), 0); + +/* Labels + ----------------------------------------------------------------------------------------- */ + +const weekdays = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; + +/** The agenda has no time axis, so this line is the only thing saying when. */ +export function timeLabel(item: AgendaItem): string { + const { instance } = item; + if (instance.allDay || (item.continued && item.continues)) return "all day"; + if (item.continued) return `until ${formatTime(item.endMs)}`; + if (item.continues) return `from ${formatTime(item.startMs)}`; + if (item.endMs <= item.startMs) return formatTime(item.startMs); + return `${formatTime(item.startMs)} to ${formatTime(item.endMs)}`; +} + +/** The date or dates a gap covers, in the same shape a day heading uses. */ +export function gapDates(gap: AgendaGap): string { + const head = `${dayName(gap.from)} ${new Date(gap.from).getDate()}`; + if (gap.days === 1) return gap.showMonth ? `${head} ${monthName(gap.from)}` : head; + const last = addDays(gap.to, -1); + const tail = `${dayName(last)} ${new Date(last).getDate()} ${monthName(last)}`; + return monthKey(gap.from) === monthKey(last) ? `${head} to ${tail}` : `${head} ${monthName(gap.from)} to ${tail}`; +} + +export function gapLabel(gap: AgendaGap): string { + if (gap.days === 1) return "nothing"; + if (gap.next !== null) return `nothing until ${weekdays[new Date(gap.next).getDay()]}`; + return `nothing for ${gap.days} days`; +} + +/* Search matching + ----------------------------------------------------------------------------------------- */ + +export const queryTerms = (query: string): string[] => + query + .toLowerCase() + .split(/\s+/) + .filter((term) => term.length > 0); + +/** Every term has to land somewhere, so a second word narrows rather than widens. */ +export function matchesQuery(instance: Instance, terms: readonly string[]): boolean { + if (terms.length === 0) return false; + const hay = `${instance.summary}\n${instance.location ?? ""}\n${instance.description ?? ""}`.toLowerCase(); + return terms.every((term) => hay.includes(term)); +} + +export interface MatchPart { + text: string; + hit: boolean; +} + +/** `text` cut around every occurrence of any term, longest term winning at a given position. */ +export function splitMatch(text: string, terms: readonly string[]): MatchPart[] { + const clean = terms.filter((term) => term.length > 0); + if (clean.length === 0 || text.length === 0) return [{ text, hit: false }]; + const hay = text.toLowerCase(); + const parts: MatchPart[] = []; + let plain = 0; + let at = 0; + while (at < text.length) { + let width = 0; + for (const term of clean) { + if (term.length > width && hay.startsWith(term, at)) width = term.length; + } + if (width === 0) { + at++; + continue; + } + if (at > plain) parts.push({ text: text.slice(plain, at), hit: false }); + parts.push({ text: text.slice(at, at + width), hit: true }); + at += width; + plain = at; + } + if (plain < text.length) parts.push({ text: text.slice(plain), hit: false }); + return parts; +} diff --git a/src/components/AgendaRow.tsx b/src/components/AgendaRow.tsx new file mode 100644 index 0000000..1cd489c --- /dev/null +++ b/src/components/AgendaRow.tsx @@ -0,0 +1,71 @@ +// One line of the agenda: when, what, where, on a surface tinted with its calendar's colour. Same +// rule as a block on the grid, and the same tokens, drawn a shade weaker: a row is several times +// the area of a block, and a fortnight of them at the grid's strength is a stack of highlighter. + +import { memo } from "react"; +import type { InstanceKey } from "../ipc"; +import { calVars, keyOf, vars, eventTitle } from "./GridModel"; +import { splitMatch, timeLabel, type AgendaItem } from "./AgendaModel"; + +interface AgendaRowProps { + item: AgendaItem; + selected: boolean; + /** Lowercased search terms to mark in the summary. Empty in the agenda proper. */ + terms: readonly string[]; + onSelect: (key: InstanceKey) => void; + onOpen?: (key: InstanceKey) => void; +} + +export const AgendaRow = memo(function AgendaRow({ + item, + selected, + terms, + onSelect, + onOpen, +}: AgendaRowProps) { + const { instance } = item; + const summary = eventTitle(instance); + + let hint = summary; + if (instance.location) hint += ` · ${instance.location}`; + if (instance.status === "tentative") hint += " · tentative"; + if (instance.pending) hint += " · not pushed yet"; + + const parts = terms.length > 0 ? splitMatch(summary, terms) : null; + + return ( + + ); +}); diff --git a/src/components/AgendaSearch.tsx b/src/components/AgendaSearch.tsx new file mode 100644 index 0000000..7615790 --- /dev/null +++ b/src/components/AgendaSearch.tsx @@ -0,0 +1,75 @@ +// Search. Results are an agenda list, which is the whole reason the agenda owns them: a search hit +// is a day, a time and a summary, and that is a row. +// +// It filters the instances the view has already loaded rather than asking the backend, because +// there is no search command in the IPC contract. See the note in `useAgendaSearch`. +// +// This renders its own surface but not the backdrop: drop it inside a `.overlay` and let that +// layer own Escape. + +import { useMemo } from "react"; +import type { Instance, InstanceKey } from "../ipc"; +import { useCalendarView } from "../store/useCalendarView"; +import { buildSearch, countItems, queryTerms } from "./AgendaModel"; +import { AgendaList } from "./AgendaList"; +import { useAgendaSearch } from "./useAgendaSearch"; +import "../styles/agenda.css"; + +interface AgendaSearchProps { + /** Defaults to whatever the calendar view has loaded. */ + instances?: readonly Instance[]; + onOpen?: (key: InstanceKey) => void; +} + +export function AgendaSearch({ instances, onOpen }: AgendaSearchProps) { + const loaded = useCalendarView((s) => s.instances); + const selected = useCalendarView((s) => s.selected); + const select = useCalendarView((s) => s.select); + const query = useAgendaSearch((s) => s.query); + const setQuery = useAgendaSearch((s) => s.setQuery); + + const source = instances ?? loaded; + const terms = useMemo(() => queryTerms(query), [query]); + const sections = useMemo(() => buildSearch(source, query), [source, query]); + const hits = countItems(sections); + + const note = + terms.length === 0 + ? "Type to search the days already loaded." + : hits === 0 + ? `Nothing matches "${query.trim()}".` + : null; + + return ( +
+
+ e.currentTarget.select()} + onChange={(e) => setQuery(e.target.value)} + /> + {hits > 0 && ( + + {hits} {hits === 1 ? "match" : "matches"} + + )} +
+ +
+ ); +} + +export default AgendaSearch; diff --git a/src/components/AgendaView.tsx b/src/components/AgendaView.tsx new file mode 100644 index 0000000..c47f351 --- /dev/null +++ b/src/components/AgendaView.tsx @@ -0,0 +1,46 @@ +// The agenda: thirty days as a flat chronological list, grouped by day, with no time axis at all. +// Nothing here is measured, solved or packed, which is what makes it cheap and what makes it a +// genuinely different view rather than a squashed grid. + +import { useMemo } from "react"; +import { spanFor, useCalendarView } from "../store/useCalendarView"; +import { buildAgenda } from "./AgendaModel"; +import { AgendaList } from "./AgendaList"; +import { keyId } from "./GridModel"; +import { openDetailsFor } from "./useDetails"; +import "../styles/agenda.css"; + +export function AgendaView() { + const view = useCalendarView((s) => s.view); + const anchor = useCalendarView((s) => s.anchor); + const instances = useCalendarView((s) => s.instances); + const phase = useCalendarView((s) => s.phase); + const error = useCalendarView((s) => s.error); + const selected = useCalendarView((s) => s.selected); + const select = useCalendarView((s) => s.select); + + // The store loads a day of padding either side, so the span is cut back to what it claims. + const sections = useMemo(() => { + const { from, to } = spanFor("agenda", anchor); + return buildAgenda(instances, from, to); + }, [instances, anchor]); + + if (view !== "agenda") return null; + + const note = + phase === "error" ? error : phase === "loading" && instances.length === 0 ? "Loading…" : null; + + return ( +
+ openDetailsFor(key, document.querySelector(`[data-id="${keyId(key)}"]`))} + note={note} + /> +
+ ); +} + +export default AgendaView; diff --git a/src/components/CalendarList.tsx b/src/components/CalendarList.tsx new file mode 100644 index 0000000..e9a3382 --- /dev/null +++ b/src/components/CalendarList.tsx @@ -0,0 +1,110 @@ +// Which calendars the grid draws, grouped by the account they came from. The checkbox writes +// straight through `useAccounts.setSelected`, which is already optimistic and reverts itself on a +// failure, so there is nothing to hold here. +// +// A calendar you do not own behaves differently once you try to edit it, so its role is on the +// row rather than hidden behind the event that will refuse to save. + +import { useEffect, useMemo } from "react"; +import type { Calendar } from "../ipc"; +import { useAccounts } from "../store/useAccounts"; +import { useCalendarView } from "../store/useCalendarView"; +import { useOverlays } from "../store/useOverlays"; +import { calSlot } from "./GridModel"; +import { roleLabel } from "./overlayModel"; +import { Sheet } from "./overlayShell"; + +interface Group { + accountId: string; + email: string; + calendars: Calendar[]; +} + +function group(calendars: readonly Calendar[], emails: ReadonlyMap): Group[] { + const groups = new Map(); + for (const calendar of calendars) { + let g = groups.get(calendar.accountId); + if (!g) { + g = { + accountId: calendar.accountId, + email: emails.get(calendar.accountId) ?? "Disconnected account", + calendars: [], + }; + groups.set(calendar.accountId, g); + } + g.calendars.push(calendar); + } + for (const g of groups.values()) { + g.calendars.sort( + (a, b) => Number(b.primary) - Number(a.primary) || a.summary.localeCompare(b.summary), + ); + } + return [...groups.values()].sort((a, b) => a.email.localeCompare(b.email)); +} + +export function CalendarList() { + const open = useOverlays((s) => s.open); + const close = useOverlays((s) => s.close); + const show = useOverlays((s) => s.show); + const accounts = useAccounts((s) => s.accounts); + const calendars = useAccounts((s) => s.calendars); + const setSelected = useAccounts((s) => s.setSelected); + const refresh = useAccounts((s) => s.refresh); + + const showing = open === "calendars"; + + useEffect(() => { + if (showing) void refresh(); + }, [showing, refresh]); + + const groups = useMemo( + () => group(calendars, new Map(accounts.map((a) => [a.id, a.email]))), + [calendars, accounts], + ); + + const toggle = (calendar: Calendar) => { + void setSelected(calendar.id, !calendar.selected); + // The instance stream is filtered on the Rust side, so the span has to be asked for again. + void useCalendarView.getState().load(); + }; + + return ( + + {groups.length === 0 ? ( +
+

No calendars here yet.

+ +
+ ) : ( + groups.map((g) => ( +
+ {g.email} + {g.calendars.map((calendar) => { + const role = roleLabel(calendar.accessRole); + return ( + + ); + })} +
+ )) + )} +
+ ); +} + +export default CalendarList; diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 0000000..0e35c96 --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -0,0 +1,94 @@ +// The eleven colours Google keeps against an event, plus the one that is not a colour: follow the +// calendar. Shared by the quick create card and the full editor so the choice reads the same in +// both, which is the whole point of it being a component rather than two rows of buttons. +// +// The swatches are painted in Google's own hex, because that is what `colorId` means to every +// other client this account is signed into. The grid mutes those hues onto its own eight when it +// paints the block (`calSlot` and `calHue` in GridModel), so a block is the nearest warm cousin of +// the swatch rather than the swatch itself. Showing the muted eight here instead would put three +// pairs of identical dots in the row and paint Graphite, which is grey, some arbitrary colour. +// +// One radio group, one tab stop: the arrow keys move between swatches. Twelve tab stops in a card +// this small would bury the buttons under them. + +import { useRef, type KeyboardEvent } from "react"; +import { EVENT_COLORS } from "../ipc"; +import { vars } from "./GridModel"; + +/** `null` is "same as calendar", and is always one of the options. */ +export type ColorChoice = string | null; + +export interface ColorPickerProps { + value: ColorChoice; + onChange: (colorId: ColorChoice) => void; + /** + * A CSS colour for the "same as calendar" swatch, so it previews what following the calendar + * would look like. The callers pass the same expression their calendar dot uses. + */ + calendarColor?: string; + disabled?: boolean; + /** Names the group for a screen reader. The visible label is the caller's business. */ + label?: string; +} + +const OPTIONS: ColorChoice[] = [null, ...EVENT_COLORS.map((c) => c.id)]; + +const nameOf = (id: ColorChoice): string => + id === null ? "Same as calendar" : (EVENT_COLORS.find((c) => c.id === id)?.name ?? "Colour"); + +export function ColorPicker({ + value, + onChange, + calendarColor = "var(--line-strong)", + disabled, + label = "Colour", +}: ColorPickerProps) { + const row = useRef(null); + + const at = OPTIONS.indexOf(OPTIONS.includes(value) ? value : null); + + function move(delta: number) { + const next = OPTIONS[(at + delta + OPTIONS.length) % OPTIONS.length]; + onChange(next); + // Focus follows the selection, which is what a radio group does and what makes holding an + // arrow key feel like scrubbing through the palette rather than tabbing through it. + const buttons = row.current?.querySelectorAll(".swatch"); + buttons?.[OPTIONS.indexOf(next)]?.focus(); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "ArrowRight" || e.key === "ArrowDown") move(1); + else if (e.key === "ArrowLeft" || e.key === "ArrowUp") move(-1); + else return; + e.preventDefault(); + } + + return ( +
+ {OPTIONS.map((id) => { + const on = id === OPTIONS[at]; + return ( +
+ ); +} + +export default ColorPicker; diff --git a/src/components/EventDetails.tsx b/src/components/EventDetails.tsx new file mode 100644 index 0000000..1773a25 --- /dev/null +++ b/src/components/EventDetails.tsx @@ -0,0 +1,403 @@ +// What clicking an event gets you: a card pinned to the block you clicked, not a modal in the +// middle of the window. Clicking an event is a local gesture and the answer should arrive in the +// same place your eye already is. +// +// Everything here is read-only except the three buttons at the bottom. Attendees and the +// conference link are shown and never touched: there is no RSVP flow, no availability finder and +// no scheduling links in v1, which is a deliberate cut in docs/design.md rather than an oversight. +// +// The card is not an `.overlay`. It has no backdrop, because a backdrop would swallow the click +// that opens the next event and make switching between two blocks take two clicks instead of one. +// A click outside closes it through one capture-phase pointer listener, and the keyboard is left +// to the keymap: pushing the `overlay` frame shadows the view's keys for as long as the card is up. + +import "../styles/details.css"; + +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { eventDelete } from "../api/events"; +import { useEscapeLayer } from "../escape"; +import type { Attendee, Calendar, Instance, InstanceKey, Scope } from "../ipc"; +import { useKeyContext } from "../keys/keymap"; +import { useAccounts } from "../store/useAccounts"; +import { useCalendarView } from "../store/useCalendarView"; +import { notify } from "../store/useToast"; +import { parseDescription } from "./EventDetailsHtml"; +import { RichText, openLink } from "./RichText"; +import { + badgesOf, + conferenceLabel, + orderGuests, + place, + type Bounds, + type Placement, +} from "./EventDetailsModel"; +// The title is the grid's, so a free/busy block reads "Busy" in both places or in neither. +import { calSlot, eventTitle, keyId } from "./GridModel"; +import { Icon } from "./Icon"; + +const CLOSE = "M18 6L6 18M6 6l12 12"; +import { attendeeTally, responseLabel, whenText } from "./overlayModel"; +import { Confirm } from "./overlayShell"; +import { useDetails, type AnchorRect } from "./useDetails"; +import { useEditor } from "./useEditor"; + +const CLOCK = "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0M12 7v5l3 2"; +const PIN = "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0M15 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0"; +const LINK = "M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2"; +const TEXT = "M4 6h16M4 12h12M4 18h9"; +const USERS = + "M17 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M12.5 7.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"; + +/** Clear of the window edges, and clear of the one row of chrome that is always resident. */ +const EDGE = 8; + +function viewBounds(): Bounds { + const bar = Number.parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--titlebar-h"), + ); + return { + top: (Number.isFinite(bar) ? bar : 0) + EDGE, + left: EDGE, + right: window.innerWidth - EDGE, + bottom: window.innerHeight - EDGE, + }; +} + +/** + * Mounted unconditionally and renders nothing until something is open. The inner card is keyed on + * the instance, so opening a second event mounts a fresh one rather than carrying the first one's + * half-finished delete over to it. + */ +export function EventDetails() { + const key = useDetails((s) => s.key); + const anchor = useDetails((s) => s.anchor); + if (!key) return null; + return ; +} + +interface DetailsCardProps { + target: InstanceKey; + anchor: AnchorRect | null; +} + +function DetailsCard({ target, anchor }: DetailsCardProps) { + const close = useDetails((s) => s.close); + const instances = useCalendarView((s) => s.instances); + const calendars = useAccounts((s) => s.calendars); + + const card = useRef(null); + const [spot, setSpot] = useState(null); + const [asking, setAsking] = useState(false); + const [busy, setBusy] = useState(false); + + const instance = useMemo( + () => + instances.find((i) => i.eventId === target.eventId && i.originalStart === target.originalStart) ?? + null, + [instances, target], + ); + + const recurring = instance?.recurring ?? false; + const readOnly = instance?.readOnly ?? true; + + useEscapeLayer(true, close); + // The scope step is not a `Confirm`, so it brings its own layer. Pushed after the card's, which + // is what makes Escape unwind the question first and the card second. + useEscapeLayer(asking && recurring, () => setAsking(false)); + useKeyContext("overlay"); + + // Measured, then placed, before the browser paints. The card also grows and shrinks as the + // delete question replaces its body, so the observer keeps it against its block either way. + useLayoutEffect(() => { + const el = card.current; + if (!el) return; + const measure = () => { + const box = el.getBoundingClientRect(); + setSpot(place(anchor, { width: box.width, height: box.height }, viewBounds())); + }; + measure(); + const watch = new ResizeObserver(measure); + watch.observe(el); + return () => watch.disconnect(); + }, [anchor]); + + // Focus has to leave the grid or the first keystroke goes to whatever the click left focused. + // Only once it has been placed: React flushes this before the placement commit, and the frame + // before that the card is still invisible at the origin. + const placed = spot !== null; + useEffect(() => { + if (placed) card.current?.focus({ preventScroll: true }); + }, [placed]); + + // A pointer anywhere else closes it, including on the header, which the card does not cover. + // Capture phase, so the block you clicked instead can open its own card in the same gesture. + useEffect(() => { + const onDown = (e: PointerEvent) => { + const el = card.current; + if (el && e.target instanceof Node && el.contains(e.target)) return; + close(); + }; + document.addEventListener("pointerdown", onDown, true); + return () => document.removeEventListener("pointerdown", onDown, true); + }, [close]); + + // The row heights are derived from the window, so after a resize the anchor describes somewhere + // the block no longer is. Repositioning against a stale rectangle is worse than getting out of + // the way. Scrolling is the agenda's list, and means the same thing. + useEffect(() => { + window.addEventListener("resize", close); + window.addEventListener("scroll", close, true); + return () => { + window.removeEventListener("resize", close); + window.removeEventListener("scroll", close, true); + }; + }, [close]); + + const edit = () => { + close(); + useEditor.getState().edit(target); + }; + + async function remove(scope: Scope) { + setBusy(true); + try { + await eventDelete(target, scope); + notify("Event deleted"); + void useCalendarView.getState().load(); + close(); + } catch (e) { + notify(`Could not delete the event: ${e}`); + setAsking(false); + } finally { + setBusy(false); + } + } + + return ( +
+ + {!instance ? ( + + ) : asking ? ( + recurring ? ( + void remove(s)} onBack={() => setAsking(false)} /> + ) : ( +
+ It goes from this calendar for everyone it was shared with.

} + confirmLabel="Delete" + busy={busy} + onConfirm={() => void remove("this")} + onCancel={() => setAsking(false)} + /> +
+ ) + ) : ( + <> + +
+ {!readOnly && ( + <> + + + + )} + +
+ + )} +
+ ); +} + +function Missing({ onClose }: { onClose: () => void }) { + return ( + <> +
+

That event is no longer in the range you are looking at.

+
+
+ +
+ + ); +} + +const SCOPES: [Scope, string, string][] = [ + ["this", "This event", "Only the occurrence you opened."], + ["following", "This and following", "This one and every later occurrence."], + ["all", "All events", "The whole series, including the ones already past."], +]; + +/** Deleting an occurrence of a series means three different things, so the card stops and asks. */ +function Scopes({ + instance, + busy, + onPick, + onBack, +}: { + instance: Instance; + busy: boolean; + onPick: (scope: Scope) => void; + onBack: () => void; +}) { + return ( + <> +
+

{eventTitle(instance)} repeats. Delete which of it?

+
+ {SCOPES.map(([scope, name, note]) => ( + + ))} +
+
+
+ +
+ + ); +} + +function Body({ instance, calendars }: { instance: Instance; calendars: readonly Calendar[] }) { + const badges = badgesOf(instance); + // Parsed, never interpolated. `EventDetailsHtml` explains why at length; the short version is + // that this string is written by whoever put the event on the calendar. + const description = useMemo(() => parseDescription(instance.description), [instance.description]); + const calendar = calendars.find((c) => c.id === instance.calendarId) ?? null; + const name = calendar?.summary || "Unknown calendar"; + const uri = instance.conference?.uri ?? null; + const tally = attendeeTally(instance.attendees); + const organizer = instance.organizer; + + return ( +
+
+

{eventTitle(instance)}

+ {badges.length > 0 && ( +
+ {badges.map((badge) => ( + + {badge} + + ))} +
+ )} +
+ +
+ + {whenText(instance)} +
+ +
+ + {name} +
+ + {instance.location && ( +
+ + {instance.location} +
+ )} + + {uri && ( +
+ + +
+ )} + + {description.nodes.length > 0 && ( +
+ +
+ +
+
+ )} + + {(tally || organizer) && ( +
+ +
+ + {tally ?? `Organised by ${organizer}`} + + {instance.attendees.length > 0 && ( +
    + {orderGuests(instance.attendees).map((a) => ( +
  • + + {nameOf(a)} + {a.organizer && organiser} + {a.self && you} + {a.optional && optional} + {responseLabel(a.responseStatus)} +
  • + ))} +
+ )} +
+
+ )} +
+ ); +} + +const nameOf = (a: Attendee): string => a.displayName || a.email; + +/** The parser's vocabulary, and the only elements a description can put on screen. */ + +export default EventDetails; diff --git a/src/components/EventDetailsHtml.test.ts b/src/components/EventDetailsHtml.test.ts new file mode 100644 index 0000000..8477421 --- /dev/null +++ b/src/components/EventDetailsHtml.test.ts @@ -0,0 +1,290 @@ +// Two halves. The first is "does a real description come out looking like what it says", using the +// shapes Google actually sends. The second is the one that matters: hostile input, because this +// string comes off the wire from whoever put the event on the calendar, and the only reason it is +// safe to render is that this module refuses to carry anything but a known list of elements and one +// vetted attribute. + +import { describe, expect, it } from "vitest"; +import { + decodeEntities, + hasMarkup, + parseDescription, + safeHref, + type DescElement, + type DescNode, +} from "./EventDetailsHtml"; + +/** The visible words, which is what "never renders" is asserted against. */ +function textOf(nodes: readonly DescNode[]): string { + return nodes + .map((node) => + node.kind === "text" ? node.text : node.kind === "break" ? "\n" : textOf(node.children), + ) + .join(""); +} + +/** Every element in the tree, flattened, so a whitelist can be asserted over the whole thing. */ +function elementsOf(nodes: readonly DescNode[]): DescElement[] { + const out: DescElement[] = []; + for (const node of nodes) { + if (node.kind !== "element") continue; + out.push(node); + out.push(...elementsOf(node.children)); + } + return out; +} + +const parse = (raw: string): DescNode[] => parseDescription(raw).nodes; +const links = (raw: string): DescElement[] => elementsOf(parse(raw)).filter((e) => e.tag === "a"); + +describe("plain text", () => { + it("keeps its newlines as breaks", () => { + const nodes = parse("Bring the deck.\nRoom 3.\n\nAsk Sam first."); + expect(nodes.map((n) => (n.kind === "text" ? n.text : "
"))).toEqual([ + "Bring the deck.", + "
", + "Room 3.", + "
", + "
", + "Ask Sam first.", + ]); + }); + + it("takes carriage returns and the Windows pair as one break each", () => { + expect(textOf(parse("one\r\ntwo\rthree"))).toBe("one\ntwo\nthree"); + }); + + it("is marked plain, so the card can keep its own spacing", () => { + expect(parseDescription(" indented\n more").plain).toBe(true); + expect(parseDescription("a
b").plain).toBe(false); + }); + + it("keeps an angle bracket that is arithmetic rather than markup", () => { + expect(hasMarkup("if a < b then 2 > 1")).toBe(false); + expect(textOf(parse("if a < b then 2 > 1"))).toBe("if a < b then 2 > 1"); + }); + + it("is nothing at all when it is empty or only whitespace", () => { + expect(parse("")).toEqual([]); + expect(parse(" \n ")).toEqual([]); + expect(parseDescription(null).nodes).toEqual([]); + expect(parseDescription(undefined).nodes).toEqual([]); + }); +}); + +describe("html", () => { + it("turns a br into a break", () => { + expect(parse("Job List:
tomorrow")).toEqual([ + { kind: "text", text: "Job List:" }, + { kind: "break" }, + { kind: "text", text: "tomorrow" }, + ]); + expect(parse("a
b")).toEqual([ + { kind: "text", text: "a" }, + { kind: "break" }, + { kind: "text", text: "b" }, + ]); + }); + + it("renders the description from the bug report as text and one link", () => { + const url = + "https://docs.google.com/spreadsheets/d/15rc9TXa46HwY3KsppvMR93T8L_7BTWacDnvisxPpzkE/edit?gid=71264683#gid=71264683"; + const nodes = parse( + `Job List:
${url}`, + ); + expect(textOf(nodes)).toBe(`Job List:\n${url}`); + const [link] = links(`Job List:
${url}`); + expect(link.href).toBe(url); + // The class attribute is gone, and nothing else came with it. + expect(Object.keys(link)).toEqual(["kind", "tag", "href", "children"]); + }); + + it("keeps the href of an anchor and the words inside it", () => { + const [link] = links('the sheet'); + expect(link.href).toBe("https://example.com/a?b=1&c=2"); + expect(textOf(link.children)).toBe("the sheet"); + }); + + it("takes a mailto link", () => { + expect(links('Sam')[0].href).toBe("mailto:sam@example.com"); + }); + + it("keeps emphasis, and normalises the tags that mean the same thing", () => { + const tags = elementsOf(parse("abcde")).map( + (e) => e.tag, + ); + expect(tags).toEqual(["b", "i", "i", "u", "s"]); + }); + + it("keeps lists as lists", () => { + const nodes = parse("
  • first
  • second
"); + expect(nodes).toHaveLength(1); + const list = nodes[0] as DescElement; + expect(list.tag).toBe("ul"); + expect(list.children.map((c) => (c as DescElement).tag)).toEqual(["li", "li"]); + expect(textOf(list.children)).toBe("firstsecond"); + expect((parse("
  1. one
")[0] as DescElement).tag).toBe("ol"); + }); + + it("closes an item that the sender left open", () => { + const list = parse("
  • first
  • second
")[0] as DescElement; + expect(list.children).toHaveLength(2); + expect(textOf(list.children[0].kind === "element" ? list.children[0].children : [])).toBe("first"); + }); + + it("gathers loose content in a list into an item, so a ul only ever holds li", () => { + const list = parse("
    stray
  • first
")[0] as DescElement; + expect(list.children.every((c) => c.kind === "element" && c.tag === "li")).toBe(true); + expect(textOf([list])).toBe("strayfirst"); + }); + + it("degrades an unknown tag to the text it wrapped", () => { + expect(textOf(parse('red and big'))).toBe( + "red and big", + ); + expect(elementsOf(parse("
cell
"))).toEqual([]); + expect(textOf(parse("
cell
"))).toBe("cell"); + }); + + it("collapses whitespace the way html does, and keeps a hard space", () => { + expect(textOf(parse("
a \n b
"))).toBe("a b"); + expect(textOf(parse("
a  b
"))).toBe("a\u00a0\u00a0b"); + }); + + it("decodes entities, named, decimal and hex", () => { + expect(textOf(parse("

Tom & Jerry <b> € 😀 "hi"

"))).toBe( + 'Tom & Jerry \u20ac \u{1F600} "hi"', + ); + }); + + it("leaves an entity it does not know exactly as it found it", () => { + expect(decodeEntities("&zzz; &")).toBe("&zzz; &"); + // Not a lookup on Object.prototype, whatever the name looks like. + expect(decodeEntities("&constructor; &toString;")).toBe("&constructor; &toString;"); + }); + + it("drops the padding a mail client leaves behind", () => { + expect(parse("

Only this.


")).toEqual([ + { kind: "text", text: "Only this." }, + ]); + }); + + it("keeps a blank line in the middle, but not a run of six", () => { + expect(parse("a



b").filter((n) => n.kind === "break")).toHaveLength(2); + }); + + it("survives tags that were never closed", () => { + expect(textOf(parse("
bold both"))).toBe("bold both"); + expect(textOf(parse("stray close
"))).toBe("stray close"); + }); + + it("survives a bare angle bracket next to real markup", () => { + expect(textOf(parse("5 < 6 really"))).toBe("5 < 6 really"); + }); + + it("drops comments and doctypes without dropping the text around them", () => { + expect(textOf(parse("beforeafter"))).toBe("beforeafter"); + expect(textOf(parse("before", lt + 4); + i = end < 0 ? html.length : end + 3; + continue; + } + if (html.startsWith("", lt); + i = end < 0 ? html.length : end + 1; + continue; + } + + MARKUP.lastIndex = lt; + const tag = MARKUP.exec(html); + if (!tag) { + // Something tag-shaped that never finished parsing: a browser drops it, and so do we, rather + // than spilling half an attribute list into the card as words. A bare `<` in prose, which is + // the far more common case, is text and stays text. + if (/^<\/?[a-zA-Z]/.test(html.slice(lt, lt + 3))) { + const end = html.indexOf(">", lt); + i = end < 0 ? html.length : end + 1; + } else { + addText("<"); + i = lt + 1; + } + continue; + } + i = MARKUP.lastIndex; + + const name = tag[2].toLowerCase(); + const attrs = tag[3] ?? ""; + + if (tag[1] === "/") { + for (let at = stack.length - 1; at > 0; at -= 1) { + if (stack[at].name !== name) continue; + while (stack.length > at) closeTop(); + break; + } + continue; + } + + if (RAW_TEXT.has(name)) { + // Everything up to the matching close is the element's raw text, and none of it is ours. + // An unterminated one swallows the rest of the description, which is what a browser does. + const close = new RegExp(`]*)?>`, "i"); + const found = close.exec(html.slice(i)); + i = found ? i + found.index + found[0].length : html.length; + continue; + } + + if (name === "br") { + top().children.push({ kind: "break" }); + continue; + } + if (VOID.has(name)) continue; + + // `
  • a
  • b` is two items, not one nested inside the other. + if (name === "li" && top().name === "li") closeTop(); + + let mapped = stack.length > MAX_DEPTH ? null : TAGS.get(name) ?? null; + let href: string | undefined; + if (mapped === "a") { + href = safeHref(attribute(attrs, "href")) ?? undefined; + // A link we will not follow is not a link. Its text still belongs to the reader. + if (!href) mapped = null; + } + + stack.push({ name, tag: mapped, href, children: [] }); + if (/\/\s*$/.test(attrs)) closeTop(); + } + + while (stack.length > 1) closeTop(); + return root.children; +} + +/** One attribute out of a tag's attribute text. Every other attribute is discarded unread. */ +function attribute(attrs: string, want: string): string | null { + ATTRIBUTE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = ATTRIBUTE.exec(attrs)) !== null) { + if (match[1].toLowerCase() !== want) continue; + const value = match[2]; + if (value === undefined) return null; + const quoted = value.startsWith('"') || value.startsWith("'"); + return quoted ? value.slice(1, -1) : value; + } + return null; +} + +const isBreak = (node: DescNode): boolean => node.kind === "break"; + +const isBlank = (node: DescNode): boolean => + isBreak(node) || (node.kind === "text" && node.text.trim() === ""); + +/** + * The cosmetic pass. Descriptions arrive padded with the editor's leftovers: empty paragraphs, a + * run of six `
    ` at the end, list markup with loose text in it. None of that should turn into + * white space the reader has to scroll past. + */ +function tidy(nodes: readonly DescNode[], parent: DescTag | null): DescNode[] { + const out: DescNode[] = []; + + for (const node of nodes) { + if (node.kind === "text") { + out.push(node); + continue; + } + if (node.kind === "break") { + // Two is a blank line. Beyond that it is someone leaning on the return key. + const run = out.length >= 2 && isBreak(out[out.length - 1]) && isBreak(out[out.length - 2]); + if (!run) out.push(node); + continue; + } + + // An `
  • ` that lost its list reads as a paragraph rather than as a stray bullet. + const tag = node.tag === "li" && parent !== "ul" && parent !== "ol" ? "block" : node.tag; + let children = tidy(node.children, tag); + if (tag === "ul" || tag === "ol") children = intoItems(children); + if (children.length === 0) continue; + out.push( + node.href + ? { kind: "element", tag, href: node.href, children } + : { kind: "element", tag, children }, + ); + } + + return trimEdges(out); +} + +/** Loose content directly inside a list is gathered into items, so `ul` only ever holds `li`. */ +function intoItems(children: readonly DescNode[]): DescNode[] { + const items: DescNode[] = []; + let loose: DescNode[] = []; + const flush = (): void => { + const trimmed = trimEdges(loose); + if (trimmed.length > 0) items.push({ kind: "element", tag: "li", children: trimmed }); + loose = []; + }; + for (const child of children) { + if (child.kind === "element" && child.tag === "li") { + flush(); + items.push(child); + } else loose.push(child); + } + flush(); + return items; +} + +/** Blank leading and trailing nodes go, and the text at either end loses its outer whitespace. */ +function trimEdges(nodes: readonly DescNode[]): DescNode[] { + let from = 0; + let to = nodes.length; + while (from < to && isBlank(nodes[from])) from += 1; + while (to > from && isBlank(nodes[to - 1])) to -= 1; + const out = nodes.slice(from, to); + if (out.length === 0) return []; + + const first = out[0]; + if (first.kind === "text") out[0] = { kind: "text", text: first.text.replace(/^[ \t]+/, "") }; + const last = out[out.length - 1]; + if (last.kind === "text") out[out.length - 1] = { kind: "text", text: last.text.replace(/[ \t]+$/, "") }; + return out.filter((node) => node.kind !== "text" || node.text !== ""); +} diff --git a/src/components/EventDetailsModel.test.ts b/src/components/EventDetailsModel.test.ts new file mode 100644 index 0000000..5e66b28 --- /dev/null +++ b/src/components/EventDetailsModel.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + badgesOf, + conferenceLabel, + isStrandedAnchor, + orderGuests, + place, + type Bounds, +} from "./EventDetailsModel"; +import type { Attendee, Instance } from "../ipc"; + +const bounds: Bounds = { top: 54, left: 8, right: 1192, bottom: 792 }; +const card = { width: 336, height: 300 }; + +describe("place", () => { + it("prefers the right of the block, aligned to its top", () => { + const at = place({ top: 200, left: 300, width: 120, height: 40 }, card, bounds); + expect(at).toEqual({ top: 200, left: 430, side: "right" }); + }); + + it("flips to the left when the right would not fit", () => { + const at = place({ top: 200, left: 1000, width: 150, height: 40 }, card, bounds); + expect(at).toEqual({ top: 200, left: 654, side: "left" }); + }); + + it("goes below when neither side fits, centred across the block", () => { + const narrow: Bounds = { top: 54, left: 8, right: 420, bottom: 792 }; + const at = place({ top: 100, left: 120, width: 160, height: 40 }, card, narrow); + expect(at.side).toBe("below"); + expect(at.top).toBe(150); + expect(at.left).toBe(32); + }); + + it("goes above when there is no room below", () => { + const shallow: Bounds = { top: 54, left: 8, right: 420, bottom: 600 }; + const at = place({ top: 500, left: 120, width: 160, height: 40 }, card, shallow); + expect(at).toEqual({ top: 190, left: 32, side: "above" }); + }); + + it("never lets the card hang off the top or the bottom", () => { + const high = place({ top: 30, left: 300, width: 120, height: 60 }, { width: 336, height: 700 }, bounds); + expect(high.top).toBe(54); + const low = place({ top: 700, left: 300, width: 120, height: 20 }, card, bounds); + expect(low.top).toBe(492); + expect(low.top + card.height).toBeLessThanOrEqual(bounds.bottom); + }); + + it("takes the middle of the window when the anchor has no size", () => { + const at = place({ top: 400, left: 600, width: 0, height: 0 }, card, bounds); + expect(at.side).toBe("centre"); + expect(at.left).toBe(432); + expect(at.top).toBe(273); + }); + + it("takes the middle of the window when there is no anchor at all", () => { + expect(place(null, card, bounds).side).toBe("centre"); + }); + + it("stays inside bounds smaller than the card itself", () => { + const tiny: Bounds = { top: 54, left: 8, right: 200, bottom: 200 }; + const at = place({ top: 100, left: 20, width: 40, height: 10 }, card, tiny); + expect(at.top).toBe(54); + expect(at.left).toBe(8); + }); +}); + +describe("isStrandedAnchor", () => { + it("catches a rectangle scrolled off the top", () => { + expect(isStrandedAnchor({ top: -80, left: 300, width: 100, height: 40 }, bounds)).toBe(true); + }); + + it("catches a rectangle past the right edge", () => { + expect(isStrandedAnchor({ top: 200, left: 1400, width: 100, height: 40 }, bounds)).toBe(true); + }); + + it("accepts a rectangle only partly on screen", () => { + expect(isStrandedAnchor({ top: 20, left: 300, width: 100, height: 80 }, bounds)).toBe(false); + }); +}); + +const instance = (over: Partial = {}): Instance => ({ + colorId: null, + eventId: "ev-1", + calendarId: "cal-1", + accountId: "acct-1", + originalStart: null, + start: "2026-08-11T09:00:00+05:30", + end: "2026-08-11T09:30:00+05:30", + startMs: 0, + endMs: 0, + allDay: false, + summary: "Stand-up", + description: null, + location: null, + status: "confirmed", + recurring: false, + colorHex: "#6f6194", + etag: null, + organizer: null, + attendees: [], + conference: null, + readOnly: false, + pending: false, + ...over, +}); + +describe("badgesOf", () => { + it("says nothing about an ordinary event", () => { + expect(badgesOf(instance())).toEqual([]); + }); + + it("names everything unusual about the occurrence", () => { + expect(badgesOf(instance({ status: "tentative", recurring: true, pending: true, readOnly: true }))).toEqual([ + "tentative", + "repeats", + "not synced yet", + "read only", + ]); + }); +}); + +describe("conferenceLabel", () => { + it("prefers the label Google sent", () => { + const c = { kind: "hangoutsMeet", uri: "https://meet.google.com/a", label: "meet.google.com/a" }; + expect(conferenceLabel(instance({ conference: c }))).toBe("meet.google.com/a"); + }); + + it("names Meet when there is no label", () => { + const c = { kind: "hangoutsMeet", uri: "https://meet.google.com/a", label: null }; + expect(conferenceLabel(instance({ conference: c }))).toBe("Google Meet"); + }); + + it("is empty when there is no conference", () => { + expect(conferenceLabel(instance())).toBe(""); + }); +}); + +const guest = (over: Partial): Attendee => ({ + email: "a@example.com", + displayName: null, + responseStatus: "needsAction", + organizer: false, + self: false, + optional: false, + ...over, +}); + +describe("orderGuests", () => { + it("puts the organiser first and the declines last", () => { + const list = [ + guest({ email: "no@example.com", responseStatus: "declined" }), + guest({ email: "yes@example.com", responseStatus: "accepted" }), + guest({ email: "boss@example.com", responseStatus: "needsAction", organizer: true }), + guest({ email: "maybe@example.com", responseStatus: "tentative" }), + ]; + expect(orderGuests(list).map((a) => a.email)).toEqual([ + "boss@example.com", + "yes@example.com", + "maybe@example.com", + "no@example.com", + ]); + }); + + it("does not mutate what it was handed", () => { + const list = [guest({ email: "b@example.com", responseStatus: "declined" }), guest({ email: "a@example.com" })]; + orderGuests(list); + expect(list[0].email).toBe("b@example.com"); + }); +}); diff --git a/src/components/EventDetailsModel.ts b/src/components/EventDetailsModel.ts new file mode 100644 index 0000000..08169ff --- /dev/null +++ b/src/components/EventDetailsModel.ts @@ -0,0 +1,144 @@ +// Pure helpers for the details card: where it goes, and the small amount of vocabulary its +// content needs. Nothing here holds state or touches the DOM, which is what makes it testable in +// a node run. +// +// The placement is the only genuinely fiddly part. The card is anchored to the block that was +// clicked rather than centred, so it has to find a side that fits and then stay inside the window +// whatever happens, including when the anchor is offscreen, has no size at all, or the card is +// taller than the space it was given. + +import type { Attendee, Instance } from "../ipc"; + +/** Viewport coordinates, the same shape `useDetails` stores and a DOMRect satisfies. */ +export interface Rect { + top: number; + left: number; + width: number; + height: number; +} + +export interface Size { + width: number; + height: number; +} + +/** The region the card is allowed to occupy, in viewport coordinates. */ +export interface Bounds { + top: number; + left: number; + right: number; + bottom: number; +} + +/** Which side of the anchor the card ended up on. `centre` means it gave up and took the window. */ +export type Side = "right" | "left" | "below" | "above" | "centre"; + +export interface Placement { + top: number; + left: number; + side: Side; +} + +/** The breathing room between the block and the card. */ +export const ANCHOR_GAP = 10; + +function clamp(value: number, low: number, high: number): number { + if (high < low) return low; + return value < low ? low : value > high ? high : value; +} + +/** + * True when the rectangle is not worth aiming at: absent, sizeless, or entirely outside the + * region the card may occupy. `openDetailsFor` hands over a zero-size rect at the middle of the + * window when it had no element to measure, and that is exactly the case this catches. + */ +export function isStrandedAnchor(anchor: Rect | null, bounds: Bounds): boolean { + if (!anchor) return true; + if (anchor.width <= 0 && anchor.height <= 0) return true; + return ( + anchor.left + anchor.width <= bounds.left || + anchor.left >= bounds.right || + anchor.top + anchor.height <= bounds.top || + anchor.top >= bounds.bottom + ); +} + +/** + * Right of the block, then left, then below, then above, then the middle of the window. The first + * side the card fits on wins, and the cross axis is clamped rather than allowed to hang off an + * edge, so the card is always whole and always on screen. + */ +export function place(anchor: Rect | null, card: Size, bounds: Bounds, gap = ANCHOR_GAP): Placement { + const middle = (): Placement => ({ + top: clamp(Math.round((bounds.top + bounds.bottom - card.height) / 2), bounds.top, bounds.bottom - card.height), + left: clamp(Math.round((bounds.left + bounds.right - card.width) / 2), bounds.left, bounds.right - card.width), + side: "centre", + }); + + if (isStrandedAnchor(anchor, bounds)) return middle(); + const a = anchor as Rect; + + const beside = (left: number, side: Side): Placement => ({ + top: clamp(Math.round(a.top), bounds.top, bounds.bottom - card.height), + left: Math.round(left), + side, + }); + + const right = a.left + a.width + gap; + if (right + card.width <= bounds.right) return beside(right, "right"); + + const left = a.left - gap - card.width; + if (left >= bounds.left) return beside(left, "left"); + + const across = clamp( + Math.round(a.left + a.width / 2 - card.width / 2), + bounds.left, + bounds.right - card.width, + ); + + const below = a.top + a.height + gap; + if (below + card.height <= bounds.bottom) return { top: Math.round(below), left: across, side: "below" }; + + const above = a.top - gap - card.height; + if (above >= bounds.top) return { top: Math.round(above), left: across, side: "above" }; + + return middle(); +} + +const RESPONSE_ORDER: Record = { + accepted: 0, + tentative: 1, + needsAction: 2, + declined: 3, +}; + +/** The organiser first, then who is coming, then who is not. Read-only: v1 has no RSVP flow. */ +export function orderGuests(attendees: readonly Attendee[]): Attendee[] { + return attendees + .slice() + .sort( + (a, b) => + Number(b.organizer) - Number(a.organizer) || + RESPONSE_ORDER[a.responseStatus] - RESPONSE_ORDER[b.responseStatus] || + (a.displayName ?? a.email).localeCompare(b.displayName ?? b.email), + ); +} + +/** What to call the conference link, falling back through label, kind, then something honest. */ +export function conferenceLabel(instance: Instance): string { + const conference = instance.conference; + if (!conference) return ""; + if (conference.label) return conference.label; + if (conference.kind === "hangoutsMeet") return "Google Meet"; + return conference.kind || "Join"; +} + +/** The badges under the title: everything unusual about this occurrence, and nothing else. */ +export function badgesOf(instance: Instance): string[] { + const out: string[] = []; + if (instance.status === "tentative") out.push("tentative"); + if (instance.recurring) out.push("repeats"); + if (instance.pending) out.push("not synced yet"); + if (instance.readOnly) out.push("read only"); + return out; +} diff --git a/src/components/EventEditor.tsx b/src/components/EventEditor.tsx new file mode 100644 index 0000000..6f01cb9 --- /dev/null +++ b/src/components/EventEditor.tsx @@ -0,0 +1,602 @@ +// One event, created or edited. Summary, calendar, when, where and the description are writable; +// attendees and the conference link are not, deliberately, because v1 has no RSVP flow and no +// scheduling anything. +// +// The one genuinely subtle thing here is scope. Saving or deleting an occurrence of a recurring +// series does something different to the server depending on whether you meant this one, this one +// and everything after it, or the whole series, so the panel stops and asks. There is no default, +// because a default is exactly what gets missed. + +import { useMemo, useState, type FormEvent } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { eventCreate, eventDelete, eventUpdate } from "../api/events"; +import { useEscapeLayer } from "../escape"; +import type { Calendar, EventDraft, EventPatch, Instance, InstanceKey, Scope } from "../ipc"; +import { useAccounts } from "../store/useAccounts"; +import { useCalendarView } from "../store/useCalendarView"; +import { useOverlays } from "../store/useOverlays"; +import { notify } from "../store/useToast"; +import { parseDateOnly, toDateOnly, toOffsetIso } from "../time"; +import "../styles/create.css"; +import { ColorPicker } from "./ColorPicker"; +import { parseDescription } from "./EventDetailsHtml"; +import { Icon } from "./Icon"; +import { RichText } from "./RichText"; +import { + allDayEndInput, + allDayEndWire, + attendeeTally, + canWrite, + defaultRange, + fromDateTime, + responseLabel, + toTimeOnly, + whenText, +} from "./overlayModel"; +import { Confirm, Sheet } from "./overlayShell"; +import { calSlot, eventTitle } from "./GridModel"; +import { useEditor, type EditorSeed, type EditorTarget } from "./useEditor"; + +const FORM_ID = "event-editor-form"; + +const LINK = "M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2"; +const REPEAT = "M17 2l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3"; +const LOCK = "M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4"; + +interface Form { + summary: string; + calendarId: string; + allDay: boolean; + startDate: string; + startTime: string; + endDate: string; + endTime: string; + location: string; + description: string; + /** Google's per-event colour id. Empty means the event takes its calendar's colour. */ + colorId: string; +} + +interface Times { + startMs: number; + endMs: number; + start: string; + end: string; + valid: boolean; +} + +function formFor(instance: Instance | null, seed: EditorSeed, anchor: number, fallback: string): Form { + if (instance) { + return { + summary: instance.summary, + calendarId: instance.calendarId, + allDay: instance.allDay, + startDate: toDateOnly(instance.startMs), + startTime: toTimeOnly(instance.startMs), + endDate: instance.allDay ? allDayEndInput(instance.endMs) : toDateOnly(instance.endMs), + endTime: toTimeOnly(instance.endMs), + location: instance.location ?? "", + description: instance.description ?? "", + colorId: instance.colorId ?? "", + }; + } + const range = defaultRange(anchor); + const startMs = seed.startMs ?? range.startMs; + const endMs = seed.endMs ?? startMs + (range.endMs - range.startMs); + const allDay = seed.allDay ?? false; + return { + summary: seed.summary ?? "", + calendarId: seed.calendarId ?? fallback, + allDay, + startDate: toDateOnly(startMs), + startTime: toTimeOnly(startMs), + endDate: allDay ? allDayEndInput(endMs) : toDateOnly(endMs), + endTime: toTimeOnly(endMs), + location: seed.location ?? "", + description: seed.description ?? "", + colorId: seed.colorId ?? "", + }; +} + +/** What the form means in wire terms, and whether it means anything at all. */ +function timesOf(form: Form): Times { + if (form.allDay) { + const startMs = parseDateOnly(form.startDate); + const endMs = parseDateOnly(form.endDate); + const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs; + return { startMs, endMs, start: form.startDate, end: valid ? allDayEndWire(form.endDate) : "", valid }; + } + const startMs = fromDateTime(form.startDate, form.startTime); + const endMs = fromDateTime(form.endDate, form.endTime); + const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs; + return { + startMs, + endMs, + start: valid ? toOffsetIso(startMs) : "", + end: valid ? toOffsetIso(endMs) : "", + valid, + }; +} + +/** Only what moved. An absent field means unchanged; an empty string clears the two that allow it. */ +function patchOf(form: Form, times: Times, instance: Instance): EventPatch { + const patch: EventPatch = {}; + if (form.summary !== instance.summary) patch.summary = form.summary; + if (form.location !== (instance.location ?? "")) patch.location = form.location; + if (form.description !== (instance.description ?? "")) patch.description = form.description; + if (form.calendarId !== instance.calendarId) patch.calendarId = form.calendarId; + // The empty string is meaningful here: it is how the event is put back on its calendar's colour. + if (form.colorId !== (instance.colorId ?? "")) patch.colorId = form.colorId; + + // The panel holds the inclusive last day of an all-day event, so compare like with like. + const wasEnd = instance.allDay ? parseDateOnly(allDayEndInput(instance.endMs)) : instance.endMs; + if (form.allDay !== instance.allDay || times.startMs !== instance.startMs || times.endMs !== wasEnd) { + patch.allDay = form.allDay; + patch.start = times.start; + patch.end = times.end; + } + return patch; +} + +const targetId = (target: EditorTarget): string => + target.mode === "create" ? "create" : `edit ${target.key.eventId} ${target.key.originalStart ?? ""}`; + +export interface EventEditorProps { + /** Where a new event lands when nothing seeded a calendar. */ + defaultCalendarId?: string; +} + +/** + * Nothing is resident: the panel below only exists while the overlay is open, which is what keeps + * its form state from surviving into the next event you open. + */ +export function EventEditor({ defaultCalendarId }: EventEditorProps) { + const open = useOverlays((s) => s.open); + const target = useEditor((s) => s.target); + if (open !== "editor") return null; + const resolved: EditorTarget = target ?? { mode: "create", seed: {} }; + return ( + + ); +} + +interface EditorPanelProps { + target: EditorTarget; + defaultCalendarId?: string; +} + +function EditorPanel({ target, defaultCalendarId }: EditorPanelProps) { + const dismiss = useEditor((s) => s.close); + const calendars = useAccounts((s) => s.calendars); + const instances = useCalendarView((s) => s.instances); + const anchor = useCalendarView((s) => s.anchor); + + const key: InstanceKey | null = target.mode === "edit" ? target.key : null; + + const instance = useMemo( + () => + key + ? (instances.find((i) => i.eventId === key.eventId && i.originalStart === key.originalStart) ?? null) + : null, + [instances, key], + ); + + const writable = useMemo( + () => calendars.filter((c) => canWrite(c.accessRole) || c.id === instance?.calendarId), + [calendars, instance], + ); + + const fallback = defaultCalendarId ?? writable.find((c) => c.primary)?.id ?? writable[0]?.id ?? ""; + + const [form, setForm] = useState
    (() => + formFor(instance, target.mode === "create" ? target.seed : {}, anchor, fallback), + ); + const [ask, setAsk] = useState<{ action: "save" | "delete" } | null>(null); + const [busy, setBusy] = useState(false); + + // The swatch for "same as calendar" previews the calendar it would follow, drawn in the slot + // that calendar's dot is drawn in everywhere else rather than in the hex Google stores. + const chosen = calendars.find((c) => c.id === form.calendarId) ?? null; + const calendarColor = chosen + ? `var(--cal-${calSlot(chosen.colorHex, chosen.id)})` + : "var(--line-strong)"; + + const recurring = instance?.recurring ?? false; + const readOnly = instance?.readOnly ?? false; + const times = timesOf(form); + const scopeStep = ask !== null && recurring; + + useEscapeLayer(scopeStep, () => setAsk(null)); + + const set = (field: K, value: Form[K]) => + setForm((prev) => ({ ...prev, [field]: value })); + + const close = () => { + setAsk(null); + dismiss(); + }; + + async function run(action: "save" | "delete", scope: Scope) { + setBusy(true); + try { + if (action === "delete") { + if (!key) { + close(); + return; + } + await eventDelete(key, scope); + notify("Event deleted"); + } else if (key && instance) { + const patch = patchOf(form, times, instance); + if (Object.keys(patch).length > 0) await eventUpdate(key, patch, scope); + } else { + const draft: EventDraft = { + calendarId: form.calendarId, + summary: form.summary.trim() || "Untitled", + description: form.description || null, + location: form.location || null, + start: times.start, + end: times.end, + allDay: form.allDay, + // Absent rather than empty when it follows the calendar, which is what the wire reads + // as no override. + ...(form.colorId ? { colorId: form.colorId } : {}), + }; + await eventCreate(draft); + } + void useCalendarView.getState().load(); + close(); + } catch (e) { + notify(action === "delete" ? `Could not delete the event: ${e}` : `Could not save the event: ${e}`); + setAsk(null); + } finally { + setBusy(false); + } + } + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!times.valid || !form.calendarId || busy) return; + if (recurring) setAsk({ action: "save" }); + else void run("save", "this"); + }; + + if (scopeStep && ask) { + const action = ask.action; + return ( + setAsk(null)}> + Back + + } + > +

    + {instance?.summary || "This event"} repeats. Choose what this{" "} + {action === "delete" ? "deletion" : "change"} applies to. +

    +
    + {SCOPES.map(([scope, name, note]) => ( + + ))} +
    +
    + ); + } + + if (ask) { + return ( + + It goes from this calendar for everyone it was shared with.

    } + confirmLabel="Delete" + busy={busy} + onConfirm={() => void run("delete", "this")} + onCancel={() => setAsk(null)} + /> +
    + ); + } + + if (target.mode === "edit" && !instance) { + return ( + + Close + + } + > +

    That event is no longer in the range you are looking at.

    +
    + ); + } + + if (readOnly && instance) { + return ( + + Close + + } + > + + + ); + } + + return ( + + {target.mode === "edit" && ( + + )} + + + + } + > + e.stopPropagation()}> + + + + +
    + Colour + set("colorId", colorId ?? "")} + calendarColor={calendarColor} + /> +
    + +
    + When +
    + set("startDate", e.target.value)} + /> + {!form.allDay && ( + set("startTime", e.target.value)} + /> + )} + to + set("endDate", e.target.value)} + /> + {!form.allDay && ( + set("endTime", e.target.value)} + /> + )} +
    + + {!times.valid && ( + + That ends before it starts. + + )} + {recurring && ( + + + Repeats, so saving will ask what the change applies to. + + )} +
    + + + +