Margin Calendar: a Google Calendar client for desktop and phone

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

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

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

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

No files matched your search

+1
View File
@@ -0,0 +1 @@
/target/
+6025
View File
File diff suppressed because it is too large. Load diff
+60
View File
@@ -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"
+29
View File
@@ -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");
}
+7
View File
@@ -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"]
}
+14
View File
@@ -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"
]
}
+12
View File
@@ -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
+20
View File
@@ -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
+6
View File
@@ -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
@@ -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")
+21
View File
@@ -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
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.margin_calendar"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- The OAuth callback filter below is written from plugins.deep-link.mobile in
tauri.conf.json by the plugin's build.rs. Edit it there, not here: the same config
is what DeepLinkPlugin.kt matches URLs against before it forwards them to Rust. -->
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter >
<action android:name="android.intent.action.VIEW" />
<!-- ChromeOS ARC++ uses a different action for deep links -->
<action android:name="org.chromium.arc.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="studio.margin.calendar" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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)
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.margin_calendar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">"Margin Calendar"</string>
<string name="main_activity_title">"Margin Calendar"</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.margin_calendar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -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")
}
@@ -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")
}
@@ -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()
}
}
@@ -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<Project> {
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<ApplicationExtension> {
@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
)
}
}
}
}
}
+24
View File
@@ -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
Binary file not shown.
@@ -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
+185
View File
@@ -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" "$@"
+89
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+3
View File
@@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "[email protected]",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>debugging</string>
</dict>
</plist>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+21
View File
@@ -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
@@ -0,0 +1,8 @@
#pragma once
namespace ffi {
extern "C" {
void start_app();
}
}
@@ -0,0 +1,6 @@
#include "bindings/bindings.h"
int main(int argc, char * argv[]) {
ffi::start_app();
return 0;
}
@@ -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 = "<group>"; };
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 = "<group>"; };
06C647F836A02A8532C617DA /* read.rs */ = {isa = PBXFileReference; path = read.rs; sourceTree = "<group>"; };
11EB0C96B4964DD8EB696D63 /* api.rs */ = {isa = PBXFileReference; path = api.rs; sourceTree = "<group>"; };
1578DA0D4DFD83FCFC7CD967 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
19E9713B598286F9A58F6524 /* auth.rs */ = {isa = PBXFileReference; path = auth.rs; sourceTree = "<group>"; };
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 = "<group>"; };
42AC5D9341EC41579150A559 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
463516468F6D0DFDE911E397 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
482B1BACC9A878BD6000F332 /* tests.rs */ = {isa = PBXFileReference; path = tests.rs; sourceTree = "<group>"; };
5F2BD57CDF2418F2114AC00F /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = "<group>"; };
601DA7E7CE0314ED76601AB8 /* library.rs */ = {isa = PBXFileReference; path = library.rs; sourceTree = "<group>"; };
63440E6A8AA7263282FF0880 /* schema.rs */ = {isa = PBXFileReference; path = schema.rs; sourceTree = "<group>"; };
65E80ECEB0844651FB03158B /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
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 = "<group>"; };
7DF7225718DEED80CC685D70 /* secrets.rs */ = {isa = PBXFileReference; path = secrets.rs; sourceTree = "<group>"; };
83A2962170C125E12A298599 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = "<group>"; };
8879346700D62B7DDE676FB9 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; };
9FDB21551CF4959451A03485 /* pull.rs */ = {isa = PBXFileReference; path = pull.rs; sourceTree = "<group>"; };
A19747E3FB043C2FC441866B /* margin-calendar_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "margin-calendar_iOS.entitlements"; sourceTree = "<group>"; };
A2B433E2EAB81F688739BD72 /* push.rs */ = {isa = PBXFileReference; path = push.rs; sourceTree = "<group>"; };
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 = "<group>"; };
AF43A8B9F86EC0EE625D5AC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
BB370CF51946E1086F725448 /* dto.rs */ = {isa = PBXFileReference; path = dto.rs; sourceTree = "<group>"; };
C2072E3C608C4C15356BFD25 /* sync.rs */ = {isa = PBXFileReference; path = sync.rs; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* 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 = "<group>";
};
1DFBF6D830071E5D1FE33720 /* margin-calendar_iOS */ = {
isa = PBXGroup;
children = (
AF43A8B9F86EC0EE625D5AC0 /* Info.plist */,
A19747E3FB043C2FC441866B /* margin-calendar_iOS.entitlements */,
);
path = "margin-calendar_iOS";
sourceTree = "<group>";
};
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 = "<group>";
};
31E412A61C3C36BBEA22CAB4 /* sync */ = {
isa = PBXGroup;
children = (
42975F72473A262C8DC12B2D /* model.rs */,
9FDB21551CF4959451A03485 /* pull.rs */,
A2B433E2EAB81F688739BD72 /* push.rs */,
482B1BACC9A878BD6000F332 /* tests.rs */,
04B2978EA3C716D6EB74712D /* transport.rs */,
);
path = sync;
sourceTree = "<group>";
};
701F7017030A43C5B1F02E43 /* Externals */ = {
isa = PBXGroup;
children = (
);
path = Externals;
sourceTree = "<group>";
};
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 = "<group>";
};
819B89450E4EF4F878332BE3 /* bindings */ = {
isa = PBXGroup;
children = (
1578DA0D4DFD83FCFC7CD967 /* bindings.h */,
);
path = bindings;
sourceTree = "<group>";
};
8465EC9E3B20D359B37ED5CC /* google */ = {
isa = PBXGroup;
children = (
11EB0C96B4964DD8EB696D63 /* api.rs */,
19E9713B598286F9A58F6524 /* auth.rs */,
5F2BD57CDF2418F2114AC00F /* mod.rs */,
7DF7225718DEED80CC685D70 /* secrets.rs */,
);
path = google;
sourceTree = "<group>";
};
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 = "<group>";
};
C7CA186F2513338D60A6B00E /* margin-calendar */ = {
isa = PBXGroup;
children = (
65E80ECEB0844651FB03158B /* main.mm */,
819B89450E4EF4F878332BE3 /* bindings */,
);
path = "margin-calendar";
sourceTree = "<group>";
};
F2D7B8DA6405BBC1A306E0F5 /* Sources */ = {
isa = PBXGroup;
children = (
C7CA186F2513338D60A6B00E /* margin-calendar */,
);
path = Sources;
sourceTree = "<group>";
};
FD095DBA118C3E02B31F8572 /* Products */ = {
isa = PBXGroup;
children = (
75B8603218CF087545CA23FE /* margin-calendar_iOS.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildSystemType</key>
<string>Original</string>
<key>DisableBuildSystemDeprecationDiagnostic</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "44343353A6DEDC69656FAA54"
BuildableName = "margin-calendar_iOS.app"
BlueprintName = "margin-calendar_iOS"
ReferencedContainer = "container:margin-calendar.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "44343353A6DEDC69656FAA54"
BuildableName = "margin-calendar_iOS.app"
BlueprintName = "margin-calendar_iOS"
ReferencedContainer = "container:margin-calendar.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</TestAction>
<LaunchAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "44343353A6DEDC69656FAA54"
BuildableName = "margin-calendar_iOS.app"
BlueprintName = "margin-calendar_iOS"
ReferencedContainer = "container:margin-calendar.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "release"
shouldUseLaunchSchemeArgsEnv = "NO"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "44343353A6DEDC69656FAA54"
BuildableName = "margin-calendar_iOS.app"
BlueprintName = "margin-calendar_iOS"
ReferencedContainer = "container:margin-calendar.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>studio.margin.calendar</string>
</array>
<key>CFBundleURLName</key>
<string>studio.margin.calendar</string>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+88
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+177
View File
@@ -0,0 +1,177 @@
// The IPC contract. Every type here has a matching declaration in src/ipc.ts. Both sides are
// frozen once written: implementation modules add bodies, not fields.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Account {
pub id: String,
pub email: String,
pub connected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Calendar {
pub id: String,
pub account_id: String,
pub summary: String,
pub description: Option<String>,
pub color_hex: String,
pub selected: bool,
pub access_role: String,
pub time_zone: String,
pub primary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attendee {
pub email: String,
pub display_name: Option<String>,
/// needsAction | declined | tentative | accepted
pub response_status: String,
pub organizer: bool,
#[serde(rename = "self")]
pub is_self: bool,
pub optional: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Conference {
/// hangoutsMeet, addOn, and so on
pub kind: String,
pub uri: Option<String>,
pub label: Option<String>,
}
/// Identifies one occurrence of a series, stable across syncs. `original_start` is None for a
/// single event and the unmoved start of the occurrence for anything recurring.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstanceKey {
pub event_id: String,
pub original_start: Option<String>,
}
/// One event occurrence, already expanded and already converted to the local zone. `start` and
/// `end` are RFC3339 with an offset, except when `all_day`, where they are date-only `YYYY-MM-DD`
/// and must never be shifted into a zone. `start_ms` and `end_ms` are epoch milliseconds, with
/// all-day events pinned to local midnight, and `end_ms` is exclusive.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Instance {
pub event_id: String,
pub calendar_id: String,
pub account_id: String,
pub original_start: Option<String>,
pub start: String,
pub end: String,
pub start_ms: i64,
pub end_ms: i64,
pub all_day: bool,
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
/// confirmed | tentative | cancelled
pub status: String,
pub recurring: bool,
/// Resolved for rendering: the event's own colour when it has one, else its calendar's.
pub color_hex: String,
/// Google's per-event colour id, 1 to 11. None means the event follows its calendar.
pub color_id: Option<String>,
pub etag: Option<String>,
pub organizer: Option<String>,
pub attendees: Vec<Attendee>,
pub conference: Option<Conference>,
pub read_only: bool,
/// Written locally and still sitting in the outbox.
pub pending: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventDraft {
pub calendar_id: String,
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
pub start: String,
pub end: String,
pub all_day: bool,
/// Raw RFC5545 lines (RRULE/RDATE/EXDATE), empty for a one-off.
#[serde(default)]
pub recurrence: Vec<String>,
/// Google's per-event colour id, 1 to 11. Absent means follow the calendar.
#[serde(default)]
pub color_id: Option<String>,
}
/// An absent field means unchanged. An empty string on `description` or `location` clears it.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventPatch {
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub start: Option<String>,
#[serde(default)]
pub end: Option<String>,
#[serde(default)]
pub all_day: Option<bool>,
#[serde(default)]
pub calendar_id: Option<String>,
#[serde(default)]
pub recurrence: Option<Vec<String>>,
/// An empty string clears it back to the calendar's colour.
#[serde(default)]
pub color_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Scope {
This,
Following,
All,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncStatus {
/// idle | syncing | error
pub phase: String,
/// Epoch milliseconds of the last successful sync.
pub last_sync: Option<i64>,
pub error: Option<String>,
pub pending_writes: u32,
pub message: Option<String>,
}
impl Default for SyncStatus {
fn default() -> Self {
SyncStatus {
phase: "idle".to_string(),
last_sync: None,
error: None,
pending_writes: 0,
message: None,
}
}
}
/// Payload of the `auth` event, the same shape as margin's `gdrive-auth`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthEvent {
pub ok: bool,
pub error: Option<String>,
pub account_id: Option<String>,
pub email: Option<String>,
}
+476
View File
@@ -0,0 +1,476 @@
// Typed wrapper over the Google Calendar REST API.
//
// Everything the sync engine and the write path need:
// calendar_list(access, sync_token, page_token) -> CalendarListPage
// events_list(access, calendar_id, sync_token, page) -> EventsPage (one page; caller pages)
// events_insert / events_patch / events_delete, all carrying If-Match where an etag is known
// events_instances(access, calendar_id, event_id, original_start) -> Vec<RawEvent>
//
// The sync chain constraint: `singleEvents=false`, `showDeleted=true`, `maxResults=2500`, and the
// parameter set byte-identical on every call in a chain including the first. `timeMin`/`timeMax`
// are rejected alongside `syncToken`.
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::google::auth::HTTP;
const BASE: &str = "https://www.googleapis.com/calendar/v3";
/// One page of events. Google caps this at 2500 and the value is part of the sync chain, so it is
/// a constant rather than an argument.
pub const MAX_RESULTS: &str = "2500";
/// A Google event exactly as returned, before flattening into the store.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawEvent {
pub id: String,
#[serde(default)]
pub etag: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub start: Option<EventDateTime>,
#[serde(default)]
pub end: Option<EventDateTime>,
#[serde(default)]
pub recurrence: Option<Vec<String>>,
#[serde(default)]
pub recurring_event_id: Option<String>,
#[serde(default)]
pub original_start_time: Option<EventDateTime>,
#[serde(default)]
pub organizer: Option<serde_json::Value>,
#[serde(default)]
pub attendees: Option<serde_json::Value>,
#[serde(default)]
pub conference_data: Option<serde_json::Value>,
#[serde(default)]
pub updated: Option<String>,
#[serde(default)]
pub transparency: Option<String>,
#[serde(default)]
pub color_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventDateTime {
/// Date-only, for an all-day event. Never shift this into a zone.
#[serde(default)]
pub date: Option<String>,
/// RFC3339 with an offset.
#[serde(default)]
pub date_time: Option<String>,
#[serde(default)]
pub time_zone: Option<String>,
}
/// One page of `events.list`. `next_sync_token` is present only on the final page.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventsPage {
#[serde(default)]
pub items: Vec<RawEvent>,
#[serde(default)]
pub next_page_token: Option<String>,
#[serde(default)]
pub next_sync_token: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawCalendar {
pub id: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub background_color: Option<String>,
#[serde(default)]
pub access_role: Option<String>,
#[serde(default)]
pub time_zone: Option<String>,
#[serde(default)]
pub primary: Option<bool>,
#[serde(default)]
pub selected: Option<bool>,
#[serde(default)]
pub deleted: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarListPage {
#[serde(default)]
pub items: Vec<RawCalendar>,
#[serde(default)]
pub next_page_token: Option<String>,
#[serde(default)]
pub next_sync_token: Option<String>,
}
/// One page of `events.instances`.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InstancesPage {
#[serde(default)]
items: Vec<RawEvent>,
}
/// A sync token that Google has expired. The caller drops that one calendar's rows and cursor and
/// full-resyncs it alone.
#[derive(Debug, Clone)]
pub enum ApiError {
/// HTTP 410, the sync token is dead.
SyncTokenExpired,
/// HTTP 412, the etag did not match, so someone else won.
PreconditionFailed,
/// Network reachability, as opposed to a rejection from Google.
Offline(String),
Other(String),
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::SyncTokenExpired => write!(f, "sync token expired"),
ApiError::PreconditionFailed => write!(f, "the event changed elsewhere"),
ApiError::Offline(e) => write!(f, "offline: {e}"),
ApiError::Other(e) => write!(f, "{e}"),
}
}
}
impl From<ApiError> for String {
fn from(e: ApiError) -> String {
e.to_string()
}
}
/// A failure to reach Google at all is Offline. Anything reqwest raises after a response has
/// arrived is a real error and keeps its text.
impl From<reqwest::Error> for ApiError {
fn from(e: reqwest::Error) -> ApiError {
if e.is_connect() || e.is_timeout() || e.is_request() || e.is_body() {
ApiError::Offline(e.to_string())
} else {
ApiError::Other(e.to_string())
}
}
}
/// Google's error body is a wall of JSON carrying the same sentence three times over. The one
/// useful line is `error.message`, so pull it out and keep the rest out of the user's face. A body
/// that will not parse is truncated rather than pasted whole.
fn explain(body: &str) -> String {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(message) = value
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
{
return message.to_string();
}
}
let trimmed = body.trim();
match trimmed.char_indices().nth(200) {
Some((end, _)) => format!("{}…", &trimmed[..end]),
None => trimmed.to_string(),
}
}
fn error_for(status: u16, context: &str, body: &str) -> ApiError {
match status {
410 => ApiError::SyncTokenExpired,
412 => ApiError::PreconditionFailed,
_ => ApiError::Other(format!("{context} failed ({status}): {}", explain(body))),
}
}
/// The same body-to-String-first shape as `auth::read_json`, so Google's error payload survives
/// into the message, but keeping the status so the sync engine can tell a 410 from a 412.
async fn read<T: DeserializeOwned>(resp: reqwest::Response, context: &str) -> Result<T, ApiError> {
let status = resp.status().as_u16();
let text = resp.text().await?;
if !(200..300).contains(&status) {
return Err(error_for(status, context, &text));
}
serde_json::from_str(&text)
.map_err(|e| ApiError::Other(format!("{context}: could not parse response: {e}")))
}
/// Calendar ids are email shaped and can carry a '#', so they are percent-encoded into the path.
/// Not `form_urlencoded`, which would turn a space into a '+'.
fn path_segment(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
/// The one definition of the sync chain's parameter set. Every call in a chain, including the
/// first, sends exactly this, which is what makes the chain valid.
fn events_params() -> Vec<(&'static str, &'static str)> {
vec![
("singleEvents", "false"),
("showDeleted", "true"),
("maxResults", MAX_RESULTS),
]
}
fn calendar_params() -> Vec<(&'static str, &'static str)> {
vec![
("showDeleted", "true"),
("showHidden", "true"),
("maxResults", "250"),
]
}
pub async fn calendar_list(
access_token: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> Result<CalendarListPage, ApiError> {
let mut query = calendar_params();
if let Some(token) = sync_token {
query.push(("syncToken", token));
}
if let Some(token) = page_token {
query.push(("pageToken", token));
}
let resp = HTTP
.get(format!("{BASE}/users/me/calendarList"))
.bearer_auth(access_token)
.query(&query)
.send()
.await?;
read(resp, "Google calendar list").await
}
/// One page. The caller walks `next_page_token` to exhaustion, because `next_sync_token` only
/// appears on the final page and nothing may be committed before it does. Both tokens are passed
/// through when both are given, which is what the official clients do with `list_next`.
pub async fn events_list(
access_token: &str,
calendar_id: &str,
sync_token: Option<&str>,
page_token: Option<&str>,
) -> Result<EventsPage, ApiError> {
let mut query = events_params();
if let Some(token) = sync_token {
query.push(("syncToken", token));
}
if let Some(token) = page_token {
query.push(("pageToken", token));
}
let resp = HTTP
.get(format!(
"{BASE}/calendars/{}/events",
path_segment(calendar_id)
))
.bearer_auth(access_token)
.query(&query)
.send()
.await?;
read(resp, "Google events list").await
}
pub async fn events_insert(
access_token: &str,
calendar_id: &str,
body: &serde_json::Value,
) -> Result<RawEvent, ApiError> {
let resp = HTTP
.post(format!(
"{BASE}/calendars/{}/events",
path_segment(calendar_id)
))
.bearer_auth(access_token)
.json(body)
.send()
.await?;
read(resp, "Google event create").await
}
/// `etag` becomes an If-Match, so a 412 tells the caller someone else changed the event first
/// rather than the write silently clobbering them.
pub async fn events_patch(
access_token: &str,
calendar_id: &str,
event_id: &str,
body: &serde_json::Value,
etag: Option<&str>,
) -> Result<RawEvent, ApiError> {
let mut request = HTTP
.patch(format!(
"{BASE}/calendars/{}/events/{}",
path_segment(calendar_id),
path_segment(event_id)
))
.bearer_auth(access_token)
.json(body);
if let Some(etag) = etag {
request = request.header(reqwest::header::IF_MATCH, etag);
}
let resp = request.send().await?;
read(resp, "Google event update").await
}
/// Idempotent by design: Google answers 410 for an event that is already gone, and a delete of
/// something already deleted is the outcome the caller wanted. That is the one place where a 410
/// is not a dead sync token.
pub async fn events_delete(
access_token: &str,
calendar_id: &str,
event_id: &str,
etag: Option<&str>,
) -> Result<(), ApiError> {
let mut request = HTTP
.delete(format!(
"{BASE}/calendars/{}/events/{}",
path_segment(calendar_id),
path_segment(event_id)
))
.bearer_auth(access_token);
if let Some(etag) = etag {
request = request.header(reqwest::header::IF_MATCH, etag);
}
let resp = request.send().await?;
let status = resp.status().as_u16();
if (200..300).contains(&status) || status == 404 || status == 410 {
return Ok(());
}
let text = resp.text().await.unwrap_or_default();
Err(error_for(status, "Google event delete", &text))
}
/// Resolves the concrete instance of a series at `original_start`, which is how a "this occurrence"
/// edit finds its event rather than constructing the instance id by hand. The filter narrows the
/// result to that one occurrence, so there is nothing to paginate.
pub async fn events_instances(
access_token: &str,
calendar_id: &str,
event_id: &str,
original_start: &str,
) -> Result<Vec<RawEvent>, ApiError> {
let resp = HTTP
.get(format!(
"{BASE}/calendars/{}/events/{}/instances",
path_segment(calendar_id),
path_segment(event_id)
))
.bearer_auth(access_token)
.query(&[
("originalStart", original_start),
("showDeleted", "true"),
("maxResults", "250"),
])
.send()
.await?;
let page: InstancesPage = read(resp, "Google event instances").await?;
Ok(page.items)
}
#[cfg(test)]
mod tests {
use super::*;
/// The real 403 body that reached the UI as a wall of JSON: the same sentence repeated in
/// `errors`, `details` and a localised copy. Only the first sentence is worth showing.
#[test]
fn a_google_error_body_is_reduced_to_its_message() {
let body = r#"{"error":{"code":403,
"message":"Google Calendar API has not been used in project 205537985128 before or it is disabled.",
"errors":[{"message":"Google Calendar API has not been used in project 205537985128 before or it is disabled.","domain":"usageLimits","reason":"accessNotConfigured"}],
"status":"PERMISSION_DENIED",
"details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"SERVICE_DISABLED"}]}}"#;
let ApiError::Other(message) = error_for(403, "Google calendar list", body) else {
panic!("a 403 is an Other");
};
assert_eq!(
message,
"Google calendar list failed (403): Google Calendar API has not been used in project 205537985128 before or it is disabled."
);
assert!(!message.contains("PERMISSION_DENIED"));
}
#[test]
fn an_unparseable_body_is_truncated_rather_than_pasted_whole() {
let body = "x".repeat(5000);
let ApiError::Other(message) = error_for(500, "Google events list", &body) else {
panic!("a 500 is an Other");
};
assert!(message.chars().count() < 260, "was {}", message.chars().count());
assert!(message.ends_with('…'));
}
#[test]
fn a_410_is_a_dead_sync_token() {
assert!(matches!(
error_for(410, "Google events list", "{}"),
ApiError::SyncTokenExpired
));
}
#[test]
fn a_412_is_a_lost_race() {
assert!(matches!(
error_for(412, "Google event update", "{}"),
ApiError::PreconditionFailed
));
}
#[test]
fn anything_else_carries_googles_own_body() {
let error = error_for(403, "Google events list", "rateLimitExceeded");
match error {
ApiError::Other(message) => {
assert!(message.contains("403"), "{message}");
assert!(message.contains("rateLimitExceeded"), "{message}");
}
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn the_sync_chain_parameters_are_fixed_and_carry_no_window() {
let params = events_params();
assert_eq!(
params,
vec![
("singleEvents", "false"),
("showDeleted", "true"),
("maxResults", "2500"),
]
);
assert!(params.iter().all(|(key, _)| *key != "timeMin" && *key != "timeMax"));
}
#[test]
fn calendar_ids_are_percent_encoded_into_the_path() {
assert_eq!(
path_segment("[email protected]"),
"a.person%40example.com"
);
assert_eq!(
path_segment("en.uk#[email protected]"),
"en.uk%23holiday%40group.v.calendar.google.com"
);
assert_eq!(path_segment("primary"), "primary");
}
}
+851
View File
@@ -0,0 +1,851 @@
// The OAuth desktop flow, ported from margin/src-tauri/src/gdrive.rs with three fixes:
//
// 1. HTTP gets real timeouts. margin's `LazyLock::new(reqwest::Client::new)` has none, which a
// polling client cannot afford.
// 2. valid_access_token is single-flight. N concurrent calendar syncs must not all refresh.
// 3. Disconnect wipes the whole store for that account, not just the token, so reconnecting as
// a different account cannot write against stale remote ids.
use std::collections::HashMap;
#[cfg(desktop)]
use std::io::{Read, Write};
#[cfg(desktop)]
use std::net::{TcpListener, TcpStream};
use std::sync::LazyLock;
#[cfg(desktop)]
use std::time::Instant;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rand::RngCore;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use tauri::{Emitter, Manager};
use tokio::sync::Mutex;
use crate::dto::{Account, AuthEvent};
use crate::google::secrets;
pub const SCOPES: &str = "openid email https://www.googleapis.com/auth/calendar";
#[cfg(desktop)]
pub const AUTH_TIMEOUT_SECS: u64 = 120;
/// How long a mobile consent round trip may take. Far longer than the desktop listener's two
/// minutes, because on a phone the browser is a separate app: signing in, a password manager and
/// a 2FA prompt in a third app can all happen between leaving and coming back, and this process
/// is doing nothing but holding a verifier in the meantime.
#[cfg(mobile)]
pub const PENDING_TIMEOUT_SECS: u64 = 900;
/// Refresh this many seconds before Google would expire the token, so a request in flight when the
/// clock rolls over does not come back 401.
const EXPIRY_SKEW_SECS: u64 = 60;
const CREDENTIALS_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/google-credentials.json"));
pub static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.expect("could not build the HTTP client")
});
/// Google issues a different OAuth client per platform and will not accept one in another's place.
/// A desktop client is confidential and redirects to loopback; an Android or iOS client is public,
/// has no secret at all, and redirects to a custom URI scheme. So the credentials file carries up
/// to three clients and the build picks the one for the platform it is being compiled for.
#[derive(Deserialize)]
struct CredentialsFile {
installed: Credentials,
#[serde(default)]
android: Option<Credentials>,
#[serde(default)]
ios: Option<Credentials>,
}
#[derive(Deserialize)]
pub struct Credentials {
pub client_id: String,
/// Absent for Android and iOS clients. A public client has nothing to keep secret, so PKCE is
/// the only thing standing between an intercepted code and a token, which is why the verifier
/// is not optional anywhere in this file.
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default = "default_auth_uri")]
pub auth_uri: String,
#[serde(default = "default_token_uri")]
pub token_uri: String,
/// Mobile only, and only when Google's console shows something other than the default below.
#[serde(default)]
pub redirect_uri: Option<String>,
}
fn default_auth_uri() -> String {
"https://accounts.google.com/o/oauth2/auth".to_string()
}
fn default_token_uri() -> String {
"https://oauth2.googleapis.com/token".to_string()
}
/// The custom URI scheme an Android build redirects to. It is the package name, which is Google's
/// documented form for an Android client and, unlike the reversed client id, is known at build
/// time, so it can sit in AndroidManifest.xml rather than being pasted in per install.
#[cfg(target_os = "android")]
pub const ANDROID_REDIRECT: &str = "studio.margin.calendar:/oauth2redirect";
/// iOS gets no such choice: Google requires the reversed client id, so the scheme is only knowable
/// once the client id is. `docs/mobile.md` says which line of Info.plist to put it on.
#[cfg(target_os = "ios")]
fn reversed_client_id(client_id: &str) -> String {
let base = client_id
.strip_suffix(".apps.googleusercontent.com")
.unwrap_or(client_id);
format!("com.googleusercontent.apps.{base}:/oauth2redirect")
}
const NOT_SET_UP: &str = "Google Calendar is not set up yet. Add a real OAuth client to google-credentials.json and rebuild.";
pub fn load_credentials() -> Result<Credentials, String> {
let parsed: CredentialsFile = serde_json::from_str(CREDENTIALS_JSON)
.map_err(|e| format!("invalid google-credentials.json: {e}"))?;
#[cfg(target_os = "android")]
let creds = parsed.android.ok_or(
"google-credentials.json has no \"android\" client. Create an OAuth client of type Android in the Google Cloud console and add it. See docs/mobile.md.",
)?;
#[cfg(target_os = "ios")]
let creds = parsed.ios.ok_or(
"google-credentials.json has no \"ios\" client. Create an OAuth client of type iOS in the Google Cloud console and add it. See docs/mobile.md.",
)?;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let creds = parsed.installed;
if creds.client_id.starts_with("YOUR_CLIENT_ID")
|| creds
.client_secret
.as_deref()
.is_some_and(|secret| secret.starts_with("YOUR_CLIENT_SECRET"))
{
return Err(NOT_SET_UP.to_string());
}
Ok(creds)
}
/// Where Google sends the browser back to. Desktop binds a loopback port per attempt, so it is
/// decided in `connect` rather than here and this is mobile only.
#[cfg(mobile)]
pub fn redirect_uri(creds: &Credentials) -> String {
if let Some(explicit) = &creds.redirect_uri {
return explicit.clone();
}
#[cfg(target_os = "android")]
{
ANDROID_REDIRECT.to_string()
}
#[cfg(not(target_os = "android"))]
{
reversed_client_id(&creds.client_id)
}
}
/// Reads the body to a String first so Google's error payload survives into the message.
/// Ported from gdrive.rs:286.
pub async fn read_json<T: DeserializeOwned>(
resp: reqwest::Response,
context: &str,
) -> Result<T, String> {
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("{context} failed ({status}): {text}"));
}
serde_json::from_str(&text).map_err(|e| format!("{context}: could not parse response: {e}"))
}
#[derive(Default)]
pub struct Session {
pub access_token: Option<String>,
pub access_expiry: u64,
pub email: Option<String>,
}
/// A consent round trip that has left for the browser and not come back.
///
/// Desktop does not need this: the loopback listener holds the verifier on its own stack for the
/// two minutes it is alive. Mobile has no listener. The browser is a separate app, this process
/// may be backgrounded while the user consents, and the answer arrives later as a deep link with
/// nothing but a code and a state parameter, so the verifier has to be waiting for it here.
#[cfg(mobile)]
pub struct Pending {
pub state: String,
pub verifier: String,
pub redirect: String,
pub expires: u64,
}
/// One entry per connected account. The outer Mutex is tokio's, not std's, because
/// `valid_access_token` holds it across the refresh await to keep refresh single-flight.
#[derive(Default)]
pub struct AuthState {
pub sessions: Mutex<HashMap<String, Session>>,
#[cfg(mobile)]
pub pending: Mutex<Option<Pending>>,
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn random_b64(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
URL_SAFE_NO_PAD.encode(buf)
}
fn pkce_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
}
fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
#[cfg(desktop)]
fn write_http_message(stream: &mut TcpStream, message: &str) {
let body = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Margin Calendar</title></head>\
<body style=\"font-family:system-ui,sans-serif;text-align:center;padding-top:80px;color:#222\">\
<h2>{message}</h2></body></html>"
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
#[cfg(desktop)]
#[derive(Debug, PartialEq, Eq)]
enum Redirect {
Code(String),
Denied(String),
Mismatch,
/// Anything else the browser asked for on the way, including the favicon.
Waiting,
}
#[cfg(desktop)]
fn request_path(request: &str) -> &str {
request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("")
}
#[cfg(desktop)]
fn parse_redirect(path: &str, expected_state: &str) -> Redirect {
if path == "/favicon.ico" {
return Redirect::Waiting;
}
let parsed = match url::Url::parse(&format!("http://127.0.0.1{path}")) {
Ok(parsed) => parsed,
Err(_) => return Redirect::Waiting,
};
let mut code = None;
let mut state = None;
let mut error = None;
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"code" => code = Some(value.into_owned()),
"state" => state = Some(value.into_owned()),
"error" => error = Some(value.into_owned()),
_ => {}
}
}
if let Some(error) = error {
return Redirect::Denied(error);
}
match (code, state) {
(Some(code), Some(state)) if state == expected_state => Redirect::Code(code),
(Some(_), _) => Redirect::Mismatch,
_ => Redirect::Waiting,
}
}
#[cfg(desktop)]
fn await_code(
listener: TcpListener,
expected_state: &str,
deadline: Instant,
) -> Result<String, String> {
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
loop {
if Instant::now() > deadline {
return Err("Timed out waiting for Google authorization.".to_string());
}
match listener.accept() {
Ok((mut stream, _)) => {
stream.set_nonblocking(false).ok();
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
match parse_redirect(request_path(&request), expected_state) {
Redirect::Code(code) => {
write_http_message(
&mut stream,
"Connected to Margin Calendar. You can close this tab.",
);
return Ok(code);
}
Redirect::Denied(error) => {
write_http_message(
&mut stream,
"Authorization was cancelled. You can close this tab.",
);
return Err(format!("Google authorization failed: {error}"));
}
Redirect::Mismatch => {
write_http_message(
&mut stream,
"Could not verify the request. You can close this tab.",
);
return Err("State mismatch during Google authorization.".to_string());
}
Redirect::Waiting => write_http_message(&mut stream, "Waiting for Google…"),
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(150));
}
Err(e) => return Err(e.to_string()),
}
}
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: u64,
#[serde(default)]
id_token: Option<String>,
}
#[derive(Deserialize)]
struct UserInfo {
#[serde(default)]
email: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct IdClaims {
/// Google's stable user id. It survives an email change, which the email does not.
#[serde(default)]
sub: Option<String>,
#[serde(default)]
email: Option<String>,
}
/// The id_token arrived over TLS straight from Google's token endpoint, so verifying its signature
/// would only re-prove what the transport already proved. Only the payload is read.
fn id_token_claims(id_token: &str) -> Option<IdClaims> {
let payload = id_token.split('.').nth(1)?;
let bytes = URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// Google rejects an empty `client_secret` rather than ignoring it, so a public mobile client has
/// to omit the field entirely rather than send a blank one.
fn with_secret<'a>(
creds: &'a Credentials,
mut form: Vec<(&'a str, &'a str)>,
) -> Vec<(&'a str, &'a str)> {
if let Some(secret) = creds.client_secret.as_deref() {
form.push(("client_secret", secret));
}
form
}
async fn exchange_code(
creds: &Credentials,
code: &str,
redirect: &str,
verifier: &str,
) -> Result<TokenResponse, String> {
let form = with_secret(
creds,
vec![
("client_id", creds.client_id.as_str()),
("code", code),
("code_verifier", verifier),
("grant_type", "authorization_code"),
("redirect_uri", redirect),
],
);
let resp = HTTP
.post(&creds.token_uri)
.form(&form)
.send()
.await
.map_err(|e| e.to_string())?;
read_json(resp, "Google token exchange").await
}
async fn refresh_access_token(
creds: &Credentials,
refresh_token: &str,
) -> Result<TokenResponse, String> {
let form = with_secret(
creds,
vec![
("client_id", creds.client_id.as_str()),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
],
);
let resp = HTTP
.post(&creds.token_uri)
.form(&form)
.send()
.await
.map_err(|e| e.to_string())?;
read_json(resp, "Google token refresh").await
}
async fn fetch_email(access_token: &str) -> Result<String, String> {
let resp = HTTP
.get("https://www.googleapis.com/oauth2/v2/userinfo")
.bearer_auth(access_token)
.send()
.await
.map_err(|e| e.to_string())?;
let info: UserInfo = read_json(resp, "Google account lookup").await?;
Ok(info.email.unwrap_or_default())
}
async fn revoke(token: &str) {
let _ = HTTP
.post("https://oauth2.googleapis.com/revoke")
.form(&[("token", token)])
.send()
.await;
}
/// Takes the store's connection for one synchronous unit of work. Nothing here may await: the
/// guard is a std one, so holding it across a suspension point would make the future non-Send.
fn with_conn<T>(
app: &tauri::AppHandle,
f: impl FnOnce(&rusqlite::Connection) -> Result<T, String>,
) -> Result<T, String> {
let store = app
.try_state::<crate::store::Store>()
.ok_or("the local store is not open")?;
let conn = store.conn.lock().map_err(|e| e.to_string())?;
f(&conn)
}
/// Reads the row's `keychain_ref` and stops there. It deliberately does NOT open the token store
/// to confirm the secret is still present.
///
/// This runs on every `store-changed`, which sync emits on every pass, so a probe here is a file
/// read and a key derivation once a minute for an answer nothing acts on. If the secret really has
/// gone, the next `valid_access_token` says so and the failure surfaces as a sync error, which is
/// the honest place to learn it.
pub async fn list_accounts(
app: &tauri::AppHandle,
_state: &tauri::State<'_, AuthState>,
) -> Result<Vec<Account>, String> {
with_conn(app, crate::store::read::accounts)
}
/// The consent URL. Identical on every platform apart from the redirect it asks Google to come
/// back to, which is the whole of the difference between the desktop and mobile flows.
///
/// select_account on top of margin's consent prompt, because adding a second account is the whole
/// point of the accounts list and Google would otherwise silently reuse the signed-in one.
fn auth_url(creds: &Credentials, redirect: &str, challenge: &str, csrf: &str) -> String {
format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&state={}&access_type=offline&prompt={}",
creds.auth_uri,
urlencode(&creds.client_id),
urlencode(redirect),
urlencode(SCOPES),
challenge,
urlencode(csrf),
urlencode("select_account consent"),
)
}
/// The system browser, never an in-app webview. Google blocks the embedded-webview flow outright,
/// and it deserves to be blocked: a webview the app controls can read what the user types into it.
fn open_in_browser(app: &tauri::AppHandle, url: &str) {
use tauri_plugin_opener::OpenerExt;
let _ = app.opener().open_url(url.to_string(), None::<&str>);
}
fn emit_auth(app: &tauri::AppHandle, outcome: Result<(String, String), String>) {
let event = match outcome {
Ok((account_id, email)) => {
crate::emit_store_changed(app, "account-connected");
AuthEvent {
ok: true,
error: None,
account_id: Some(account_id),
email: Some(email),
}
}
Err(error) => AuthEvent {
ok: false,
error: Some(error),
account_id: None,
email: None,
},
};
let _ = app.emit("auth", event);
}
#[cfg(desktop)]
pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
let creds = load_credentials()?;
let verifier = random_b64(64);
let challenge = pkce_challenge(&verifier);
let csrf = random_b64(24);
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let redirect = format!("http://127.0.0.1:{port}");
let url = auth_url(&creds, &redirect, &challenge, &csrf);
open_in_browser(&app, &url);
let app_bg = app.clone();
tauri::async_runtime::spawn(async move {
let outcome = complete_auth(&app_bg, listener, csrf, verifier, redirect, creds).await;
emit_auth(&app_bg, outcome);
});
Ok(url)
}
/// Mobile has no loopback listener to wait on, so `connect` ends the moment the browser opens and
/// the flow resumes in `handle_redirect` whenever the deep link arrives. What is stashed here is
/// the PKCE verifier and the CSRF state, which is the only thing tying the code that comes back to
/// the request that went out.
#[cfg(mobile)]
pub async fn connect(app: tauri::AppHandle) -> Result<String, String> {
let creds = load_credentials()?;
let verifier = random_b64(64);
let challenge = pkce_challenge(&verifier);
let csrf = random_b64(24);
let redirect = redirect_uri(&creds);
let url = auth_url(&creds, &redirect, &challenge, &csrf);
{
let state = app.state::<AuthState>();
let mut pending = state.pending.lock().await;
*pending = Some(Pending {
state: csrf,
verifier,
redirect,
expires: now() + PENDING_TIMEOUT_SECS,
});
}
open_in_browser(&app, &url);
Ok(url)
}
/// Every URL the OS hands the app on a registered scheme, including ones that have nothing to do
/// with consent. A link that carries no `state` we are waiting for is ignored in silence rather
/// than reported, because another feature may want that scheme later and a stray link is not an
/// authorization failure worth putting on screen.
#[cfg(mobile)]
pub async fn handle_redirect(app: tauri::AppHandle, incoming: &url::Url) {
let mut code = None;
let mut state = None;
let mut error = None;
for (key, value) in incoming.query_pairs() {
match key.as_ref() {
"code" => code = Some(value.into_owned()),
"state" => state = Some(value.into_owned()),
"error" => error = Some(value.into_owned()),
_ => {}
}
}
let state = match state {
Some(state) => state,
None => return,
};
// Taken, not read: a code is good once, so leaving the verifier in place would let a replayed
// link start a second exchange.
let auth = app.state::<AuthState>();
let pending = {
let mut slot = auth.pending.lock().await;
match slot.as_ref() {
Some(p) if p.state == state => slot.take(),
// A link whose state matches nothing is either stale or forged. Either way it is not
// this app's pending request, so it is not this app's business.
_ => return,
}
};
let pending = match pending {
Some(pending) => pending,
None => return,
};
if let Some(error) = error {
emit_auth(&app, Err(format!("Google authorization failed: {error}")));
return;
}
if now() > pending.expires {
emit_auth(
&app,
Err("The sign-in took too long. Try connecting again.".to_string()),
);
return;
}
let code = match code {
Some(code) => code,
None => {
emit_auth(&app, Err("Google returned no authorization code.".to_string()));
return;
}
};
let outcome = match load_credentials() {
Ok(creds) => finish(&app, &creds, &code, &pending.redirect, &pending.verifier).await,
Err(e) => Err(e),
};
emit_auth(&app, outcome);
}
#[cfg(desktop)]
async fn complete_auth(
app: &tauri::AppHandle,
listener: TcpListener,
csrf: String,
verifier: String,
redirect: String,
creds: Credentials,
) -> Result<(String, String), String> {
let code = tauri::async_runtime::spawn_blocking(move || {
let deadline = Instant::now() + Duration::from_secs(AUTH_TIMEOUT_SECS);
await_code(listener, &csrf, deadline)
})
.await
.map_err(|e| e.to_string())??;
finish(app, &creds, &code, &redirect, &verifier).await
}
/// Code to stored account. Everything past the point where the two flows stop differing.
async fn finish(
app: &tauri::AppHandle,
creds: &Credentials,
code: &str,
redirect: &str,
verifier: &str,
) -> Result<(String, String), String> {
let tokens = exchange_code(creds, code, redirect, verifier).await?;
let refresh = tokens
.refresh_token
.clone()
.ok_or("Google did not return a refresh token. Remove Margin Calendar from your Google account permissions and connect again.")?;
let claims = tokens
.id_token
.as_deref()
.and_then(id_token_claims)
.unwrap_or_default();
let email = match claims.email {
Some(email) if !email.is_empty() => email,
_ => fetch_email(&tokens.access_token).await?,
};
// The Google user id keys everything. It falls back to the email only when the id_token is
// missing, which should not happen while `openid` is in the scope set.
let account_id = claims.sub.unwrap_or_else(|| email.clone());
if account_id.is_empty() {
return Err("Google returned neither a user id nor an email address.".to_string());
}
secrets::store(&account_id, &refresh)?;
let reference = secrets::reference(&account_id);
with_conn(app, |conn| {
crate::store::write::upsert_account(conn, &account_id, &email, Some(&reference))
})?;
let state = app.state::<AuthState>();
let mut sessions = state.sessions.lock().await;
sessions.insert(
account_id.clone(),
Session {
access_token: Some(tokens.access_token),
access_expiry: now() + tokens.expires_in.saturating_sub(EXPIRY_SKEW_SECS),
email: Some(email.clone()),
},
);
Ok((account_id, email))
}
pub async fn disconnect(
app: &tauri::AppHandle,
state: &tauri::State<'_, AuthState>,
account_id: &str,
) -> Result<(), String> {
if let Ok(Some(refresh)) = secrets::load(account_id) {
revoke(&refresh).await;
}
{
let mut sessions = state.sessions.lock().await;
sessions.remove(account_id);
}
// Every row the account owns, not just its token. margin's disconnect cleared the token and
// left the remote ids behind, so connecting a different account afterwards wrote against them.
let removed = secrets::delete(account_id);
with_conn(app, |conn| {
crate::store::write::wipe_account(conn, account_id)
})?;
crate::emit_store_changed(app, "disconnect");
removed
}
/// Single-flight: the map lock is held across the refresh await, so concurrent callers queue on
/// one token request rather than each firing their own. That serializes refreshes across accounts
/// as well, which is the right trade for something that happens once an hour per account.
pub async fn valid_access_token(
_app: &tauri::AppHandle,
state: &AuthState,
account_id: &str,
) -> Result<String, String> {
let mut sessions = state.sessions.lock().await;
if let Some(session) = sessions.get(account_id) {
if let Some(token) = &session.access_token {
if now() < session.access_expiry {
return Ok(token.clone());
}
}
}
let refresh = secrets::load(account_id)?
.ok_or_else(|| format!("Account {account_id} is not connected to Google."))?;
let creds = load_credentials()?;
let tokens = refresh_access_token(&creds, &refresh).await?;
if let Some(rotated) = &tokens.refresh_token {
if rotated != &refresh {
secrets::store(account_id, rotated)?;
}
}
let session = sessions.entry(account_id.to_string()).or_default();
session.access_token = Some(tokens.access_token.clone());
session.access_expiry = now() + tokens.expires_in.saturating_sub(EXPIRY_SKEW_SECS);
Ok(tokens.access_token)
}
/// The token store needs a directory and the sessions map wants the stored emails, both of which
/// are only knowable once the app is up.
pub fn init_sessions(app: &tauri::AppHandle) {
if let Ok(dir) = crate::library::app_data_dir(app) {
secrets::init(dir);
}
let app = app.clone();
tauri::async_runtime::spawn(async move {
let accounts = match with_conn(&app, crate::store::read::accounts) {
Ok(accounts) => accounts,
Err(_) => return,
};
let state = app.state::<AuthState>();
let mut sessions = state.sessions.lock().await;
for account in accounts {
sessions.entry(account.id).or_default().email = Some(account.email);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkce_challenge_matches_the_rfc7636_vector() {
assert_eq!(
pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn a_verifier_is_url_safe_and_unpadded() {
let verifier = random_b64(64);
assert!(verifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
}
#[test]
fn request_path_reads_the_target_of_the_request_line() {
let request = "GET /?code=abc HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
assert_eq!(request_path(request), "/?code=abc");
assert_eq!(request_path(""), "");
}
#[test]
fn a_matching_state_yields_the_code() {
assert_eq!(
parse_redirect("/?code=4%2F0Ab&state=xyz&scope=openid", "xyz"),
Redirect::Code("4/0Ab".to_string())
);
}
#[test]
fn a_foreign_state_is_rejected_rather_than_exchanged() {
assert_eq!(parse_redirect("/?code=4/0Ab&state=other", "xyz"), Redirect::Mismatch);
assert_eq!(parse_redirect("/?code=4/0Ab", "xyz"), Redirect::Mismatch);
}
#[test]
fn a_denial_carries_googles_reason() {
assert_eq!(
parse_redirect("/?error=access_denied&state=xyz", "xyz"),
Redirect::Denied("access_denied".to_string())
);
}
#[test]
fn incidental_requests_keep_the_listener_waiting() {
assert_eq!(parse_redirect("/favicon.ico", "xyz"), Redirect::Waiting);
assert_eq!(parse_redirect("/", "xyz"), Redirect::Waiting);
assert_eq!(parse_redirect("", "xyz"), Redirect::Waiting);
}
#[test]
fn id_token_claims_reads_the_subject_and_email() {
let payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"11829","email":"[email protected]","aud":"x"}"#);
let claims = id_token_claims(&format!("header.{payload}.signature")).expect("claims");
assert_eq!(claims.sub.as_deref(), Some("11829"));
assert_eq!(claims.email.as_deref(), Some("[email protected]"));
}
#[test]
fn a_malformed_id_token_is_none_rather_than_a_panic() {
assert!(id_token_claims("not-a-jwt").is_none());
assert!(id_token_claims("header..signature").is_none());
}
}
+34
View File
@@ -0,0 +1,34 @@
// auth.rs OAuth, token refresh. Loopback on desktop, a deep link on mobile.
// api.rs typed Google Calendar REST wrapper
// secrets.rs refresh tokens, encrypted on disk, same on every platform
pub mod api;
pub mod auth;
pub mod secrets;
use crate::dto::Account;
pub use auth::AuthState;
#[tauri::command]
pub async fn accounts_list(
app: tauri::AppHandle,
state: tauri::State<'_, AuthState>,
) -> Result<Vec<Account>, String> {
auth::list_accounts(&app, &state).await
}
/// Returns the consent URL. Completion arrives on the frontend as the `auth` event.
#[tauri::command]
pub async fn account_connect(app: tauri::AppHandle) -> Result<String, String> {
auth::connect(app).await
}
#[tauri::command]
pub async fn account_disconnect(
app: tauri::AppHandle,
state: tauri::State<'_, AuthState>,
account_id: String,
) -> Result<(), String> {
auth::disconnect(&app, &state, &account_id).await
}
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more