diff --git a/.gitignore b/.gitignore
index 1c1104414..78a7e9732 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,27 @@ devAssets
*.tfbackend
!*.tfbackend.example
crash.log
+
+# Tauri
+/build
+/target/
+/gen/schemas
+dist-js
+
+# Gradle / Android build artifacts
+**/android/build/
+**/android/.tauri/
+**/android/.gradle/
+**/.gradle/
+
+# Rust build artifacts
+src-tauri/target/
+# Tauri Android build outputs
+src-tauri/gen/android/app/build/
+
+
+# Rust build artifacts in subcrates
+crates/**/target/
+
+# Worktrees
+.worktrees
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index af963ef93..93534783c 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,4 +5,7 @@ pnpm-lock.yaml
LICENSE
README.md
CHANGELOG.md
-./changeset
\ No newline at end of file
+./changeset
+src/app/generated
+crates/
+src-tauri/
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 29e56e92a..67cc799e6 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -7,5 +7,8 @@
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[rust]": {
+ "editor.defaultFormatter": "rust-lang.rust-analyzer"
}
}
diff --git a/config.json b/config.json
index 24d3e65e5..dedb37c39 100644
--- a/config.json
+++ b/config.json
@@ -10,7 +10,8 @@
"pushNotificationDetails": {
"pushNotifyUrl": "https://cinny.cc/_matrix/push/v1/notify",
"vapidPublicKey": "BHLwykXs79AbKNiblEtZZRAgnt7o5_ieImhVJD8QZ01MVwAHnXwZzNgQEJJEU3E5CVsihoKtb7yaNe5x3vmkWkI",
- "webPushAppID": "cc.cinny.web"
+ "webPushAppID": "cc.cinny.web",
+ "unifiedPushAppID": "moe.sable.up"
},
"slidingSync": {
diff --git a/crates/sable-macros/Cargo.toml b/crates/sable-macros/Cargo.toml
new file mode 100644
index 000000000..867c3bed3
--- /dev/null
+++ b/crates/sable-macros/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "sable-macros"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+proc-macro = true
+
+[dependencies]
+proc-macro2 = "1"
+quote = "1"
+syn = { version = "2", features = ["full"] }
diff --git a/crates/sable-macros/src/lib.rs b/crates/sable-macros/src/lib.rs
new file mode 100644
index 000000000..7c0a94e53
--- /dev/null
+++ b/crates/sable-macros/src/lib.rs
@@ -0,0 +1,154 @@
+use proc_macro::TokenStream;
+use proc_macro2::TokenStream as TokenStream2;
+use quote::quote;
+use syn::{
+ parse::{Parse, ParseStream},
+ parse_macro_input, Attribute, Path, Token,
+};
+
+struct CommandItem {
+ /// The tokens inside `#[cfg(...)]`, e.g. `desktop` or `target_os = "windows"`.
+ /// `None` means the command is always compiled in.
+ cfg_tokens: Option,
+ path: Path,
+}
+
+struct CommandList(Vec);
+
+impl Parse for CommandList {
+ fn parse(input: ParseStream) -> syn::Result {
+ let mut items = vec![];
+ while !input.is_empty() {
+ let attrs = Attribute::parse_outer(input)?;
+ let path: Path = input.parse()?;
+
+ // Extract the first #[cfg(...)] attribute if present.
+ // Any other attributes are ignored (they wouldn't make sense here anyway).
+ let cfg_tokens = attrs.iter().find_map(|attr| {
+ if !attr.path().is_ident("cfg") {
+ return None;
+ }
+ attr.meta
+ .require_list()
+ .ok()
+ .map(|list| list.tokens.clone())
+ });
+
+ items.push(CommandItem { cfg_tokens, path });
+
+ // Consume optional trailing comma
+ if input.peek(Token![,]) {
+ let _ = input.parse::();
+ }
+ }
+ Ok(CommandList(items))
+ }
+}
+
+/// A drop-in replacement for `tauri_specta::collect_commands!` that supports
+/// `#[cfg(...)]` attributes on individual commands.
+///
+/// # Example
+/// ```rust
+/// collect_commands![
+/// #[cfg(desktop)]
+/// desktop_tray::set_close_to_tray_enabled,
+/// windows::snap_overlay::show_snap_overlay,
+/// windows::snap_overlay::hide_snap_overlay,
+/// ]
+/// ```
+///
+/// # How it works
+///
+/// For each unique cfg predicate P found in the list the macro generates two
+/// complete `tauri_specta::internal::command(generate_handler![...],
+/// collect_functions![...])` calls — one for `#[cfg(P)]` (including those
+/// commands) and one for `#[cfg(not(P))]` (excluding them). The compiler
+/// picks exactly one branch per target, so every command path only needs to
+/// exist on the targets where its cfg condition is true.
+///
+/// For N distinct predicates, 2^N branches are emitted. In practice only
+/// `#[cfg(desktop)]` is used so this is always just two branches.
+#[proc_macro]
+pub fn collect_commands(input: TokenStream) -> TokenStream {
+ let CommandList(items) = parse_macro_input!(input as CommandList);
+
+ // Collect the unique cfg predicates present in this invocation.
+ let mut predicates: Vec = vec![];
+ for item in &items {
+ if let Some(cfg) = &item.cfg_tokens {
+ let key = cfg.to_string();
+ if !predicates
+ .iter()
+ .any(|p: &TokenStream2| p.to_string() == key)
+ {
+ predicates.push(cfg.clone());
+ }
+ }
+ }
+
+ let n = predicates.len();
+ let num_variants = 1usize << n; // 2^n — always at least 1
+
+ let mut branches: Vec = vec![];
+
+ for variant in 0..num_variants {
+ // For variant `v`, bit `i` being set means predicate[i] is "active"
+ // (true) for this branch.
+
+ // Build `#[cfg(all(pred0_or_not, pred1_or_not, ...))]`
+ let conditions: Vec = predicates
+ .iter()
+ .enumerate()
+ .map(|(i, pred)| {
+ if variant & (1 << i) != 0 {
+ quote! { #pred }
+ } else {
+ quote! { not(#pred) }
+ }
+ })
+ .collect();
+
+ let cfg_guard: TokenStream2 = if conditions.is_empty() {
+ // No predicates at all — unconditional (wrapping in all() is valid).
+ quote! {}
+ } else {
+ quote! { #[cfg(all(#(#conditions),*))] }
+ };
+
+ // Collect commands that are visible in this variant:
+ // - always-on commands (no cfg attribute) are always included
+ // - cfg-gated commands are included only when their predicate bit is set
+ let variant_paths: Vec<&Path> = items
+ .iter()
+ .filter(|item| match &item.cfg_tokens {
+ None => true, // always-on
+ Some(cfg) => {
+ let key = cfg.to_string();
+ let idx = predicates
+ .iter()
+ .position(|p| p.to_string() == key)
+ .unwrap();
+ variant & (1 << idx) != 0
+ }
+ })
+ .map(|item| &item.path)
+ .collect();
+
+ branches.push(quote! {
+ #cfg_guard
+ let __commands = ::tauri_specta::internal::command(
+ ::tauri::generate_handler![#(#variant_paths),*],
+ ::specta::function::collect_functions![#(#variant_paths),*],
+ );
+ });
+ }
+
+ quote! {
+ {
+ #(#branches)*
+ __commands
+ }
+ }
+ .into()
+}
diff --git a/crates/tauri-plugin-splashscreen/.gitignore b/crates/tauri-plugin-splashscreen/.gitignore
new file mode 100644
index 000000000..7ea37a945
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/.gitignore
@@ -0,0 +1,16 @@
+/.vs
+.DS_Store
+.Thumbs.db
+*.sublime*
+.idea/
+debug.log
+package-lock.json
+.vscode/settings.json
+yarn.lock
+
+/.tauri
+/.gradle
+/target
+Cargo.lock
+node_modules/
+
diff --git a/crates/tauri-plugin-splashscreen/Cargo.toml b/crates/tauri-plugin-splashscreen/Cargo.toml
new file mode 100644
index 000000000..688859dae
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "tauri-plugin-splashscreen"
+version = "0.1.0"
+authors = [ "You" ]
+description = ""
+edition = "2021"
+rust-version = "1.77.2"
+exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
+links = "tauri-plugin-splashscreen"
+
+[dependencies]
+tauri = { version = "2.10.3" }
+serde = "1.0"
+thiserror = "2"
+
+[build-dependencies]
+tauri-plugin = { version = "2.5.4", features = ["build"] }
diff --git a/crates/tauri-plugin-splashscreen/README.md b/crates/tauri-plugin-splashscreen/README.md
new file mode 100644
index 000000000..1173cdba2
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/README.md
@@ -0,0 +1 @@
+# Tauri Plugin splashscreen
diff --git a/crates/tauri-plugin-splashscreen/android/.gitignore b/crates/tauri-plugin-splashscreen/android/.gitignore
new file mode 100644
index 000000000..a964bbce7
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/.gitignore
@@ -0,0 +1,3 @@
+/build
+/.tauri
+/.gradle
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/android/build.gradle.kts b/crates/tauri-plugin-splashscreen/android/build.gradle.kts
new file mode 100644
index 000000000..cb9446f46
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/build.gradle.kts
@@ -0,0 +1,45 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "moe.sable.app.plugin.splashscreen"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 21
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+
+ implementation("androidx.core:core-ktx:1.9.0")
+ implementation("androidx.core:core-splashscreen:1.2.0")
+ implementation("androidx.appcompat:appcompat:1.6.0")
+ implementation("com.google.android.material:material:1.7.0")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ implementation(project(":tauri-android"))
+}
diff --git a/crates/tauri-plugin-splashscreen/android/proguard-rules.pro b/crates/tauri-plugin-splashscreen/android/proguard-rules.pro
new file mode 100644
index 000000000..64b4a059a
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/android/settings.gradle b/crates/tauri-plugin-splashscreen/android/settings.gradle
new file mode 100644
index 000000000..39b1210b3
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/settings.gradle
@@ -0,0 +1,31 @@
+pluginManagement {
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ google()
+ }
+ resolutionStrategy {
+ eachPlugin {
+ switch (requested.id.id) {
+ case "com.android.library":
+ useVersion("8.0.2")
+ break
+ case "org.jetbrains.kotlin.android":
+ useVersion("1.8.20")
+ break
+ }
+ }
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ mavenCentral()
+ google()
+
+ }
+}
+
+include ':tauri-android'
+project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
diff --git a/crates/tauri-plugin-splashscreen/android/src/androidTest/java/ExampleInstrumentedTest.kt b/crates/tauri-plugin-splashscreen/android/src/androidTest/java/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..cd40ebd90
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/src/androidTest/java/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package moe.sable.app.plugin.splashscreen
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("moe.sable.app.plugin.splashscreen", appContext.packageName)
+ }
+}
diff --git a/crates/tauri-plugin-splashscreen/android/src/main/AndroidManifest.xml b/crates/tauri-plugin-splashscreen/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..74b7379f7
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/android/src/main/java/moe/sable/app/plugin/splashscreen/SplashScreenPlugin.kt b/crates/tauri-plugin-splashscreen/android/src/main/java/moe/sable/app/plugin/splashscreen/SplashScreenPlugin.kt
new file mode 100644
index 000000000..41ec7b96f
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/src/main/java/moe/sable/app/plugin/splashscreen/SplashScreenPlugin.kt
@@ -0,0 +1,46 @@
+package moe.sable.app.plugin.splashscreen
+
+import android.app.Activity
+import android.os.Handler
+import android.os.Looper
+import android.webkit.WebView
+import androidx.core.splashscreen.SplashScreen
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import app.tauri.annotation.Command
+import app.tauri.annotation.TauriPlugin
+import app.tauri.plugin.Invoke
+import app.tauri.plugin.Plugin
+
+@TauriPlugin
+class SplashScreenPlugin(private val activity: Activity) : Plugin(activity) {
+
+ companion object {
+ private const val TIMEOUT_MS = 10000L
+ }
+
+ @Volatile
+ private var isAppReady = false
+ private var splashScreen: SplashScreen? = null
+
+ override fun load(webView: WebView) {
+ splashScreen = activity.installSplashScreen()
+
+ splashScreen?.setKeepOnScreenCondition {
+ !isAppReady
+ }
+
+ splashScreen?.setOnExitAnimationListener { splashScreenViewProvider ->
+ splashScreenViewProvider.remove()
+ }
+
+ Handler(Looper.getMainLooper()).postDelayed({
+ isAppReady = true
+ }, TIMEOUT_MS)
+ }
+
+ @Command
+ fun close(invoke: Invoke) {
+ isAppReady = true
+ invoke.resolve()
+ }
+}
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/android/src/main/res/drawable/loading_icon.xml b/crates/tauri-plugin-splashscreen/android/src/main/res/drawable/loading_icon.xml
new file mode 100644
index 000000000..b7450dd6a
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/src/main/res/drawable/loading_icon.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/android/src/main/res/values/colors.xml b/crates/tauri-plugin-splashscreen/android/src/main/res/values/colors.xml
new file mode 100644
index 000000000..b7f8c6255
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #000000
+
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/android/src/main/res/values/themes.xml b/crates/tauri-plugin-splashscreen/android/src/main/res/values/themes.xml
new file mode 100644
index 000000000..247073d98
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/android/src/main/res/values/themes.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/build.rs b/crates/tauri-plugin-splashscreen/build.rs
new file mode 100644
index 000000000..2ad059e85
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/build.rs
@@ -0,0 +1,8 @@
+const COMMANDS: &[&str] = &["ping", "close"];
+
+fn main() {
+ tauri_plugin::Builder::new(COMMANDS)
+ .android_path("android")
+ .ios_path("ios")
+ .build();
+}
diff --git a/crates/tauri-plugin-splashscreen/dist-js/index.cjs b/crates/tauri-plugin-splashscreen/dist-js/index.cjs
new file mode 100644
index 000000000..9cf984a47
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/dist-js/index.cjs
@@ -0,0 +1,18 @@
+'use strict';
+
+var core = require('@tauri-apps/api/core');
+
+const splashscreen = {
+ async ping(value) {
+ return await core.invoke('plugin:splashscreen|ping', {
+ payload: {
+ value,
+ },
+ }).then((r) => (r.value ? r.value : null));
+ },
+ async close() {
+ await core.invoke('plugin:splashscreen|close');
+ },
+};
+
+module.exports = splashscreen;
diff --git a/crates/tauri-plugin-splashscreen/dist-js/index.d.ts b/crates/tauri-plugin-splashscreen/dist-js/index.d.ts
new file mode 100644
index 000000000..4299c4a02
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/dist-js/index.d.ts
@@ -0,0 +1,6 @@
+interface Splashscreen {
+ ping(value: string): Promise;
+ close(): Promise;
+}
+declare const splashscreen: Splashscreen;
+export default splashscreen;
diff --git a/crates/tauri-plugin-splashscreen/dist-js/index.js b/crates/tauri-plugin-splashscreen/dist-js/index.js
new file mode 100644
index 000000000..00983e423
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/dist-js/index.js
@@ -0,0 +1,16 @@
+import { invoke } from '@tauri-apps/api/core';
+
+const splashscreen = {
+ async ping(value) {
+ return await invoke('plugin:splashscreen|ping', {
+ payload: {
+ value,
+ },
+ }).then((r) => (r.value ? r.value : null));
+ },
+ async close() {
+ await invoke('plugin:splashscreen|close');
+ },
+};
+
+export { splashscreen as default };
diff --git a/crates/tauri-plugin-splashscreen/guest-js/index.ts b/crates/tauri-plugin-splashscreen/guest-js/index.ts
new file mode 100644
index 000000000..64adf0902
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/guest-js/index.ts
@@ -0,0 +1,22 @@
+import { invoke } from '@tauri-apps/api/core';
+
+interface Splashscreen {
+ ping(value: string): Promise;
+ close(): Promise;
+}
+
+const splashscreen: Splashscreen = {
+ async ping(value: string): Promise {
+ return invoke<{ value?: string }>('plugin:splashscreen|ping', {
+ payload: {
+ value,
+ },
+ }).then((r) => (r.value ? r.value : null));
+ },
+
+ async close(): Promise {
+ await invoke('plugin:splashscreen|close');
+ },
+};
+
+export default splashscreen;
diff --git a/crates/tauri-plugin-splashscreen/package-lock.json b/crates/tauri-plugin-splashscreen/package-lock.json
new file mode 100644
index 000000000..d0e4c82f9
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/package-lock.json
@@ -0,0 +1,619 @@
+{
+ "name": "tauri-plugin-splashscreen-api",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tauri-plugin-splashscreen-api",
+ "version": "0.1.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.0.0"
+ },
+ "devDependencies": {
+ "@rollup/plugin-typescript": "^12.0.0",
+ "rollup": "^4.9.6",
+ "tslib": "^2.6.2",
+ "typescript": "^5.3.3"
+ }
+ },
+ "node_modules/@rollup/plugin-typescript": {
+ "version": "12.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz",
+ "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.1.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.14.0||^3.0.0||^4.0.0",
+ "tslib": "*",
+ "typescript": ">=3.7.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ },
+ "tslib": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tauri-apps/api": {
+ "version": "2.10.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
+ "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
+ "license": "Apache-2.0 OR MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/crates/tauri-plugin-splashscreen/package.json b/crates/tauri-plugin-splashscreen/package.json
new file mode 100644
index 000000000..c1ab5968c
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "tauri-plugin-splashscreen-api",
+ "version": "0.1.0",
+ "author": "You",
+ "description": "",
+ "type": "module",
+ "types": "./dist-js/index.d.ts",
+ "main": "./dist-js/index.cjs",
+ "module": "./dist-js/index.js",
+ "exports": {
+ "types": "./dist-js/index.d.ts",
+ "import": "./dist-js/index.js",
+ "require": "./dist-js/index.cjs"
+ },
+ "files": [
+ "dist-js",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "rollup -c",
+ "prepublishOnly": "pnpm build",
+ "pretest": "pnpm build"
+ },
+ "dependencies": {
+ "@tauri-apps/api": "^2.0.0"
+ },
+ "devDependencies": {
+ "@rollup/plugin-typescript": "^12.0.0",
+ "rollup": "^4.9.6",
+ "typescript": "^5.3.3",
+ "tslib": "^2.6.2"
+ }
+}
diff --git a/crates/tauri-plugin-splashscreen/permissions/autogenerated/commands/close.toml b/crates/tauri-plugin-splashscreen/permissions/autogenerated/commands/close.toml
new file mode 100644
index 000000000..fad12d151
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/permissions/autogenerated/commands/close.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-close"
+description = "Enables the close command without any pre-configured scope."
+commands.allow = ["close"]
+
+[[permission]]
+identifier = "deny-close"
+description = "Denies the close command without any pre-configured scope."
+commands.deny = ["close"]
diff --git a/crates/tauri-plugin-splashscreen/permissions/autogenerated/commands/ping.toml b/crates/tauri-plugin-splashscreen/permissions/autogenerated/commands/ping.toml
new file mode 100644
index 000000000..1d1358807
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/permissions/autogenerated/commands/ping.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-ping"
+description = "Enables the ping command without any pre-configured scope."
+commands.allow = ["ping"]
+
+[[permission]]
+identifier = "deny-ping"
+description = "Denies the ping command without any pre-configured scope."
+commands.deny = ["ping"]
diff --git a/crates/tauri-plugin-splashscreen/permissions/autogenerated/reference.md b/crates/tauri-plugin-splashscreen/permissions/autogenerated/reference.md
new file mode 100644
index 000000000..b549f64e7
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/permissions/autogenerated/reference.md
@@ -0,0 +1,70 @@
+## Default Permission
+
+Default permissions for the splashscreen plugin
+
+#### This default permission set includes the following:
+
+- `allow-ping`
+- `allow-close`
+
+## Permission Table
+
+
+
+Identifier
+Description
+
+
+
+
+
+
+`splashscreen:allow-close`
+
+
+
+
+Enables the close command without any pre-configured scope.
+
+
+
+
+
+
+
+`splashscreen:deny-close`
+
+
+
+
+Denies the close command without any pre-configured scope.
+
+
+
+
+
+
+
+`splashscreen:allow-ping`
+
+
+
+
+Enables the ping command without any pre-configured scope.
+
+
+
+
+
+
+
+`splashscreen:deny-ping`
+
+
+
+
+Denies the ping command without any pre-configured scope.
+
+
+
+
diff --git a/crates/tauri-plugin-splashscreen/permissions/default.toml b/crates/tauri-plugin-splashscreen/permissions/default.toml
new file mode 100644
index 000000000..2aad6f4b0
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/permissions/default.toml
@@ -0,0 +1,8 @@
+"$schema" = "schemas/schema.json"
+
+[default]
+description = "Default permissions for the splashscreen plugin"
+permissions = [
+ "allow-ping",
+ "allow-close",
+]
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/permissions/schemas/schema.json b/crates/tauri-plugin-splashscreen/permissions/schemas/schema.json
new file mode 100644
index 000000000..d8b2f7f76
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/permissions/schemas/schema.json
@@ -0,0 +1,330 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "PermissionFile",
+ "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
+ "type": "object",
+ "properties": {
+ "default": {
+ "description": "The default permission set for the plugin",
+ "anyOf": [
+ {
+ "$ref": "#/definitions/DefaultPermission"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "set": {
+ "description": "A list of permissions sets defined",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PermissionSet"
+ }
+ },
+ "permission": {
+ "description": "A list of inlined permissions",
+ "default": [],
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Permission"
+ }
+ }
+ },
+ "definitions": {
+ "DefaultPermission": {
+ "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
+ "type": "object",
+ "required": [
+ "permissions"
+ ],
+ "properties": {
+ "version": {
+ "description": "The version of the permission.",
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "uint64",
+ "minimum": 1.0
+ },
+ "description": {
+ "description": "Human-readable description of what the permission does. Tauri convention is to use `` headings in markdown content for Tauri documentation generation purposes.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "permissions": {
+ "description": "All permissions this set contains.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "PermissionSet": {
+ "description": "A set of direct permissions grouped together under a new name.",
+ "type": "object",
+ "required": [
+ "description",
+ "identifier",
+ "permissions"
+ ],
+ "properties": {
+ "identifier": {
+ "description": "A unique identifier for the permission.",
+ "type": "string"
+ },
+ "description": {
+ "description": "Human-readable description of what the permission does.",
+ "type": "string"
+ },
+ "permissions": {
+ "description": "All permissions this set contains.",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PermissionKind"
+ }
+ }
+ }
+ },
+ "Permission": {
+ "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
+ "type": "object",
+ "required": [
+ "identifier"
+ ],
+ "properties": {
+ "version": {
+ "description": "The version of the permission.",
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "uint64",
+ "minimum": 1.0
+ },
+ "identifier": {
+ "description": "A unique identifier for the permission.",
+ "type": "string"
+ },
+ "description": {
+ "description": "Human-readable description of what the permission does. Tauri internal convention is to use `` headings in markdown content for Tauri documentation generation purposes.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "commands": {
+ "description": "Allowed or denied commands when using this permission.",
+ "default": {
+ "allow": [],
+ "deny": []
+ },
+ "allOf": [
+ {
+ "$ref": "#/definitions/Commands"
+ }
+ ]
+ },
+ "scope": {
+ "description": "Allowed or denied scoped when using this permission.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/Scopes"
+ }
+ ]
+ },
+ "platforms": {
+ "description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/definitions/Target"
+ }
+ }
+ }
+ },
+ "Commands": {
+ "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
+ "type": "object",
+ "properties": {
+ "allow": {
+ "description": "Allowed command.",
+ "default": [],
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "deny": {
+ "description": "Denied command, which takes priority.",
+ "default": [],
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "Scopes": {
+ "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
+ "type": "object",
+ "properties": {
+ "allow": {
+ "description": "Data that defines what is allowed by the scope.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/definitions/Value"
+ }
+ },
+ "deny": {
+ "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/definitions/Value"
+ }
+ }
+ }
+ },
+ "Value": {
+ "description": "All supported ACL values.",
+ "anyOf": [
+ {
+ "description": "Represents a null JSON value.",
+ "type": "null"
+ },
+ {
+ "description": "Represents a [`bool`].",
+ "type": "boolean"
+ },
+ {
+ "description": "Represents a valid ACL [`Number`].",
+ "allOf": [
+ {
+ "$ref": "#/definitions/Number"
+ }
+ ]
+ },
+ {
+ "description": "Represents a [`String`].",
+ "type": "string"
+ },
+ {
+ "description": "Represents a list of other [`Value`]s.",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Value"
+ }
+ },
+ {
+ "description": "Represents a map of [`String`] keys to [`Value`]s.",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/Value"
+ }
+ }
+ ]
+ },
+ "Number": {
+ "description": "A valid ACL number.",
+ "anyOf": [
+ {
+ "description": "Represents an [`i64`].",
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "description": "Represents a [`f64`].",
+ "type": "number",
+ "format": "double"
+ }
+ ]
+ },
+ "Target": {
+ "description": "Platform target.",
+ "oneOf": [
+ {
+ "description": "MacOS.",
+ "type": "string",
+ "enum": [
+ "macOS"
+ ]
+ },
+ {
+ "description": "Windows.",
+ "type": "string",
+ "enum": [
+ "windows"
+ ]
+ },
+ {
+ "description": "Linux.",
+ "type": "string",
+ "enum": [
+ "linux"
+ ]
+ },
+ {
+ "description": "Android.",
+ "type": "string",
+ "enum": [
+ "android"
+ ]
+ },
+ {
+ "description": "iOS.",
+ "type": "string",
+ "enum": [
+ "iOS"
+ ]
+ }
+ ]
+ },
+ "PermissionKind": {
+ "type": "string",
+ "oneOf": [
+ {
+ "description": "Enables the close command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-close",
+ "markdownDescription": "Enables the close command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the close command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-close",
+ "markdownDescription": "Denies the close command without any pre-configured scope."
+ },
+ {
+ "description": "Enables the ping command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-ping",
+ "markdownDescription": "Enables the ping command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the ping command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-ping",
+ "markdownDescription": "Denies the ping command without any pre-configured scope."
+ },
+ {
+ "description": "Default permissions for the splashscreen plugin\n#### This default permission set includes:\n\n- `allow-ping`\n- `allow-close`",
+ "type": "string",
+ "const": "default",
+ "markdownDescription": "Default permissions for the splashscreen plugin\n#### This default permission set includes:\n\n- `allow-ping`\n- `allow-close`"
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/crates/tauri-plugin-splashscreen/rollup.config.js b/crates/tauri-plugin-splashscreen/rollup.config.js
new file mode 100644
index 000000000..d7dacf3d1
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/rollup.config.js
@@ -0,0 +1,31 @@
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { cwd } from 'node:process';
+import typescript from '@rollup/plugin-typescript';
+
+const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8'));
+
+export default {
+ input: 'guest-js/index.ts',
+ output: [
+ {
+ file: pkg.exports.import,
+ format: 'esm',
+ },
+ {
+ file: pkg.exports.require,
+ format: 'cjs',
+ },
+ ],
+ plugins: [
+ typescript({
+ declaration: true,
+ declarationDir: dirname(pkg.exports.import),
+ }),
+ ],
+ external: [
+ /^@tauri-apps\/api/,
+ ...Object.keys(pkg.dependencies || {}),
+ ...Object.keys(pkg.peerDependencies || {}),
+ ],
+};
diff --git a/crates/tauri-plugin-splashscreen/src/commands.rs b/crates/tauri-plugin-splashscreen/src/commands.rs
new file mode 100644
index 000000000..c3552928e
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/src/commands.rs
@@ -0,0 +1,18 @@
+use tauri::{AppHandle, command, Runtime};
+
+use crate::models::*;
+use crate::Result;
+use crate::SplashscreenExt;
+
+#[command]
+pub(crate) async fn ping(
+ app: AppHandle,
+ payload: PingRequest,
+) -> Result {
+ app.splashscreen().ping(payload)
+}
+
+#[command]
+pub(crate) async fn close(app: AppHandle) -> Result<()> {
+ app.splashscreen().close()
+}
diff --git a/crates/tauri-plugin-splashscreen/src/desktop.rs b/crates/tauri-plugin-splashscreen/src/desktop.rs
new file mode 100644
index 000000000..8542cb1fa
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/src/desktop.rs
@@ -0,0 +1,26 @@
+use serde::de::DeserializeOwned;
+use tauri::{plugin::PluginApi, AppHandle, Runtime};
+
+use crate::models::*;
+
+pub fn init(
+ app: &AppHandle,
+ _api: PluginApi,
+) -> crate::Result> {
+ Ok(Splashscreen(app.clone()))
+}
+
+/// Access to the splashscreen APIs.
+pub struct Splashscreen(AppHandle);
+
+impl Splashscreen {
+ pub fn ping(&self, payload: PingRequest) -> crate::Result {
+ Ok(PingResponse {
+ value: payload.value,
+ })
+ }
+
+ pub fn close(&self) -> crate::Result<()> {
+ Ok(())
+ }
+}
diff --git a/crates/tauri-plugin-splashscreen/src/error.rs b/crates/tauri-plugin-splashscreen/src/error.rs
new file mode 100644
index 000000000..929b53836
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/src/error.rs
@@ -0,0 +1,21 @@
+use serde::{ser::Serializer, Serialize};
+
+pub type Result = std::result::Result;
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(transparent)]
+ Io(#[from] std::io::Error),
+ #[cfg(mobile)]
+ #[error(transparent)]
+ PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
+}
+
+impl Serialize for Error {
+ fn serialize(&self, serializer: S) -> std::result::Result
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(self.to_string().as_ref())
+ }
+}
diff --git a/crates/tauri-plugin-splashscreen/src/lib.rs b/crates/tauri-plugin-splashscreen/src/lib.rs
new file mode 100644
index 000000000..18b0a7de7
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/src/lib.rs
@@ -0,0 +1,48 @@
+use tauri::{
+ plugin::{Builder, TauriPlugin},
+ Manager, Runtime,
+};
+
+pub use models::*;
+
+#[cfg(desktop)]
+mod desktop;
+#[cfg(mobile)]
+mod mobile;
+
+mod commands;
+mod error;
+mod models;
+
+pub use error::{Error, Result};
+
+#[cfg(desktop)]
+use desktop::Splashscreen;
+#[cfg(mobile)]
+use mobile::Splashscreen;
+
+/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the splashscreen APIs.
+pub trait SplashscreenExt {
+ fn splashscreen(&self) -> &Splashscreen;
+}
+
+impl> crate::SplashscreenExt for T {
+ fn splashscreen(&self) -> &Splashscreen {
+ self.state::>().inner()
+ }
+}
+
+/// Initializes the plugin.
+pub fn init() -> TauriPlugin {
+ Builder::new("splashscreen")
+ .invoke_handler(tauri::generate_handler![commands::ping, commands::close])
+ .setup(|app, api| {
+ #[cfg(mobile)]
+ let splashscreen = mobile::init(app, api)?;
+ #[cfg(desktop)]
+ let splashscreen = desktop::init(app, api)?;
+ app.manage(splashscreen);
+ Ok(())
+ })
+ .build()
+}
diff --git a/crates/tauri-plugin-splashscreen/src/mobile.rs b/crates/tauri-plugin-splashscreen/src/mobile.rs
new file mode 100644
index 000000000..2163c3be5
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/src/mobile.rs
@@ -0,0 +1,41 @@
+use serde::de::DeserializeOwned;
+use tauri::{
+ plugin::{PluginApi, PluginHandle},
+ AppHandle, Runtime,
+};
+
+use crate::models::*;
+
+#[cfg(target_os = "ios")]
+tauri::ios_plugin_binding!(init_plugin_splashscreen);
+
+// initializes the Kotlin or Swift plugin classes
+pub fn init(
+ _app: &AppHandle,
+ api: PluginApi,
+) -> crate::Result> {
+ #[cfg(target_os = "android")]
+ let handle = api.register_android_plugin("moe.sable.app.plugin.splashscreen", "SplashScreenPlugin")?;
+ #[cfg(target_os = "ios")]
+ let handle = api.register_ios_plugin(init_plugin_splashscreen)?;
+ Ok(Splashscreen(handle))
+}
+
+/// Access to the splashscreen APIs.
+pub struct Splashscreen(PluginHandle);
+
+impl Splashscreen {
+ pub fn ping(&self, payload: PingRequest) -> crate::Result {
+ self
+ .0
+ .run_mobile_plugin("ping", payload)
+ .map_err(Into::into)
+ }
+
+ pub fn close(&self) -> crate::Result<()> {
+ self
+ .0
+ .run_mobile_plugin("close", ())
+ .map_err(Into::into)
+ }
+}
diff --git a/crates/tauri-plugin-splashscreen/src/models.rs b/crates/tauri-plugin-splashscreen/src/models.rs
new file mode 100644
index 000000000..c755abd14
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/src/models.rs
@@ -0,0 +1,13 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PingRequest {
+ pub value: Option,
+}
+
+#[derive(Debug, Clone, Default, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PingResponse {
+ pub value: Option,
+}
diff --git a/crates/tauri-plugin-splashscreen/tsconfig.json b/crates/tauri-plugin-splashscreen/tsconfig.json
new file mode 100644
index 000000000..059112270
--- /dev/null
+++ b/crates/tauri-plugin-splashscreen/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "es2021",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noImplicitAny": true,
+ "noEmit": true
+ },
+ "include": ["guest-js/*.ts"],
+ "exclude": ["dist-js", "node_modules"]
+}
diff --git a/index.html b/index.html
index 21c34e6d1..03bc47068 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,10 @@
-
+
Sable Client
diff --git a/knip.json b/knip.json
index 3f415d940..f422e956f 100644
--- a/knip.json
+++ b/knip.json
@@ -1,10 +1,11 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
- "entry": ["src/sw.ts", "scripts/normalize-imports.js"],
+ "entry": ["src/sw.ts", "scripts/**/*.js"],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
},
+ "ignoreFiles": ["crates/**/*"],
"ignoreDependencies": [
"buffer",
"@element-hq/element-call-embedded",
diff --git a/knope.toml b/knope.toml
index 7442b6bf9..0c9bfead8 100644
--- a/knope.toml
+++ b/knope.toml
@@ -1,5 +1,5 @@
[package]
-versioned_files = ["package.json"]
+versioned_files = ["package.json", "src-tauri/tauri.conf.json"]
changelog = "CHANGELOG.md"
extra_changelog_sections = [
{ name = "Documentation", types = ["docs"] },
diff --git a/package.json b/package.json
index cbdee7db2..3e8841781 100644
--- a/package.json
+++ b/package.json
@@ -17,10 +17,14 @@
"fmt": "prettier --write .",
"fmt:check": "prettier --check .",
"typecheck": "tsc",
+ "tauri": "tauri",
"knip": "knip",
"knope": "knope",
"document-change": "knope document-change",
- "postinstall": "node scripts/install-knope.js"
+ "postinstall": "node scripts/install-knope.js",
+ "setup:knope": "node scripts/install-knope.js",
+ "setup:tauri-icons": "node scripts/symlink-tauri-icons.js",
+ "lint:normalize-imports": "node scripts/normalize-imports.js"
},
"keywords": [],
"author": "7w1",
@@ -31,9 +35,14 @@
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@fontsource-variable/nunito": "5.2.7",
"@fontsource/space-mono": "5.2.9",
+ "@sableclient/tauri-plugin-notifications-api": "git+https://github.com/SableClient/tauri-plugin-notifications.git#9f162c62f89f9f7e1e17c71893faa51d140f18fd",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-virtual": "^3.13.19",
+ "@tauri-apps/api": "2.10.1",
+ "@tauri-apps/plugin-deep-link": "2.4.7",
+ "@tauri-apps/plugin-opener": "2.5.3",
+ "@tauri-apps/plugin-os": "2.3.2",
"@use-gesture/react": "10.3.1",
"@vanilla-extract/css": "^1.18.0",
"@vanilla-extract/recipes": "^0.5.7",
@@ -53,7 +62,6 @@
"file-saver": "^2.0.5",
"focus-trap-react": "^10.3.1",
"folds": "^2.6.2",
- "framer-motion": "12.34.3",
"html-dom-parser": "^5.1.8",
"html-react-parser": "^4.2.10",
"i18next": "^25.8.13",
@@ -67,6 +75,7 @@
"matrix-js-sdk": "^38.4.0",
"matrix-widget-api": "1.13.0",
"millify": "^6.1.0",
+ "motion": "12.35.0",
"pdfjs-dist": "^5.4.624",
"prismjs": "^1.30.0",
"react": "^18.3.1",
@@ -84,6 +93,7 @@
"slate-dom": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
+ "tauri-plugin-splashscreen-api": "file:./crates/tauri-plugin-splashscreen",
"ua-parser-js": "^1.0.41",
"workbox-precaching": "^7.4.0"
},
@@ -95,6 +105,7 @@
"@eslint/js": "9.39.3",
"@rollup/plugin-inject": "^5.0.5",
"@rollup/plugin-wasm": "^6.2.2",
+ "@tauri-apps/cli": "2.10.1",
"@types/chroma-js": "^3.1.2",
"@types/file-saver": "^2.0.7",
"@types/is-hotkey": "^0.1.10",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 129484b42..a59c05e25 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -31,6 +31,9 @@ importers:
'@fontsource/space-mono':
specifier: 5.2.9
version: 5.2.9
+ '@sableclient/tauri-plugin-notifications-api':
+ specifier: git+https://github.com/SableClient/tauri-plugin-notifications.git#9f162c62f89f9f7e1e17c71893faa51d140f18fd
+ version: https://codeload.github.com/SableClient/tauri-plugin-notifications/tar.gz/9f162c62f89f9f7e1e17c71893faa51d140f18fd
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@18.3.1)
@@ -40,6 +43,18 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.13.19
version: 3.13.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@tauri-apps/api':
+ specifier: 2.10.1
+ version: 2.10.1
+ '@tauri-apps/plugin-deep-link':
+ specifier: 2.4.7
+ version: 2.4.7
+ '@tauri-apps/plugin-opener':
+ specifier: 2.5.3
+ version: 2.5.3
+ '@tauri-apps/plugin-os':
+ specifier: 2.3.2
+ version: 2.3.2
'@use-gesture/react':
specifier: 10.3.1
version: 10.3.1(react@18.3.1)
@@ -97,9 +112,6 @@ importers:
folds:
specifier: ^2.6.2
version: 2.6.2(@vanilla-extract/css@1.18.0)(@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.18.0))(classnames@2.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- framer-motion:
- specifier: 12.34.3
- version: 12.34.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
html-dom-parser:
specifier: ^5.1.8
version: 5.1.8
@@ -139,6 +151,9 @@ importers:
millify:
specifier: ^6.1.0
version: 6.1.0
+ motion:
+ specifier: 12.35.0
+ version: 12.35.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
pdfjs-dist:
specifier: ^5.4.624
version: 5.5.207
@@ -190,6 +205,9 @@ importers:
slate-react:
specifier: ^0.123.0
version: 0.123.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate-dom@0.123.0(slate@0.123.0))(slate@0.123.0)
+ tauri-plugin-splashscreen-api:
+ specifier: file:./crates/tauri-plugin-splashscreen
+ version: file:crates/tauri-plugin-splashscreen
ua-parser-js:
specifier: ^1.0.41
version: 1.0.41
@@ -218,6 +236,9 @@ importers:
'@rollup/plugin-wasm':
specifier: ^6.2.2
version: 6.2.2(rollup@4.59.0)
+ '@tauri-apps/cli':
+ specifier: 2.10.1
+ version: 2.10.1
'@types/chroma-js':
specifier: ^3.1.2
version: 3.1.2
@@ -2322,6 +2343,10 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@sableclient/tauri-plugin-notifications-api@https://codeload.github.com/SableClient/tauri-plugin-notifications/tar.gz/9f162c62f89f9f7e1e17c71893faa51d140f18fd':
+ resolution: {tarball: https://codeload.github.com/SableClient/tauri-plugin-notifications/tar.gz/9f162c62f89f9f7e1e17c71893faa51d140f18fd}
+ version: 0.4.3
+
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
@@ -2517,6 +2542,94 @@ packages:
'@tanstack/virtual-core@3.13.21':
resolution: {integrity: sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==}
+ '@tauri-apps/api@2.10.1':
+ resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
+
+ '@tauri-apps/cli-darwin-arm64@2.10.1':
+ resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tauri-apps/cli-darwin-x64@2.10.1':
+ resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
+ resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tauri-apps/cli-linux-arm64-gnu@2.10.1':
+ resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tauri-apps/cli-linux-arm64-musl@2.10.1':
+ resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
+ resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tauri-apps/cli-linux-x64-gnu@2.10.1':
+ resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tauri-apps/cli-linux-x64-musl@2.10.1':
+ resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tauri-apps/cli-win32-arm64-msvc@2.10.1':
+ resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tauri-apps/cli-win32-ia32-msvc@2.10.1':
+ resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==}
+ engines: {node: '>= 10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@tauri-apps/cli-win32-x64-msvc@2.10.1':
+ resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tauri-apps/cli@2.10.1':
+ resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==}
+ engines: {node: '>= 10'}
+ hasBin: true
+
+ '@tauri-apps/plugin-deep-link@2.4.7':
+ resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==}
+
+ '@tauri-apps/plugin-opener@2.5.3':
+ resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
+
+ '@tauri-apps/plugin-os@2.3.2':
+ resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -3555,8 +3668,8 @@ packages:
engines: {node: '>=18.3.0'}
hasBin: true
- framer-motion@12.34.3:
- resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==}
+ framer-motion@12.35.2:
+ resolution: {integrity: sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
@@ -4146,6 +4259,20 @@ packages:
motion-utils@12.29.2:
resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==}
+ motion@12.35.0:
+ resolution: {integrity: sha512-BQUhNUIGvUcwXCzwmnT1JpjUqab34lIwxHnXUyWRht1WC1vAyp7/4qgMiUXxN3K6hgUhyoR+HNnLeQMwUZjVjw==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4739,6 +4866,9 @@ packages:
tar-mini@0.2.0:
resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==}
+ tauri-plugin-splashscreen-api@file:crates/tauri-plugin-splashscreen:
+ resolution: {directory: crates/tauri-plugin-splashscreen, type: directory}
+
temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
@@ -7484,6 +7614,10 @@ snapshots:
'@rtsao/scc@1.1.0': {}
+ '@sableclient/tauri-plugin-notifications-api@https://codeload.github.com/SableClient/tauri-plugin-notifications/tar.gz/9f162c62f89f9f7e1e17c71893faa51d140f18fd':
+ dependencies:
+ '@tauri-apps/api': 2.10.1
+
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.14': {}
@@ -7657,6 +7791,67 @@ snapshots:
'@tanstack/virtual-core@3.13.21': {}
+ '@tauri-apps/api@2.10.1': {}
+
+ '@tauri-apps/cli-darwin-arm64@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-darwin-x64@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-linux-arm64-gnu@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-linux-arm64-musl@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-linux-x64-gnu@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-linux-x64-musl@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-win32-arm64-msvc@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-win32-ia32-msvc@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli-win32-x64-msvc@2.10.1':
+ optional: true
+
+ '@tauri-apps/cli@2.10.1':
+ optionalDependencies:
+ '@tauri-apps/cli-darwin-arm64': 2.10.1
+ '@tauri-apps/cli-darwin-x64': 2.10.1
+ '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1
+ '@tauri-apps/cli-linux-arm64-gnu': 2.10.1
+ '@tauri-apps/cli-linux-arm64-musl': 2.10.1
+ '@tauri-apps/cli-linux-riscv64-gnu': 2.10.1
+ '@tauri-apps/cli-linux-x64-gnu': 2.10.1
+ '@tauri-apps/cli-linux-x64-musl': 2.10.1
+ '@tauri-apps/cli-win32-arm64-msvc': 2.10.1
+ '@tauri-apps/cli-win32-ia32-msvc': 2.10.1
+ '@tauri-apps/cli-win32-x64-msvc': 2.10.1
+
+ '@tauri-apps/plugin-deep-link@2.4.7':
+ dependencies:
+ '@tauri-apps/api': 2.10.1
+
+ '@tauri-apps/plugin-opener@2.5.3':
+ dependencies:
+ '@tauri-apps/api': 2.10.1
+
+ '@tauri-apps/plugin-os@2.3.2':
+ dependencies:
+ '@tauri-apps/api': 2.10.1
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -8929,7 +9124,7 @@ snapshots:
dependencies:
fd-package-json: 2.0.0
- framer-motion@12.34.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ framer-motion@12.35.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 12.35.2
motion-utils: 12.29.2
@@ -9524,6 +9719,14 @@ snapshots:
motion-utils@12.29.2: {}
+ motion@12.35.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ framer-motion: 12.35.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -10258,6 +10461,10 @@ snapshots:
tar-mini@0.2.0: {}
+ tauri-plugin-splashscreen-api@file:crates/tauri-plugin-splashscreen:
+ dependencies:
+ '@tauri-apps/api': 2.10.1
+
temp-dir@2.0.0: {}
tempy@0.6.0:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 47b97ee36..2e0230c74 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,6 +2,7 @@ engineStrict: true
minimumReleaseAge: 1440
allowBuilds:
+ '@sableclient/tauri-plugin-notifications-api': true
'@swc/core': true
esbuild: true
sharp: true
diff --git a/public/full_res_sable_square.png b/public/full_res_sable_square.png
new file mode 100644
index 000000000..1c3980e22
Binary files /dev/null and b/public/full_res_sable_square.png differ
diff --git a/scripts/symlink-tauri-icons.js b/scripts/symlink-tauri-icons.js
new file mode 100644
index 000000000..25674f335
--- /dev/null
+++ b/scripts/symlink-tauri-icons.js
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+/* eslint-disable no-console */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { createTextHelpers } from './utils/console-style.js';
+
+const ANDROID_ICONS = [
+ 'mipmap-hdpi',
+ 'mipmap-mdpi',
+ 'mipmap-xhdpi',
+ 'mipmap-xxhdpi',
+ 'mipmap-xxxhdpi',
+]
+ .flatMap((dir) => [
+ `${dir}/ic_launcher.png`,
+ `${dir}/ic_launcher_round.png`,
+ `${dir}/ic_launcher_foreground.png`,
+ `${dir}/ic_notification.png`,
+ ])
+ .concat(['mipmap-anydpi-v26/ic_launcher.xml', 'values/ic_launcher_background.xml'])
+ .concat(['drawable/ic_notification.xml', 'drawable/notification_icon.xml']);
+
+const IOS_ICONS = [
+ 'AppIcon-20x20@1x.png',
+ 'AppIcon-20x20@2x.png',
+ 'AppIcon-20x20@2x-1.png',
+ 'AppIcon-20x20@3x.png',
+ 'AppIcon-29x29@1x.png',
+ 'AppIcon-29x29@2x.png',
+ 'AppIcon-29x29@2x-1.png',
+ 'AppIcon-29x29@3x.png',
+ 'AppIcon-40x40@1x.png',
+ 'AppIcon-40x40@2x.png',
+ 'AppIcon-40x40@2x-1.png',
+ 'AppIcon-40x40@3x.png',
+ 'AppIcon-60x60@2x.png',
+ 'AppIcon-60x60@3x.png',
+ 'AppIcon-76x76@1x.png',
+ 'AppIcon-76x76@2x.png',
+ 'AppIcon-83.5x83.5@2x.png',
+ 'AppIcon-512@2x.png',
+];
+
+function parseArgs(argv) {
+ let write = false;
+ let force = false;
+
+ argv.forEach((arg) => {
+ if (arg === '--write') write = true;
+ if (arg === '--force') force = true;
+ if (arg === '--help' || arg === '-h') {
+ console.log(
+ [
+ 'Usage: node scripts/symlink-icons.js [--write] [--force]',
+ '',
+ 'Default mode is dry-run.',
+ '--write Apply changes (create symlinks).',
+ '--force Overwrite existing symlinks.',
+ ].join('\n')
+ );
+ process.exit(0);
+ }
+ });
+
+ return { write, force };
+}
+
+function createSymlink(src, dest, write, force, helpers) {
+ const { dim, red, green } = helpers;
+
+ if (!fs.existsSync(src)) {
+ console.log(` ${red('!')} ${dim('source missing, skipping:')} ${src}`);
+ return false;
+ }
+
+ const exists = fs.existsSync(dest);
+ if (exists && !force) {
+ console.log(
+ ` ${dim('~')} ${dim(path.relative(process.cwd(), dest))} ${dim('(exists, skipping)')}`
+ );
+ return false;
+ }
+
+ if (write) {
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+
+ try {
+ fs.unlinkSync(dest);
+ } catch {
+ // dest does not exist — nothing to remove
+ }
+
+ const rel = path.relative(path.dirname(dest), src).split(path.sep).join('/');
+ fs.symlinkSync(rel, dest);
+ }
+
+ console.log(` ${green('+')} ${dim(path.relative(process.cwd(), dest))}`);
+ return true;
+}
+
+function processGroup(label, srcDir, destDir, files, write, force, helpers) {
+ const { dim, red } = helpers;
+
+ if (!fs.existsSync(destDir)) {
+ console.log(`\n${label}: ${red('destination not found')}, skipping.\n ${dim(destDir)}`);
+ return;
+ }
+
+ console.log(`\n${label}`);
+
+ const results = files.map((file) => {
+ try {
+ return createSymlink(
+ path.join(srcDir, file),
+ path.join(destDir, file),
+ write,
+ force,
+ helpers
+ );
+ } catch (err) {
+ console.log(` ${red('-')} ${file}: ${err.message}`);
+ return false;
+ }
+ });
+
+ const ok = results.filter(Boolean).length;
+ const verb = write ? 'created' : 'would create';
+ console.log(` ${dim(`→ ${ok}/${files.length} symlinks ${verb}.`)}`);
+}
+
+function main() {
+ const ROOT = process.cwd();
+ const { write, force } = parseArgs(process.argv.slice(2));
+ const helpers = createTextHelpers();
+
+ processGroup(
+ 'Android',
+ path.join(ROOT, 'src-tauri', 'icons', 'android'),
+ path.join(ROOT, 'src-tauri', 'gen', 'android', 'app', 'src', 'main', 'res'),
+ ANDROID_ICONS,
+ write,
+ force,
+ helpers
+ );
+
+ processGroup(
+ 'iOS',
+ path.join(ROOT, 'src-tauri', 'icons', 'ios'),
+ path.join(ROOT, 'src-tauri', 'gen', 'apple', 'Assets.xcassets', 'AppIcon.appiconset'),
+ IOS_ICONS,
+ write,
+ force,
+ helpers
+ );
+
+ const mode = write ? 'Applied' : 'Dry run';
+ console.log(`\n${mode}.`);
+ if (!write) console.log('Re-run with --write to apply changes.');
+}
+
+main();
diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore
new file mode 100644
index 000000000..489be3277
--- /dev/null
+++ b/src-tauri/.gitignore
@@ -0,0 +1,4 @@
+# Generated by Cargo
+# will have compiled files and executables
+/target/
+/gen/schemas
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
new file mode 100644
index 000000000..273a521c3
--- /dev/null
+++ b/src-tauri/Cargo.lock
@@ -0,0 +1,6140 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "Inflector"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "version_check",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_log-sys"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
+
+[[package]]
+name = "android_logger"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
+dependencies = [
+ "android_log-sys",
+ "env_filter",
+ "log",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "atk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
+dependencies = [
+ "atk-sys",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "borsh"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
+dependencies = [
+ "borsh-derive",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "brotli"
+version = "8.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "byte-unit"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
+dependencies = [
+ "rust_decimal",
+ "schemars 1.2.1",
+ "serde",
+ "utf8-width",
+]
+
+[[package]]
+name = "bytecheck"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
+dependencies = [
+ "bytecheck_derive",
+ "ptr_meta",
+ "simdutf8",
+]
+
+[[package]]
+name = "bytecheck_derive"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cairo-rs"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
+dependencies = [
+ "bitflags 2.11.0",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "camino"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
+dependencies = [
+ "serde",
+ "toml 0.9.12+spec-1.1.0",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "chacha20"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.0",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.11.0",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.11.0",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.29.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "matches",
+ "phf 0.10.1",
+ "proc-macro2",
+ "quote",
+ "smallvec",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ctor"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "0.99.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.11.0",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dlopen2"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
+dependencies = [
+ "dlopen2_derive",
+ "libc",
+ "once_cell",
+ "winapi",
+]
+
+[[package]]
+name = "dlopen2_derive"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dlv-list"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
+dependencies = [
+ "const-random",
+]
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 0.9.12+spec-1.1.0",
+ "vswhom",
+ "winreg",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enigo"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71744ff36f35a4276e8827add8102d0e792378c574fd93cb4e1c8e0505f96b7c"
+dependencies = [
+ "core-foundation",
+ "core-graphics",
+ "foreign-types-shared",
+ "libc",
+ "log",
+ "nom",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "windows",
+ "x11rb",
+ "xkbcommon",
+ "xkeysym",
+]
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "env_filter"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "fern"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "fxhash"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "gdk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
+dependencies = [
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
+dependencies = [
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkx11"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
+dependencies = [
+ "gdk",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "libc",
+ "x11",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps",
+ "x11",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.9.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "gio"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
+dependencies = [
+ "bitflags 2.11.0",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "once_cell",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 2.0.2",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "gobject-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
+dependencies = [
+ "atk",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
+dependencies = [
+ "proc-macro-crate 1.3.1",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "html5ever"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+ "match_token",
+]
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "pin-utils",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
+dependencies = [
+ "byteorder",
+ "png",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "iri-string"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+[[package]]
+name = "js-sys"
+version = "0.3.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "keyboard-types"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+dependencies = [
+ "bitflags 2.11.0",
+ "serde",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "kuchikiki"
+version = "0.8.8-speedreader"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "indexmap 2.13.0",
+ "selectors",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libappindicator"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
+dependencies = [
+ "glib",
+ "gtk",
+ "gtk-sys",
+ "libappindicator-sys",
+ "log",
+]
+
+[[package]]
+name = "libappindicator-sys"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
+dependencies = [
+ "gtk-sys",
+ "libloading",
+ "once_cell",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+dependencies = [
+ "value-bag",
+]
+
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
+dependencies = [
+ "cc",
+ "objc2",
+ "objc2-foundation",
+ "time",
+]
+
+[[package]]
+name = "markup5ever"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
+dependencies = [
+ "log",
+ "phf 0.11.3",
+ "phf_codegen 0.11.3",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
+[[package]]
+name = "match_token"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "matches"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "memmap2"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "muda"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "gtk",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "once_cell",
+ "png",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.11.0",
+ "jni-sys",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.11.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "nodrop"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "notify-rust"
+version = "4.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
+dependencies = [
+ "futures-lite",
+ "log",
+ "mac-notification-sys",
+ "serde",
+ "tauri-winrt-notification",
+ "zbus",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+ "objc2-exception-helper",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.11.0",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.11.0",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.11.0",
+ "dispatch2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-exception-helper"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.11.0",
+ "block2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.11.0",
+ "block2",
+ "objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-web-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
+dependencies = [
+ "bitflags 2.11.0",
+ "block2",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "open"
+version = "5.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
+dependencies = [
+ "dunce",
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-multimap"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
+dependencies = [
+ "dlv-list",
+ "hashbrown 0.14.5",
+]
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "os_info"
+version = "3.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
+dependencies = [
+ "android_system_properties",
+ "log",
+ "nix",
+ "objc2",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "serde",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pango"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
+dependencies = [
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
+dependencies = [
+ "phf_shared 0.8.0",
+]
+
+[[package]]
+name = "phf"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
+dependencies = [
+ "phf_macros 0.10.0",
+ "phf_shared 0.10.0",
+ "proc-macro-hack",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros 0.11.3",
+ "phf_shared 0.11.3",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815"
+dependencies = [
+ "phf_generator 0.8.0",
+ "phf_shared 0.8.0",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526"
+dependencies = [
+ "phf_shared 0.8.0",
+ "rand 0.7.3",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
+dependencies = [
+ "phf_shared 0.10.0",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared 0.11.3",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
+dependencies = [
+ "phf_generator 0.10.0",
+ "phf_shared 0.10.0",
+ "proc-macro-hack",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7"
+dependencies = [
+ "siphasher 0.3.11",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
+dependencies = [
+ "siphasher 0.3.11",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher 1.0.2",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "plist"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap 2.13.0",
+ "quick-xml 0.38.4",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
+dependencies = [
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.4+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-hack"
+version = "0.5.20+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "ptr_meta"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
+dependencies = [
+ "ptr_meta_derive",
+]
+
+[[package]]
+name = "ptr_meta_derive"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.37.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "rand"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+dependencies = [
+ "getrandom 0.1.16",
+ "libc",
+ "rand_chacha 0.2.2",
+ "rand_core 0.5.1",
+ "rand_hc",
+ "rand_pcg",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.2",
+ "rand_core 0.10.0",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+dependencies = [
+ "getrandom 0.1.16",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
+
+[[package]]
+name = "rand_hc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
+dependencies = [
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_pcg"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429"
+dependencies = [
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rend"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
+dependencies = [
+ "bytecheck",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "rkyv"
+version = "0.7.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
+dependencies = [
+ "bitvec",
+ "bytecheck",
+ "bytes",
+ "hashbrown 0.12.3",
+ "ptr_meta",
+ "rend",
+ "rkyv_derive",
+ "seahash",
+ "tinyvec",
+ "uuid",
+]
+
+[[package]]
+name = "rkyv_derive"
+version = "0.7.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "rust-ini"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
+dependencies = [
+ "cfg-if",
+ "ordered-multimap",
+]
+
+[[package]]
+name = "rust_decimal"
+version = "1.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
+dependencies = [
+ "arrayvec",
+ "borsh",
+ "bytes",
+ "num-traits",
+ "rand 0.8.5",
+ "rkyv",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.11.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "sable"
+version = "0.1.0"
+dependencies = [
+ "enigo",
+ "log",
+ "sable-macros",
+ "serde",
+ "serde_json",
+ "specta",
+ "specta-typescript",
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-deep-link",
+ "tauri-plugin-log",
+ "tauri-plugin-notifications",
+ "tauri-plugin-opener",
+ "tauri-plugin-os",
+ "tauri-plugin-single-instance",
+ "tauri-plugin-splashscreen",
+ "tauri-plugin-window-state",
+ "tauri-specta",
+ "tokio",
+ "windows",
+]
+
+[[package]]
+name = "sable-macros"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schemars"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
+dependencies = [
+ "dyn-clone",
+ "indexmap 1.9.3",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "seahash"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
+
+[[package]]
+name = "selectors"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
+dependencies = [
+ "bitflags 1.3.2",
+ "cssparser",
+ "derive_more",
+ "fxhash",
+ "log",
+ "phf 0.8.0",
+ "phf_codegen 0.8.0",
+ "precomputed-hash",
+ "servo_arc",
+ "smallvec",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-untagged"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.13.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741"
+dependencies = [
+ "nodrop",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "siphasher"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
+
+[[package]]
+name = "siphasher"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "softbuffer"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
+dependencies = [
+ "bytemuck",
+ "js-sys",
+ "ndk",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "redox_syscall",
+ "tracing",
+ "wasm-bindgen",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "soup3"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
+dependencies = [
+ "futures-channel",
+ "gio",
+ "glib",
+ "libc",
+ "soup3-sys",
+]
+
+[[package]]
+name = "soup3-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "specta"
+version = "2.0.0-rc.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
+dependencies = [
+ "paste",
+ "specta-macros",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "specta-macros"
+version = "2.0.0-rc.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
+dependencies = [
+ "Inflector",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "specta-serde"
+version = "0.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
+dependencies = [
+ "specta",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "specta-typescript"
+version = "0.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
+dependencies = [
+ "specta",
+ "specta-serde",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared 0.11.3",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "swift-bridge"
+version = "0.1.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384ed39ea10f1cefabb197b7d8e67f0034b15a94ccbb1038b8e020da59bfb0be"
+dependencies = [
+ "once_cell",
+ "swift-bridge-build",
+ "swift-bridge-macro",
+ "tokio",
+]
+
+[[package]]
+name = "swift-bridge-build"
+version = "0.1.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71b36df21e7f8a8b5eeb718d2e71f9cfc308477bfb705981cca705de9767dcb7"
+dependencies = [
+ "proc-macro2",
+ "swift-bridge-ir",
+ "syn 1.0.109",
+ "tempfile",
+]
+
+[[package]]
+name = "swift-bridge-ir"
+version = "0.1.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c73bd16155df50708b92306945656e57d62d321290a7db490f299f709fb31c83"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "swift-bridge-macro"
+version = "0.1.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a13dc0dc875d85341dec5b5344a7d713f20eb5650b71086b27d09a6ece272f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "swift-bridge-ir",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "swift-rs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7"
+dependencies = [
+ "base64 0.21.7",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.2",
+ "version-compare",
+]
+
+[[package]]
+name = "tao"
+version = "0.34.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
+dependencies = [
+ "bitflags 2.11.0",
+ "block2",
+ "core-foundation",
+ "core-graphics",
+ "crossbeam-channel",
+ "dispatch2",
+ "dlopen2",
+ "dpi",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gtk",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "once_cell",
+ "parking_lot",
+ "raw-window-handle",
+ "tao-macros",
+ "unicode-segmentation",
+ "url",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "2.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "cookie",
+ "dirs",
+ "dunce",
+ "embed_plist",
+ "getrandom 0.3.4",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "jni",
+ "libc",
+ "log",
+ "mime",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "percent-encoding",
+ "plist",
+ "raw-window-handle",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "specta",
+ "swift-rs",
+ "tauri-build",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "tokio",
+ "tray-icon",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "window-vibrancy",
+ "windows",
+]
+
+[[package]]
+name = "tauri-build"
+version = "2.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs",
+ "glob",
+ "heck 0.5.0",
+ "json-patch",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "tauri-winres",
+ "toml 0.9.12+spec-1.1.0",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "2.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
+dependencies = [
+ "base64 0.22.1",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "syn 2.0.117",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "2.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-plugin"
+version = "2.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa"
+dependencies = [
+ "anyhow",
+ "glob",
+ "plist",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "toml 0.9.12+spec-1.1.0",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-plugin-deep-link"
+version = "2.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
+dependencies = [
+ "dunce",
+ "plist",
+ "rust-ini",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "tracing",
+ "url",
+ "windows-registry",
+ "windows-result 0.3.4",
+]
+
+[[package]]
+name = "tauri-plugin-log"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
+dependencies = [
+ "android_logger",
+ "byte-unit",
+ "fern",
+ "log",
+ "objc2",
+ "objc2-foundation",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "swift-rs",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "time",
+]
+
+[[package]]
+name = "tauri-plugin-notifications"
+version = "0.4.3"
+source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=9f162c62f89f9f7e1e17c71893faa51d140f18fd#9f162c62f89f9f7e1e17c71893faa51d140f18fd"
+dependencies = [
+ "log",
+ "notify-rust",
+ "rand 0.10.0",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "swift-bridge",
+ "swift-bridge-build",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+]
+
+[[package]]
+name = "tauri-plugin-opener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
+dependencies = [
+ "dunce",
+ "glob",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "open",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "url",
+ "windows",
+ "zbus",
+]
+
+[[package]]
+name = "tauri-plugin-os"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
+dependencies = [
+ "gethostname",
+ "log",
+ "os_info",
+ "serde",
+ "serde_json",
+ "serialize-to-javascript",
+ "sys-locale",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "tauri-plugin-single-instance"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin-deep-link",
+ "thiserror 2.0.18",
+ "tracing",
+ "windows-sys 0.60.2",
+ "zbus",
+]
+
+[[package]]
+name = "tauri-plugin-splashscreen"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "tauri-plugin-window-state"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
+dependencies = [
+ "bitflags 2.11.0",
+ "log",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2"
+dependencies = [
+ "cookie",
+ "dpi",
+ "gtk",
+ "http",
+ "jni",
+ "objc2",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e"
+dependencies = [
+ "gtk",
+ "http",
+ "jni",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "softbuffer",
+ "tao",
+ "tauri-runtime",
+ "tauri-utils",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+ "wry",
+]
+
+[[package]]
+name = "tauri-specta"
+version = "2.0.0-rc.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
+dependencies = [
+ "heck 0.5.0",
+ "serde",
+ "serde_json",
+ "specta",
+ "specta-typescript",
+ "tauri",
+ "tauri-specta-macros",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "tauri-specta-macros"
+version = "2.0.0-rc.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
+dependencies = [
+ "anyhow",
+ "brotli",
+ "cargo_metadata",
+ "ctor",
+ "dunce",
+ "glob",
+ "html5ever",
+ "http",
+ "infer",
+ "json-patch",
+ "kuchikiki",
+ "log",
+ "memchr",
+ "phf 0.11.3",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde-untagged",
+ "serde_json",
+ "serde_with",
+ "swift-rs",
+ "thiserror 2.0.18",
+ "toml 0.9.12+spec-1.1.0",
+ "url",
+ "urlpattern",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0"
+dependencies = [
+ "dunce",
+ "embed-resource",
+ "toml 0.9.12+spec-1.1.0",
+]
+
+[[package]]
+name = "tauri-winrt-notification"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
+dependencies = [
+ "quick-xml 0.37.5",
+ "thiserror 2.0.18",
+ "windows",
+ "windows-version",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.13.0",
+ "serde_core",
+ "serde_spanned 1.0.4",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.14",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.0.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.13.0",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
+dependencies = [
+ "indexmap 2.13.0",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.4+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
+dependencies = [
+ "indexmap 2.13.0",
+ "toml_datetime 1.0.0+spec-1.1.0",
+ "toml_parser",
+ "winnow 0.7.14",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.0.9+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
+dependencies = [
+ "winnow 0.7.14",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.0.6+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+dependencies = [
+ "bitflags 2.11.0",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "iri-string",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tray-icon"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
+dependencies = [
+ "crossbeam-channel",
+ "dirs",
+ "libappindicator",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "once_cell",
+ "png",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unic-char-property"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
+dependencies = [
+ "unic-char-range",
+]
+
+[[package]]
+name = "unic-char-range"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
+
+[[package]]
+name = "unic-common"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
+
+[[package]]
+name = "unic-ucd-ident"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
+dependencies = [
+ "unic-char-property",
+ "unic-char-range",
+ "unic-ucd-version",
+]
+
+[[package]]
+name = "unic-ucd-version"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
+dependencies = [
+ "unic-common",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlpattern"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
+dependencies = [
+ "regex",
+ "serde",
+ "unic-ucd-ident",
+ "url",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8-width"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "value-bag"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.9.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap 2.13.0",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.11.0",
+ "hashbrown 0.15.5",
+ "indexmap 2.13.0",
+ "semver",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup3",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pkg-config",
+ "soup3-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-implement",
+ "windows-interface",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
+dependencies = [
+ "thiserror 2.0.18",
+ "windows",
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "window-vibrancy"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
+dependencies = [
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "windows-sys 0.59.0",
+ "windows-version",
+]
+
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.61.2",
+ "windows-future",
+ "windows-link 0.1.3",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-registry"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
+dependencies = [
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.55.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "indexmap 2.13.0",
+ "prettyplease",
+ "syn 2.0.117",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.11.0",
+ "indexmap 2.13.0",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap 2.13.0",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+
+[[package]]
+name = "wry"
+version = "0.54.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a"
+dependencies = [
+ "base64 0.22.1",
+ "block2",
+ "cookie",
+ "crossbeam-channel",
+ "dirs",
+ "dpi",
+ "dunce",
+ "gdkx11",
+ "gtk",
+ "html5ever",
+ "http",
+ "javascriptcore-rs",
+ "jni",
+ "kuchikiki",
+ "libc",
+ "ndk",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "sha2",
+ "soup3",
+ "tao-macros",
+ "thiserror 2.0.18",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xkbcommon"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
+dependencies = [
+ "libc",
+ "memmap2",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
+[[package]]
+name = "yoke"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 0.7.14",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
+dependencies = [
+ "serde",
+ "winnow 0.7.14",
+ "zvariant",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zvariant"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 0.7.14",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+ "winnow 0.7.14",
+]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
new file mode 100644
index 000000000..49e46f408
--- /dev/null
+++ b/src-tauri/Cargo.toml
@@ -0,0 +1,56 @@
+[package]
+name = "sable"
+version = "0.1.0"
+description = "Yet another matrix client but better"
+authors = ["you"]
+license = ""
+repository = ""
+edition = "2021"
+rust-version = "1.77.2"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[lib]
+name = "app_lib"
+crate-type = ["staticlib", "cdylib", "rlib"]
+
+[build-dependencies]
+tauri-build = { version = "2.5.6", features = [] }
+
+[dependencies]
+serde_json = "1.0"
+serde = { version = "1.0", features = ["derive"] }
+log = "0.4"
+tokio = { version = "1", features = ["time"] }
+
+tauri = { version = "2.10.3", features = ["tray-icon"] }
+
+specta = "2.0.0-rc.22"
+specta-typescript = "0.0.9"
+tauri-specta = { version = "2.0.0-rc.21", features = ["derive", "typescript"] }
+
+tauri-plugin-log = "2"
+tauri-plugin-opener = "2.5.3"
+tauri-plugin-os = "2.3.2"
+tauri-plugin-deep-link = "2.4.7"
+tauri-plugin-splashscreen = { path = "../crates/tauri-plugin-splashscreen" }
+
+sable-macros = { path = "../crates/sable-macros" }
+
+[target.'cfg(target_os = "windows")'.dependencies]
+enigo = "0.5.0"
+windows = { version = "0.61", features = [
+ "Win32_Foundation",
+ "Win32_UI_WindowsAndMessaging",
+ "Win32_System_Threading",
+ "Win32_System_ProcessStatus"
+] }
+
+[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
+tauri-plugin-single-instance = { version = "2.4.0", features = ["deep-link"] }
+
+[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+tauri-plugin-window-state = "2.4.1"
+
+[target.'cfg(target_os = "android")'.dependencies]
+tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "9f162c62f89f9f7e1e17c71893faa51d140f18fd", features = ["unified-push"] }
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
new file mode 100644
index 000000000..d860e1e6a
--- /dev/null
+++ b/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
new file mode 100644
index 000000000..2052d768d
--- /dev/null
+++ b/src-tauri/capabilities/default.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "default",
+ "description": "enables the default permissions",
+ "windows": ["main"],
+ "permissions": [
+ "core:default",
+ "core:window:allow-close",
+ "core:window:allow-minimize",
+ "core:window:allow-maximize",
+ "core:window:allow-set-focus",
+ "core:window:allow-show",
+ "core:window:allow-is-maximized",
+ "core:window:allow-toggle-maximize",
+ "core:window:allow-start-dragging",
+ "deep-link:default",
+ "os:default",
+ "opener:default",
+ "opener:allow-default-urls",
+ {
+ "identifier": "opener:allow-open-url",
+ "allow": [
+ {
+ "url": "https://*",
+ "app": "inAppBrowser"
+ }
+ ]
+ }
+ ]
+}
diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json
new file mode 100644
index 000000000..6944175e0
--- /dev/null
+++ b/src-tauri/capabilities/desktop.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "desktop-capability",
+ "platforms": ["macOS", "windows", "linux"],
+ "windows": ["main"],
+ "permissions": ["window-state:default"]
+}
diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json
new file mode 100644
index 000000000..9b03b4d4f
--- /dev/null
+++ b/src-tauri/capabilities/mobile.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "../gen/schemas/mobile-schema.json",
+ "identifier": "mobile-capability",
+ "platforms": ["android", "iOS"],
+ "windows": ["main"],
+ "permissions": [
+ "core:default",
+ "deep-link:default",
+ "splashscreen:allow-close",
+ "notifications:default",
+ "notifications:allow-register-for-unified-push",
+ "notifications:allow-unregister-from-unified-push",
+ "notifications:allow-get-unified-push-distributors",
+ "notifications:allow-save-unified-push-distributor",
+ "notifications:allow-get-unified-push-distributor"
+ ]
+}
diff --git a/src-tauri/gen/android/.editorconfig b/src-tauri/gen/android/.editorconfig
new file mode 100644
index 000000000..ebe51d3bf
--- /dev/null
+++ b/src-tauri/gen/android/.editorconfig
@@ -0,0 +1,12 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = false
+insert_final_newline = false
\ No newline at end of file
diff --git a/src-tauri/gen/android/.gitignore b/src-tauri/gen/android/.gitignore
new file mode 100644
index 000000000..952a4a65b
--- /dev/null
+++ b/src-tauri/gen/android/.gitignore
@@ -0,0 +1,20 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+.kotlin/
+build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+key.properties
+
+/.tauri
+/tauri.settings.gradle
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/.gitignore b/src-tauri/gen/android/app/.gitignore
new file mode 100644
index 000000000..6c4d56b4a
--- /dev/null
+++ b/src-tauri/gen/android/app/.gitignore
@@ -0,0 +1,6 @@
+/src/main/**/generated
+/src/main/jniLibs/**/*.so
+/src/main/assets/tauri.conf.json
+/tauri.build.gradle.kts
+/proguard-tauri.pro
+/tauri.properties
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts
new file mode 100644
index 000000000..aaecc8536
--- /dev/null
+++ b/src-tauri/gen/android/app/build.gradle.kts
@@ -0,0 +1,70 @@
+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 = "moe.sable.app"
+ defaultConfig {
+ manifestPlaceholders["usesCleartextTraffic"] = "false"
+ applicationId = "moe.sable.app"
+ 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")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.4")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
+}
+
+apply(from = "tauri.build.gradle.kts")
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/proguard-rules.pro b/src-tauri/gen/android/app/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/src-tauri/gen/android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..c650f0d20
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-tauri/gen/android/app/src/main/java/moe/sable/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/moe/sable/app/MainActivity.kt
new file mode 100644
index 000000000..66132f329
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/java/moe/sable/app/MainActivity.kt
@@ -0,0 +1,101 @@
+package moe.sable.app
+
+import android.graphics.Bitmap
+import android.os.Bundle
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.enableEdgeToEdge
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+
+class MainActivity : TauriActivity() {
+ private var lastTop = 0f
+ private var lastBottom = 0f
+ private var lastLeft = 0f
+ private var lastRight = 0f
+ private var webViewRef: WebView? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ }
+
+ override fun onWebViewCreate(webView: WebView) {
+ super.onWebViewCreate(webView)
+ webViewRef = webView
+
+ val originalClient = webView.webViewClient
+
+ webView.webViewClient = object : WebViewClient() {
+ override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
+ originalClient.onPageStarted(view, url, favicon)
+ injectCssInsets(view)
+ }
+
+ override fun onPageFinished(view: WebView, url: String) {
+ originalClient.onPageFinished(view, url)
+ injectCssInsets(view)
+ }
+
+ override fun onPageCommitVisible(view: WebView, url: String) {
+ super.onPageCommitVisible(view, url)
+ injectCssInsets(view)
+ }
+
+ override fun shouldOverrideUrlLoading(
+ view: WebView,
+ request: android.webkit.WebResourceRequest
+ ): Boolean = originalClient.shouldOverrideUrlLoading(view, request)
+
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: android.webkit.WebResourceRequest
+ ): android.webkit.WebResourceResponse? = originalClient.shouldInterceptRequest(view, request)
+
+ override fun onReceivedError(
+ view: WebView,
+ request: android.webkit.WebResourceRequest,
+ error: android.webkit.WebResourceError
+ ) = originalClient.onReceivedError(view, request, error)
+ }
+
+ ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
+ val bars = insets.getInsets(
+ WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
+ )
+ val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
+ val density = view.resources.displayMetrics.density
+
+ lastTop = bars.top / density
+ lastBottom = maxOf(bars.bottom, ime.bottom) / density
+ lastLeft = bars.left / density
+ lastRight = bars.right / density
+
+ // Don't use setPadding — it gets clobbered by Wry/Tauri internals.
+ // Instead, rely solely on CSS custom properties for inset handling.
+ injectCssInsets(view as WebView)
+
+ WindowInsetsCompat.CONSUMED
+ }
+
+ // Wry may reset layout after onWebViewCreate — re-request insets.
+ webView.post { webView.requestApplyInsets() }
+ // Also re-request after a short delay to handle Wry's deferred setup.
+ webView.postDelayed({ webView.requestApplyInsets() }, 500)
+ }
+
+ private fun injectCssInsets(webView: WebView) {
+ webView.evaluateJavascript(
+ """
+ (function() {
+ var s = document.documentElement.style;
+ s.setProperty('--sable-inset-top', '${lastTop}px');
+ s.setProperty('--sable-inset-bottom', '${lastBottom}px');
+ s.setProperty('--sable-inset-left', '${lastLeft}px');
+ s.setProperty('--sable-inset-right', '${lastRight}px');
+ })();
+ """.trimIndent(),
+ null
+ )
+ }
+}
diff --git a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 000000000..cc14f035a
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..a4f78de59
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_notification.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_notification.xml
new file mode 120000
index 000000000..94a4ffc17
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1 @@
+../../../../../../../icons/android/drawable/ic_notification.xml
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/drawable/notification_icon.xml b/src-tauri/gen/android/app/src/main/res/drawable/notification_icon.xml
new file mode 120000
index 000000000..f363fcecd
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/drawable/notification_icon.xml
@@ -0,0 +1 @@
+../../../../../../../icons/android/drawable/notification_icon.xml
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 000000000..4fc244418
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 120000
index 000000000..eb916ab9d
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-anydpi-v26/ic_launcher.xml
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 120000
index 000000000..e4011dc87
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-hdpi/ic_launcher.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 120000
index 000000000..2bb464f07
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-hdpi/ic_launcher_foreground.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 120000
index 000000000..0e1d1415e
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-hdpi/ic_launcher_round.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_notification.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_notification.png
new file mode 120000
index 000000000..38bf128b2
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_notification.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-hdpi/ic_notification.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 120000
index 000000000..c5c8af7d5
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-mdpi/ic_launcher.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 120000
index 000000000..1dc8bd660
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-mdpi/ic_launcher_foreground.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 120000
index 000000000..de6c001a7
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-mdpi/ic_launcher_round.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_notification.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_notification.png
new file mode 120000
index 000000000..04dcb612b
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_notification.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-mdpi/ic_notification.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 120000
index 000000000..c244d43ac
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xhdpi/ic_launcher.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 120000
index 000000000..a9ba70dfa
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xhdpi/ic_launcher_foreground.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 120000
index 000000000..9756cb1f2
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xhdpi/ic_launcher_round.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_notification.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_notification.png
new file mode 120000
index 000000000..d4b91921d
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_notification.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xhdpi/ic_notification.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 120000
index 000000000..1832021dc
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xxhdpi/ic_launcher.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 120000
index 000000000..20afdb7e5
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 120000
index 000000000..f209887ca
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xxhdpi/ic_launcher_round.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_notification.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_notification.png
new file mode 120000
index 000000000..dea0cf035
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_notification.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xxhdpi/ic_notification.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 120000
index 000000000..2c20016b0
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xxxhdpi/ic_launcher.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 120000
index 000000000..18a184d9f
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 120000
index 000000000..c7eff2834
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xxxhdpi/ic_launcher_round.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_notification.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_notification.png
new file mode 120000
index 000000000..78d475ccc
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_notification.png
@@ -0,0 +1 @@
+../../../../../../../icons/android/mipmap-xxxhdpi/ic_notification.png
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml
new file mode 100644
index 000000000..fbc12a6cb
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/src-tauri/gen/android/app/src/main/res/values/colors.xml b/src-tauri/gen/android/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..f8c6127d3
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 120000
index 000000000..3aa4ae6f1
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1 @@
+../../../../../../../icons/android/values/ic_launcher_background.xml
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/values/strings.xml b/src-tauri/gen/android/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..8e9c7417a
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+ sable
+ sable
+
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/values/themes.xml b/src-tauri/gen/android/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..fbc12a6cb
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 000000000..782d63b99
--- /dev/null
+++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts
new file mode 100644
index 000000000..3373810c7
--- /dev/null
+++ b/src-tauri/gen/android/build.gradle.kts
@@ -0,0 +1,22 @@
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.11.0")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.0")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+tasks.register("clean").configure {
+ delete("build")
+}
+
diff --git a/src-tauri/gen/android/buildSrc/build.gradle.kts b/src-tauri/gen/android/buildSrc/build.gradle.kts
new file mode 100644
index 000000000..5c55bba71
--- /dev/null
+++ b/src-tauri/gen/android/buildSrc/build.gradle.kts
@@ -0,0 +1,23 @@
+plugins {
+ `kotlin-dsl`
+}
+
+gradlePlugin {
+ plugins {
+ create("pluginsForCoolKids") {
+ id = "rust"
+ implementationClass = "RustPlugin"
+ }
+ }
+}
+
+repositories {
+ google()
+ mavenCentral()
+}
+
+dependencies {
+ compileOnly(gradleApi())
+ implementation("com.android.tools.build:gradle:8.11.0")
+}
+
diff --git a/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/app/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/app/kotlin/BuildTask.kt
new file mode 100644
index 000000000..f764e2ad9
--- /dev/null
+++ b/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/app/kotlin/BuildTask.kt
@@ -0,0 +1,68 @@
+import java.io.File
+import org.apache.tools.ant.taskdefs.condition.Os
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.logging.LogLevel
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.TaskAction
+
+open class BuildTask : DefaultTask() {
+ @Input
+ var rootDirRel: String? = null
+ @Input
+ var target: String? = null
+ @Input
+ var release: Boolean? = null
+
+ @TaskAction
+ fun assemble() {
+ val executable = """pnpm""";
+ try {
+ runTauriCli(executable)
+ } catch (e: Exception) {
+ if (Os.isFamily(Os.FAMILY_WINDOWS)) {
+ // Try different Windows-specific extensions
+ val fallbacks = listOf(
+ "$executable.exe",
+ "$executable.cmd",
+ "$executable.bat",
+ )
+
+ var lastException: Exception = e
+ for (fallback in fallbacks) {
+ try {
+ runTauriCli(fallback)
+ return
+ } catch (fallbackException: Exception) {
+ lastException = fallbackException
+ }
+ }
+ throw lastException
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ fun runTauriCli(executable: String) {
+ val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
+ val target = target ?: throw GradleException("target cannot be null")
+ val release = release ?: throw GradleException("release cannot be null")
+ val args = listOf("tauri", "android", "android-studio-script");
+
+ project.exec {
+ workingDir(File(project.projectDir, rootDirRel))
+ executable(executable)
+ args(args)
+ if (project.logger.isEnabled(LogLevel.DEBUG)) {
+ args("-vv")
+ } else if (project.logger.isEnabled(LogLevel.INFO)) {
+ args("-v")
+ }
+ if (release) {
+ args("--release")
+ }
+ args(listOf("--target", target))
+ }.assertNormalExitValue()
+ }
+}
\ No newline at end of file
diff --git a/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/app/kotlin/RustPlugin.kt b/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/app/kotlin/RustPlugin.kt
new file mode 100644
index 000000000..4aa7fcaf6
--- /dev/null
+++ b/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/app/kotlin/RustPlugin.kt
@@ -0,0 +1,85 @@
+import com.android.build.api.dsl.ApplicationExtension
+import org.gradle.api.DefaultTask
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.get
+
+const val TASK_GROUP = "rust"
+
+open class Config {
+ lateinit var rootDirRel: String
+}
+
+open class RustPlugin : Plugin {
+ private lateinit var config: Config
+
+ override fun apply(project: Project) = with(project) {
+ config = extensions.create("rust", Config::class.java)
+
+ val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
+ val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
+
+ val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
+ val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
+
+ val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
+
+ extensions.configure {
+ @Suppress("UnstableApiUsage")
+ flavorDimensions.add("abi")
+ productFlavors {
+ create("universal") {
+ dimension = "abi"
+ ndk {
+ abiFilters += abiList
+ }
+ }
+ defaultArchList.forEachIndexed { index, arch ->
+ create(arch) {
+ dimension = "abi"
+ ndk {
+ abiFilters.add(defaultAbiList[index])
+ }
+ }
+ }
+ }
+ }
+
+ afterEvaluate {
+ for (profile in listOf("debug", "release")) {
+ val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
+ val buildTask = tasks.maybeCreate(
+ "rustBuildUniversal$profileCapitalized",
+ DefaultTask::class.java
+ ).apply {
+ group = TASK_GROUP
+ description = "Build dynamic library in $profile mode for all targets"
+ }
+
+ tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
+
+ for (targetPair in targetsList.withIndex()) {
+ val targetName = targetPair.value
+ val targetArch = archList[targetPair.index]
+ val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
+ val targetBuildTask = project.tasks.maybeCreate(
+ "rustBuild$targetArchCapitalized$profileCapitalized",
+ BuildTask::class.java
+ ).apply {
+ group = TASK_GROUP
+ description = "Build dynamic library in $profile mode for $targetArch"
+ rootDirRel = config.rootDirRel
+ target = targetName
+ release = profile == "release"
+ }
+
+ buildTask.dependsOn(targetBuildTask)
+ tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
+ targetBuildTask
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src-tauri/gen/android/gradle.properties b/src-tauri/gen/android/gradle.properties
new file mode 100644
index 000000000..2a7ec6959
--- /dev/null
+++ b/src-tauri/gen/android/gradle.properties
@@ -0,0 +1,24 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.nonFinalResIds=false
\ No newline at end of file
diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..e708b1c02
Binary files /dev/null and b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..c5f9a53c2
--- /dev/null
+++ b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue May 10 19:22:52 CST 2022
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/src-tauri/gen/android/gradlew b/src-tauri/gen/android/gradlew
new file mode 100755
index 000000000..4f906e0c8
--- /dev/null
+++ b/src-tauri/gen/android/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/src-tauri/gen/android/gradlew.bat b/src-tauri/gen/android/gradlew.bat
new file mode 100644
index 000000000..107acd32c
--- /dev/null
+++ b/src-tauri/gen/android/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/src-tauri/gen/android/settings.gradle b/src-tauri/gen/android/settings.gradle
new file mode 100644
index 000000000..39391166f
--- /dev/null
+++ b/src-tauri/gen/android/settings.gradle
@@ -0,0 +1,3 @@
+include ':app'
+
+apply from: 'tauri.settings.gradle'
diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png
new file mode 100644
index 000000000..474e29a1e
Binary files /dev/null and b/src-tauri/icons/128x128.png differ
diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png
new file mode 100644
index 000000000..314476266
Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ
diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png
new file mode 100644
index 000000000..f4a91f836
Binary files /dev/null and b/src-tauri/icons/32x32.png differ
diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png
new file mode 100644
index 000000000..abdfaf20d
Binary files /dev/null and b/src-tauri/icons/64x64.png differ
diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png
new file mode 100644
index 000000000..2861b265c
Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ
diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png
new file mode 100644
index 000000000..c8cbf4216
Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ
diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png
new file mode 100644
index 000000000..0f66d8bd1
Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ
diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png
new file mode 100644
index 000000000..7ba98ce9a
Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ
diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png
new file mode 100644
index 000000000..07b3f5f9d
Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ
diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png
new file mode 100644
index 000000000..c3eed74b5
Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ
diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png
new file mode 100644
index 000000000..039dfd2af
Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ
diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png
new file mode 100644
index 000000000..f16a689df
Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ
diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png
new file mode 100644
index 000000000..e0163af57
Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ
diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png
new file mode 100644
index 000000000..9ae41f127
Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ
diff --git a/src-tauri/icons/android/drawable/ic_notification.xml b/src-tauri/icons/android/drawable/ic_notification.xml
new file mode 100644
index 000000000..696a13d0c
--- /dev/null
+++ b/src-tauri/icons/android/drawable/ic_notification.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/src-tauri/icons/android/drawable/notification_icon.xml b/src-tauri/icons/android/drawable/notification_icon.xml
new file mode 100644
index 000000000..afa6af354
--- /dev/null
+++ b/src-tauri/icons/android/drawable/notification_icon.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..2ffbf24b6
--- /dev/null
+++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..c39890183
Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..dc2a6d3a8
Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..456494698
Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_notification.png b/src-tauri/icons/android/mipmap-hdpi/ic_notification.png
new file mode 100644
index 000000000..e2d71f7d2
Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_notification.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..12d2fc6d4
Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..c5348bee7
Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..e1e69bc26
Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_notification.png b/src-tauri/icons/android/mipmap-mdpi/ic_notification.png
new file mode 100644
index 000000000..255084025
Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_notification.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..eb83edbf0
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..51ec437c7
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..0abd89e7c
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_notification.png b/src-tauri/icons/android/mipmap-xhdpi/ic_notification.png
new file mode 100644
index 000000000..bea0f2d25
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_notification.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..54b6f0e3f
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..61c130179
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..4df39fe7f
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_notification.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_notification.png
new file mode 100644
index 000000000..8a3e2a860
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_notification.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..71533fb0d
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..46b30f412
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..204c18c53
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_notification.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_notification.png
new file mode 100644
index 000000000..334958c5d
Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_notification.png differ
diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml
new file mode 100644
index 000000000..ea9c223a6
--- /dev/null
+++ b/src-tauri/icons/android/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #fff
+
\ No newline at end of file
diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns
new file mode 100644
index 000000000..e15e8b580
Binary files /dev/null and b/src-tauri/icons/icon.icns differ
diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico
new file mode 100644
index 000000000..fabc3e552
Binary files /dev/null and b/src-tauri/icons/icon.ico differ
diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png
new file mode 100644
index 000000000..8103a9145
Binary files /dev/null and b/src-tauri/icons/icon.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png
new file mode 100644
index 000000000..465b5e67e
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
new file mode 100644
index 000000000..46648af08
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png
new file mode 100644
index 000000000..46648af08
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png
new file mode 100644
index 000000000..d517fc389
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png
new file mode 100644
index 000000000..58983c65a
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
new file mode 100644
index 000000000..d74dd2730
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png
new file mode 100644
index 000000000..d74dd2730
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png
new file mode 100644
index 000000000..01a606e32
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png
new file mode 100644
index 000000000..46648af08
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
new file mode 100644
index 000000000..089502002
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png
new file mode 100644
index 000000000..089502002
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png
new file mode 100644
index 000000000..d759b0bea
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png
new file mode 100644
index 000000000..bfce1e7f8
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png
new file mode 100644
index 000000000..d759b0bea
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png
new file mode 100644
index 000000000..d020c68b0
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png
new file mode 100644
index 000000000..94f38d735
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png
new file mode 100644
index 000000000..f5a7391ff
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
new file mode 100644
index 000000000..42194746b
Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ
diff --git a/src-tauri/src/desktop_tray.rs b/src-tauri/src/desktop_tray.rs
new file mode 100644
index 000000000..71c1e122d
--- /dev/null
+++ b/src-tauri/src/desktop_tray.rs
@@ -0,0 +1,186 @@
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use tauri::{
+ menu::{Menu, MenuItem, MenuEvent},
+ tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent},
+ AppHandle, Manager, RunEvent, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
+};
+
+pub const MAIN_WINDOW_LABEL: &str = "main";
+const TRAY_MENU_SHOW_ID: &str = "tray_show";
+const TRAY_MENU_QUIT_ID: &str = "tray_quit";
+
+pub struct DesktopSettingsState {
+ close_to_tray: AtomicBool,
+}
+
+impl DesktopSettingsState {
+ pub fn new(close_to_tray: bool) -> Self {
+ Self {
+ close_to_tray: AtomicBool::new(close_to_tray),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum TrayDoubleClickAction {
+ Ignore,
+ ShowOrCreateMainWindow,
+ CloseMainWindow,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ExitRequestAction {
+ AllowExit,
+ CloseWindowsToTray,
+}
+
+fn tray_double_click_action(window_exists: bool, event: &TrayIconEvent) -> TrayDoubleClickAction {
+ match event {
+ TrayIconEvent::DoubleClick {
+ button: MouseButton::Left,
+ ..
+ } => {
+ if window_exists {
+ TrayDoubleClickAction::CloseMainWindow
+ } else {
+ TrayDoubleClickAction::ShowOrCreateMainWindow
+ }
+ }
+ _ => TrayDoubleClickAction::Ignore,
+ }
+}
+
+fn exit_request_action(close_to_tray: bool, code: Option) -> ExitRequestAction {
+ match (close_to_tray, code) {
+ (_, Some(_)) => ExitRequestAction::AllowExit,
+ (true, None) => ExitRequestAction::CloseWindowsToTray,
+ (false, None) => ExitRequestAction::AllowExit,
+ }
+}
+
+#[tauri::command]
+#[specta::specta]
+pub fn set_close_to_tray_enabled(app: AppHandle, enabled: bool) {
+ app.state::()
+ .close_to_tray
+ .store(enabled, Ordering::Relaxed);
+}
+
+fn close_to_tray_enabled(app: &AppHandle) -> bool {
+ app.state::()
+ .close_to_tray
+ .load(Ordering::Relaxed)
+}
+
+fn main_window_exists(app: &AppHandle) -> bool {
+ app.get_webview_window(MAIN_WINDOW_LABEL).is_some()
+}
+
+fn close_main_window(app: &AppHandle) {
+ if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
+ let _ = window.close();
+ }
+}
+
+fn close_all_windows(app: &AppHandle) {
+ for (_label, window) in app.webview_windows() {
+ let _ = window.close();
+ }
+}
+
+fn handle_exit_request(
+ app: &AppHandle,
+ code: Option,
+ api: &tauri::ExitRequestApi,
+) {
+ if exit_request_action(close_to_tray_enabled(app), code)
+ == ExitRequestAction::CloseWindowsToTray
+ {
+ api.prevent_exit();
+ close_all_windows(app);
+ }
+}
+
+fn handle_tray_double_click(app: &AppHandle, event: &TrayIconEvent) {
+ match tray_double_click_action(main_window_exists(app), event) {
+ TrayDoubleClickAction::ShowOrCreateMainWindow => {
+ let _ = show_or_create_main_window(app);
+ }
+ TrayDoubleClickAction::CloseMainWindow => {
+ close_main_window(app);
+ }
+ TrayDoubleClickAction::Ignore => {}
+ }
+}
+
+pub fn configure_main_window(_window: &WebviewWindow) {
+ #[cfg(target_os = "windows")]
+ {
+ let _ = _window.set_decorations(false);
+ }
+}
+
+pub fn handle_run_event(app: &AppHandle, event: RunEvent) {
+ if let RunEvent::ExitRequested { code, api, .. } = event {
+ handle_exit_request(app, code, &api);
+ }
+}
+
+pub fn show_or_create_main_window(app: &AppHandle) -> tauri::Result<()> {
+ if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
+ let _ = window.unminimize();
+ let _ = window.show();
+ let _ = window.set_focus();
+ return Ok(());
+ }
+
+ if let Some(window_config) = app
+ .config()
+ .app
+ .windows
+ .iter()
+ .find(|window| window.label == MAIN_WINDOW_LABEL)
+ .or_else(|| app.config().app.windows.first())
+ {
+ let window = WebviewWindowBuilder::from_config(app, window_config)?.build()?;
+ configure_main_window(&window);
+ return Ok(());
+ }
+
+ let window = WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, WebviewUrl::default())
+ .build()?;
+ configure_main_window(&window);
+
+ Ok(())
+}
+
+pub fn create_system_tray(app: &AppHandle) -> tauri::Result<()> {
+ let show_item = MenuItem::with_id(app, TRAY_MENU_SHOW_ID, "Show", true, None::<&str>)?;
+ let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT_ID, "Quit", true, None::<&str>)?;
+ let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?;
+
+ let mut tray_builder = TrayIconBuilder::new()
+ .menu(&tray_menu)
+ .show_menu_on_left_click(false)
+ .on_menu_event(|app, event: MenuEvent| match event.id().as_ref() {
+ TRAY_MENU_SHOW_ID => {
+ let _ = show_or_create_main_window(app);
+ }
+ TRAY_MENU_QUIT_ID => {
+ app.exit(0);
+ }
+ _ => {}
+ })
+ .on_tray_icon_event(|tray: &TrayIcon, event| {
+ let app = tray.app_handle();
+ handle_tray_double_click(app, &event);
+ });
+
+ if let Some(icon) = app.default_window_icon() {
+ tray_builder = tray_builder.icon(icon.clone());
+ }
+
+ tray_builder.build(app)?;
+ Ok(())
+}
\ No newline at end of file
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
new file mode 100644
index 000000000..93ee067c0
--- /dev/null
+++ b/src-tauri/src/lib.rs
@@ -0,0 +1,103 @@
+mod windows;
+
+#[cfg(desktop)]
+mod desktop_tray;
+
+#[cfg(debug_assertions)]
+use specta_typescript::Typescript;
+
+#[cfg(desktop)]
+use tauri::Manager;
+
+use sable_macros::collect_commands;
+use tauri_specta::Builder;
+
+#[cfg(desktop)]
+use tauri_plugin_window_state::StateFlags;
+
+#[cfg_attr(mobile, tauri::mobile_entry_point)]
+pub fn run() {
+ let specta_builder = Builder::::new().commands(collect_commands![
+ #[cfg(desktop)]
+ desktop_tray::set_close_to_tray_enabled,
+ windows::snap_overlay::show_snap_overlay,
+ windows::snap_overlay::hide_snap_overlay,
+ windows::window_tracking::start_window_tracking_with_target,
+ windows::window_tracking::stop_window_tracking,
+ windows::window_tracking::is_window_tracking_active,
+ ]);
+
+ #[cfg(debug_assertions)]
+ specta_builder
+ .export(
+ Typescript::default().header("/* eslint-disable */"),
+ "../src/app/generated/tauri.ts",
+ )
+ .unwrap_or_else(|e| eprintln!("Warning: Failed to export tauri-specta bindings: {e}"));
+
+ let invoke_handler = specta_builder.invoke_handler();
+
+ let builder = tauri::Builder::default();
+
+ #[cfg(desktop)]
+ let builder = builder.plugin(
+ tauri_plugin_window_state::Builder::default()
+ .with_state_flags(StateFlags::all() & !StateFlags::VISIBLE & !StateFlags::DECORATIONS)
+ .build(),
+ );
+
+ #[cfg(desktop)]
+ let builder = builder.manage(desktop_tray::DesktopSettingsState::new(true));
+
+ let builder = builder.manage(std::sync::Arc::new(
+ windows::window_tracking::TrackingState::new(),
+ ));
+
+ #[cfg(desktop)]
+ let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
+ let _ = desktop_tray::show_or_create_main_window(app);
+ }));
+
+ #[cfg(target_os = "android")]
+ let builder = builder.plugin(tauri_plugin_notifications::init());
+
+ builder
+ .plugin(tauri_plugin_opener::init())
+ .plugin(tauri_plugin_deep_link::init())
+ .plugin(tauri_plugin_os::init())
+ .plugin(tauri_plugin_splashscreen::init())
+ .setup(|app| {
+ #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
+ {
+ use tauri_plugin_deep_link::DeepLinkExt;
+ app.deep_link().register_all()?;
+ }
+
+ #[cfg(desktop)]
+ {
+ if let Some(window) = app.get_webview_window(desktop_tray::MAIN_WINDOW_LABEL) {
+ desktop_tray::configure_main_window(&window);
+ }
+ desktop_tray::create_system_tray(app.handle())?;
+ }
+
+ if cfg!(debug_assertions) {
+ app.handle().plugin(
+ tauri_plugin_log::Builder::default()
+ .level(log::LevelFilter::Info)
+ .build(),
+ )?;
+ }
+ Ok(())
+ })
+ .invoke_handler(invoke_handler)
+ .build(tauri::generate_context!())
+ .expect("error while building tauri application")
+ .run(|app, event| {
+ #[cfg(desktop)]
+ desktop_tray::handle_run_event(app, event);
+
+ #[cfg(not(desktop))]
+ let _ = (app, event);
+ });
+}
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
new file mode 100644
index 000000000..69c3a72ec
--- /dev/null
+++ b/src-tauri/src/main.rs
@@ -0,0 +1,6 @@
+// Prevents additional console window on Windows in release, DO NOT REMOVE!!
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+fn main() {
+ app_lib::run();
+}
diff --git a/src-tauri/src/windows/mod.rs b/src-tauri/src/windows/mod.rs
new file mode 100644
index 000000000..b10464332
--- /dev/null
+++ b/src-tauri/src/windows/mod.rs
@@ -0,0 +1,13 @@
+#[cfg(windows)]
+#[path = "snap_overlay.rs"]
+pub mod snap_overlay;
+#[cfg(not(windows))]
+#[path = "snap_overlay_stub.rs"]
+pub mod snap_overlay;
+
+#[cfg(windows)]
+#[path = "window_tracking.rs"]
+pub mod window_tracking;
+#[cfg(not(windows))]
+#[path = "window_tracking_stub.rs"]
+pub mod window_tracking;
diff --git a/src-tauri/src/windows/snap_overlay.rs b/src-tauri/src/windows/snap_overlay.rs
new file mode 100644
index 000000000..513d17c4a
--- /dev/null
+++ b/src-tauri/src/windows/snap_overlay.rs
@@ -0,0 +1,26 @@
+use enigo::{
+ Direction::{Click, Press, Release},
+ Enigo, Key, Keyboard, Settings,
+};
+
+#[tauri::command]
+#[specta::specta]
+pub async fn show_snap_overlay() {
+ let mut enigo = Enigo::new(&Settings::default()).unwrap();
+
+ enigo.key(Key::Meta, Press).unwrap();
+ enigo.key(Key::Unicode('z'), Click).unwrap();
+ enigo.key(Key::Meta, Release).unwrap();
+
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ enigo.key(Key::Alt, Click).unwrap();
+}
+
+
+#[tauri::command]
+#[specta::specta]
+pub async fn hide_snap_overlay() {
+ let mut enigo = Enigo::new(&Settings::default()).unwrap();
+ enigo.key(Key::Escape, Click).unwrap();
+}
diff --git a/src-tauri/src/windows/snap_overlay_stub.rs b/src-tauri/src/windows/snap_overlay_stub.rs
new file mode 100644
index 000000000..4998f8248
--- /dev/null
+++ b/src-tauri/src/windows/snap_overlay_stub.rs
@@ -0,0 +1,7 @@
+#[tauri::command]
+#[specta::specta]
+pub async fn show_snap_overlay() {}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn hide_snap_overlay() {}
diff --git a/src-tauri/src/windows/window_tracking.rs b/src-tauri/src/windows/window_tracking.rs
new file mode 100644
index 000000000..ddb41fc82
--- /dev/null
+++ b/src-tauri/src/windows/window_tracking.rs
@@ -0,0 +1,298 @@
+use std::{
+ ffi::OsString,
+ os::windows::ffi::OsStringExt,
+ sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+ },
+ time::{Duration, Instant},
+};
+
+use serde::{Deserialize, Serialize};
+use specta::Type;
+use tauri::{AppHandle, Emitter, State};
+use windows::Win32::{
+ Foundation::{CloseHandle, HWND, POINT},
+ System::{
+ ProcessStatus::GetModuleBaseNameW,
+ Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ},
+ },
+ UI::WindowsAndMessaging::{
+ GetClassNameW, GetCursorPos, GetWindowThreadProcessId, WindowFromPoint,
+ },
+};
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Type)]
+pub struct WindowInfo {
+ pub mouse_x: i32,
+ pub mouse_y: i32,
+ pub window_class: Option,
+ pub exe_name: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Type)]
+pub struct WindowTarget {
+ pub window_class: Option,
+ pub exe_name: Option,
+}
+
+impl WindowTarget {
+ pub fn new(window_class: Option, exe_name: Option) -> Self {
+ Self {
+ window_class,
+ exe_name,
+ }
+ }
+
+ pub fn matches(&self, window_info: &WindowInfo) -> bool {
+ let class_matches = match &self.window_class {
+ Some(target_class) => window_info
+ .window_class
+ .as_ref()
+ .map(|class| class.eq_ignore_ascii_case(target_class))
+ .unwrap_or(false),
+ None => true,
+ };
+
+ let exe_matches = match &self.exe_name {
+ Some(target_exe) => window_info
+ .exe_name
+ .as_ref()
+ .map(|exe| exe.eq_ignore_ascii_case(target_exe))
+ .unwrap_or(false),
+ None => true,
+ };
+
+ class_matches && exe_matches
+ }
+}
+
+impl WindowInfo {
+ pub fn matches_target(&self, target: &WindowTarget) -> bool {
+ target.matches(self)
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Type)]
+pub enum EventType {
+ Started,
+ TargetLost,
+ Timeout,
+ Stopped,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Type)]
+pub struct TrackingEvent {
+ pub event_type: EventType,
+ pub window_info: Option,
+ pub target: WindowTarget,
+ pub message: String,
+}
+
+pub struct TrackingState {
+ active: AtomicBool,
+}
+
+impl TrackingState {
+ pub fn new() -> Self {
+ Self {
+ active: AtomicBool::new(false),
+ }
+ }
+
+ pub fn is_active(&self) -> bool {
+ self.active.load(Ordering::Relaxed)
+ }
+
+ pub fn set_active(&self, active: bool) {
+ self.active.store(active, Ordering::Relaxed);
+ }
+}
+
+impl Default for TrackingState {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+fn get_window_class_name(hwnd: HWND) -> Result> {
+ let mut buffer = [0u16; 256];
+ let len = unsafe { GetClassNameW(hwnd, &mut buffer) };
+ if len == 0 {
+ return Err("Failed to get class name".into());
+ }
+ Ok(OsString::from_wide(&buffer[..len as usize])
+ .to_string_lossy()
+ .into_owned())
+}
+
+fn get_exe_name_from_window(hwnd: HWND) -> Result> {
+ let mut process_id = 0;
+ unsafe {
+ GetWindowThreadProcessId(hwnd, Some(&mut process_id));
+ }
+ if process_id == 0 {
+ return Err("Failed to get process id".into());
+ }
+
+ let process_handle = unsafe {
+ OpenProcess(
+ PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
+ false,
+ process_id,
+ )
+ }?;
+
+ let mut buffer = [0u16; 260];
+ let len = unsafe { GetModuleBaseNameW(process_handle, None, &mut buffer) };
+ let _ = unsafe { CloseHandle(process_handle) };
+
+ if len == 0 {
+ return Err("Failed to get process name".into());
+ }
+
+ Ok(OsString::from_wide(&buffer[..len as usize])
+ .to_string_lossy()
+ .into_owned())
+}
+
+fn get_window_info() -> Result> {
+ let mut point = POINT { x: 0, y: 0 };
+ unsafe { GetCursorPos(&raw mut point) }?;
+
+ let hwnd = unsafe { WindowFromPoint(point) };
+
+ let window_class = if !hwnd.0.is_null() {
+ get_window_class_name(hwnd).ok()
+ } else {
+ None
+ };
+
+ let exe_name = if !hwnd.0.is_null() {
+ get_exe_name_from_window(hwnd).ok()
+ } else {
+ None
+ };
+
+ Ok(WindowInfo {
+ mouse_x: point.x,
+ mouse_y: point.y,
+ window_class,
+ exe_name,
+ })
+}
+
+async fn track_window_hover_with_target(
+ app_handle: AppHandle,
+ target: WindowTarget,
+ tracking_state: Arc,
+) {
+ let start_time = Instant::now();
+ let timeout_duration = Duration::from_millis(200);
+ let check_interval = Duration::from_millis(100);
+
+ let mut was_on_target = false;
+
+ while tracking_state.is_active() {
+ if start_time.elapsed() >= timeout_duration && !was_on_target {
+ let event = TrackingEvent {
+ event_type: EventType::Timeout,
+ window_info: None,
+ target: target.clone(),
+ message: "Tracking timed out before reaching target".to_owned(),
+ };
+
+ let _ = app_handle.emit("window-tracking", &event);
+ tracking_state.set_active(false);
+ break;
+ }
+
+ if let Ok(window_info) = get_window_info() {
+ let is_on_target = window_info.matches_target(&target);
+
+ if was_on_target && !is_on_target {
+ let event = TrackingEvent {
+ event_type: EventType::TargetLost,
+ window_info: Some(window_info),
+ target: target.clone(),
+ message: "Pointer moved away from snap popup".to_owned(),
+ };
+ let _ = app_handle.emit("window-tracking", &event);
+ tracking_state.set_active(false);
+ break;
+ }
+
+ was_on_target = is_on_target;
+ }
+
+ tokio::time::sleep(check_interval).await;
+ }
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn start_window_tracking_with_target(
+ app_handle: AppHandle,
+ target: WindowTarget,
+ tracking_state: State<'_, Arc>,
+) -> Result<(), String> {
+ if tracking_state.is_active() {
+ return Ok(());
+ }
+
+ tracking_state.set_active(true);
+
+ let event = TrackingEvent {
+ event_type: EventType::Started,
+ window_info: None,
+ target: target.clone(),
+ message: "Window tracking started".to_owned(),
+ };
+
+ app_handle
+ .emit("window-tracking", &event)
+ .map_err(|error| format!("Failed to emit start event: {error}"))?;
+
+ let app_handle_clone = app_handle.clone();
+ let tracking_state_clone = tracking_state.inner().clone();
+
+ tauri::async_runtime::spawn(async move {
+ track_window_hover_with_target(app_handle_clone, target, tracking_state_clone).await;
+ });
+
+ Ok(())
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn stop_window_tracking(
+ app_handle: AppHandle,
+ tracking_state: State<'_, Arc>,
+) -> Result<(), String> {
+ if !tracking_state.is_active() {
+ return Ok(());
+ }
+
+ tracking_state.set_active(false);
+
+ let event = TrackingEvent {
+ event_type: EventType::Stopped,
+ window_info: None,
+ target: WindowTarget::new(None, None),
+ message: "Window tracking stopped".to_owned(),
+ };
+
+ app_handle
+ .emit("window-tracking", &event)
+ .map_err(|error| format!("Failed to emit stop event: {error}"))?;
+
+ Ok(())
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn is_window_tracking_active(
+ tracking_state: State<'_, Arc>,
+) -> Result {
+ Ok(tracking_state.is_active())
+}
diff --git a/src-tauri/src/windows/window_tracking_stub.rs b/src-tauri/src/windows/window_tracking_stub.rs
new file mode 100644
index 000000000..9a910b890
--- /dev/null
+++ b/src-tauri/src/windows/window_tracking_stub.rs
@@ -0,0 +1,65 @@
+use std::sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+};
+
+use serde::{Deserialize, Serialize};
+use specta::Type;
+use tauri::{AppHandle, State};
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Type)]
+pub struct WindowTarget {
+ pub window_class: Option,
+ pub exe_name: Option,
+}
+
+pub struct TrackingState {
+ active: AtomicBool,
+}
+
+impl TrackingState {
+ pub fn new() -> Self {
+ Self {
+ active: AtomicBool::new(false),
+ }
+ }
+
+ pub fn is_active(&self) -> bool {
+ self.active.load(Ordering::Relaxed)
+ }
+}
+
+impl Default for TrackingState {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn start_window_tracking_with_target(
+ app_handle: AppHandle,
+ target: WindowTarget,
+ tracking_state: State<'_, Arc>,
+) -> Result<(), String> {
+ let _ = (app_handle, target, tracking_state);
+ Ok(())
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn stop_window_tracking(
+ app_handle: AppHandle,
+ tracking_state: State<'_, Arc>,
+) -> Result<(), String> {
+ let _ = (app_handle, tracking_state);
+ Ok(())
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn is_window_tracking_active(
+ tracking_state: State<'_, Arc>,
+) -> Result {
+ Ok(tracking_state.is_active())
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
new file mode 100644
index 000000000..529a78863
--- /dev/null
+++ b/src-tauri/tauri.conf.json
@@ -0,0 +1,51 @@
+{
+ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
+ "productName": "sable",
+ "version": "0.1.0",
+ "identifier": "moe.sable.app",
+ "build": {
+ "frontendDist": "../dist",
+ "devUrl": "http://localhost:8080",
+ "beforeDevCommand": "pnpm dev",
+ "beforeBuildCommand": "pnpm build"
+ },
+ "app": {
+ "windows": [
+ {
+ "title": "sable",
+ "width": 1280,
+ "height": 720,
+ "visible": false,
+ "resizable": true,
+ "fullscreen": false
+ }
+ ],
+ "security": {
+ "csp": "default-src 'self' blob: data: filesystem: ws: wss: http: https: tauri:; script-src 'self' 'unsafe-eval' 'unsafe-inline' blob: data: filesystem: ws: wss: http: https: tauri:; img-src 'self' data: blob: filesystem: http: https:; connect-src 'self' ipc: ws: wss: http: https: http://ipc.localhost"
+ }
+ },
+ "bundle": {
+ "active": true,
+ "targets": "all",
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.icns",
+ "icons/icon.ico"
+ ]
+ },
+ "plugins": {
+ "deep-link": {
+ "desktop": {
+ "schemes": ["sable"]
+ },
+ "mobile": [
+ {
+ "scheme": ["sable"],
+ "appLink": false
+ }
+ ]
+ }
+ }
+}
diff --git a/src/app/components/AuthenticatedImg.tsx b/src/app/components/AuthenticatedImg.tsx
new file mode 100644
index 000000000..59b996c40
--- /dev/null
+++ b/src/app/components/AuthenticatedImg.tsx
@@ -0,0 +1,11 @@
+/* eslint-disable jsx-a11y/alt-text */
+import { useMediaSrc } from '$hooks/useMediaSrc';
+
+type AuthenticatedImgProps = React.ImgHTMLAttributes & {
+ src: string;
+};
+
+export function AuthenticatedImg({ src, ...props }: AuthenticatedImgProps) {
+ const resolvedSrc = useMediaSrc(src);
+ return ;
+}
diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx
index 2eba0d011..b7ef2cb07 100644
--- a/src/app/components/SwipeableChatWrapper.tsx
+++ b/src/app/components/SwipeableChatWrapper.tsx
@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
-import { motion, useMotionValue, useSpring } from 'framer-motion';
+import { motion, useMotionValue, useSpring } from 'motion/react';
import { useDrag } from '@use-gesture/react';
import { useAtomValue } from 'jotai';
import { settingsAtom, RightSwipeAction } from '$state/settings';
diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx
index 4b219355c..6940dbe39 100644
--- a/src/app/components/SwipeableMessageWrapper.tsx
+++ b/src/app/components/SwipeableMessageWrapper.tsx
@@ -1,4 +1,4 @@
-import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion';
+import { useMotionValue, useSpring, useTransform, motion } from 'motion/react';
import { useDrag } from '@use-gesture/react';
import { ReactNode, useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx
index 15521bb12..ebd0a7ca9 100644
--- a/src/app/components/SwipeableOverlayWrapper.tsx
+++ b/src/app/components/SwipeableOverlayWrapper.tsx
@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
-import { motion, useMotionValue, useSpring } from 'framer-motion';
+import { motion, useMotionValue, useSpring } from 'motion/react';
import { useDrag } from '@use-gesture/react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '$state/settings';
diff --git a/src/app/components/SyncConnectionStatus.tsx b/src/app/components/SyncConnectionStatus.tsx
new file mode 100644
index 000000000..387b1a738
--- /dev/null
+++ b/src/app/components/SyncConnectionStatus.tsx
@@ -0,0 +1,162 @@
+import classNames from 'classnames';
+import { Box, config, Line, Text } from 'folds';
+import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
+import { SyncState } from '$types/matrix-sdk';
+import { type TitlebarStatusView } from '$state/titlebarStatus';
+import { ContainerColor } from '$styles/ContainerColor.css';
+
+const TITLEBAR_EASE_OUT: [number, number, number, number] = [0.32, 0.72, 0, 1];
+const TITLEBAR_EASE_OUT_SOFT: [number, number, number, number] = [0.24, 0.72, 0.08, 1];
+
+type SyncConnectionStatusProps = {
+ status: TitlebarStatusView | null;
+};
+
+export function getSyncConnectionStatusView(
+ current: SyncState | null,
+ previous: SyncState | null | undefined
+): TitlebarStatusView | null {
+ if (
+ (current === SyncState.Prepared ||
+ current === SyncState.Syncing ||
+ current === SyncState.Catchup) &&
+ previous !== SyncState.Syncing
+ ) {
+ return { text: 'Connecting...', variant: 'Success' };
+ }
+
+ if (current === SyncState.Reconnecting) {
+ return { text: 'Connection Lost! Reconnecting...', variant: 'Warning' };
+ }
+
+ if (current === SyncState.Error) {
+ return { text: 'Connection Lost!', variant: 'Critical' };
+ }
+
+ return null;
+}
+
+export function SyncConnectionStatusBanner({ status }: SyncConnectionStatusProps) {
+ if (!status) return null;
+
+ return (
+
+
+ {status.text}
+
+
+
+ );
+}
+
+export function SyncConnectionStatusTitlebar({ status }: SyncConnectionStatusProps) {
+ const shouldReduceMotion = useReducedMotion();
+ const pillVariants = shouldReduceMotion
+ ? {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.18, ease: TITLEBAR_EASE_OUT },
+ },
+ exit: {
+ opacity: 0,
+ transition: {
+ when: 'afterChildren' as const,
+ opacity: { duration: 0.1, delay: 0.08, ease: TITLEBAR_EASE_OUT_SOFT },
+ },
+ },
+ }
+ : {
+ hidden: {
+ y: -2,
+ scaleX: 0.98,
+ scaleY: 0.96,
+ opacity: 0,
+ clipPath: 'inset(0 50% 0 50% round 999px)',
+ },
+ visible: {
+ y: 0,
+ scaleX: 1,
+ scaleY: 1,
+ opacity: 1,
+ clipPath: 'inset(0 0% 0 0% round 999px)',
+ transition: { duration: 0.2, ease: TITLEBAR_EASE_OUT },
+ },
+ exit: {
+ y: -2,
+ scaleX: 0.98,
+ scaleY: 0.96,
+ opacity: 0,
+ clipPath: 'inset(0 50% 0 50% round 999px)',
+ transition: {
+ when: 'afterChildren' as const,
+ y: { duration: 0.2, ease: TITLEBAR_EASE_OUT },
+ scaleX: { duration: 0.2, ease: TITLEBAR_EASE_OUT },
+ scaleY: { duration: 0.2, ease: TITLEBAR_EASE_OUT },
+ clipPath: { duration: 0.2, ease: TITLEBAR_EASE_OUT },
+ opacity: { duration: 0.1, delay: 0.08, ease: TITLEBAR_EASE_OUT_SOFT },
+ },
+ },
+ };
+
+ const textVariants = shouldReduceMotion
+ ? {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.12, ease: TITLEBAR_EASE_OUT },
+ },
+ exit: {
+ opacity: 0,
+ transition: { duration: 0.1, ease: TITLEBAR_EASE_OUT_SOFT },
+ },
+ }
+ : {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.12, delay: 0.04, ease: TITLEBAR_EASE_OUT_SOFT },
+ },
+ exit: {
+ opacity: 0,
+ transition: { duration: 0.1, ease: TITLEBAR_EASE_OUT_SOFT },
+ },
+ };
+
+ return (
+
+ {status && (
+
+
+ {status.text}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx
index 7e3500810..537d3af98 100644
--- a/src/app/components/editor/Elements.tsx
+++ b/src/app/components/editor/Elements.tsx
@@ -12,6 +12,7 @@ import * as css from '$styles/CustomHtml.css';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { AuthenticatedImg } from '$components/AuthenticatedImg';
import { nicknamesAtom } from '$state/nicknames';
import { BlockType } from './types';
import { getBeginCommand } from './utils';
@@ -96,7 +97,7 @@ function RenderEmoticonElement({
contentEditable={false}
>
{element.key.startsWith('mxc://') ? (
- {
@@ -63,7 +64,7 @@ export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiIte
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
-
-
{key.startsWith('mxc://') ? (
-
{url ? (
-
+
) : (
{nameInitials(name ?? 'Unknown')}
diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx
index e08430f1c..213fd25da 100644
--- a/src/app/components/image-viewer/ImageViewer.tsx
+++ b/src/app/components/image-viewer/ImageViewer.tsx
@@ -4,6 +4,7 @@ import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import { useImageGestures } from '$hooks/useImageGestures';
import { downloadMedia } from '$utils/matrix';
+import { useMediaDownloadToken } from '$hooks/useMediaSrc';
import * as css from './ImageViewer.css';
export type ImageViewerProps = {
@@ -14,13 +15,14 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
+ const mediaToken = useMediaDownloadToken();
const { zoom, pan, cursor, onPointerDown, setZoom, zoomIn, zoomOut } = useImageGestures(
true,
0.2
);
const handleDownload = async () => {
- const fileContent = await downloadMedia(src);
+ const fileContent = await downloadMedia(src, mediaToken);
FileSaver.saveAs(fileContent, alt);
};
diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx
index 36d8adeb1..e776636e1 100644
--- a/src/app/components/message/FileHeader.tsx
+++ b/src/app/components/message/FileHeader.tsx
@@ -7,6 +7,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix';
+import { useMediaDownloadToken } from '$hooks/useMediaSrc';
const badgeStyles = { maxWidth: toRem(100) };
@@ -19,19 +20,24 @@ type FileDownloadButtonProps = {
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
+ const mediaToken = useMediaDownloadToken();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
- : await downloadMedia(mediaUrl);
+ ? await downloadEncryptedMedia(
+ mediaUrl,
+ (encBuf) => decryptFile(encBuf, mimeType, encInfo),
+ mediaToken
+ )
+ : await downloadMedia(mediaUrl, mediaToken);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);
return fileURL;
- }, [mx, url, useAuthentication, mimeType, encInfo, filename])
+ }, [mx, url, useAuthentication, mimeType, encInfo, filename, mediaToken])
);
const downloading = downloadState.status === AsyncStatus.Loading;
diff --git a/src/app/components/message/Reaction.tsx b/src/app/components/message/Reaction.tsx
index 155de11f4..d66914b2b 100644
--- a/src/app/components/message/Reaction.tsx
+++ b/src/app/components/message/Reaction.tsx
@@ -7,6 +7,7 @@ import { getMemberDisplayName } from '$utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
import { useAtomValue } from 'jotai';
import { nicknamesAtom } from '$state/nicknames';
+import { AuthenticatedImg } from '$components/AuthenticatedImg';
import * as css from './Reaction.css';
export const Reaction = as<
@@ -43,7 +44,7 @@ export const Reaction = as<
);
return (
- {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
- : await downloadMedia(mediaUrl);
+ ? await downloadEncryptedMedia(
+ mediaUrl,
+ (encBuf) => decryptFile(encBuf, mimeType, encInfo),
+ mediaToken
+ )
+ : await downloadMedia(mediaUrl, mediaToken);
return URL.createObjectURL(fileContent);
- }, [mx, url, useAuthentication, mimeType, encInfo])
+ }, [mx, url, useAuthentication, mimeType, encInfo, mediaToken])
);
const audioRef = useRef(null);
diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx
index af802d46b..416ff8ae4 100644
--- a/src/app/components/message/content/FileContent.tsx
+++ b/src/app/components/message/content/FileContent.tsx
@@ -30,6 +30,7 @@ import {
import { stopPropagation } from '$utils/keyboard';
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useMediaDownloadToken } from '$hooks/useMediaSrc';
import { ModalWide } from '$styles/Modal.css';
const renderErrorButton = (retry: () => void, text: string) => (
@@ -77,6 +78,7 @@ type ReadTextFileProps = {
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
+ const mediaToken = useMediaDownloadToken();
const [textViewer, setTextViewer] = useState(false);
const [textState, loadText] = useAsyncCallback(
@@ -84,13 +86,17 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
- : await downloadMedia(mediaUrl);
+ ? await downloadEncryptedMedia(
+ mediaUrl,
+ (encBuf) => decryptFile(encBuf, mimeType, encInfo),
+ mediaToken
+ )
+ : await downloadMedia(mediaUrl, mediaToken);
const text = fileContent.text();
setTextViewer(true);
return text;
- }, [mx, useAuthentication, mimeType, encInfo, url])
+ }, [mx, useAuthentication, mimeType, encInfo, url, mediaToken])
);
return (
@@ -168,6 +174,7 @@ export type ReadPdfFileProps = {
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
+ const mediaToken = useMediaDownloadToken();
const [pdfViewer, setPdfViewer] = useState(false);
const [pdfState, loadPdf] = useAsyncCallback(
@@ -175,11 +182,15 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
- : await downloadMedia(mediaUrl);
+ ? await downloadEncryptedMedia(
+ mediaUrl,
+ (encBuf) => decryptFile(encBuf, mimeType, encInfo),
+ mediaToken
+ )
+ : await downloadMedia(mediaUrl, mediaToken);
setPdfViewer(true);
return URL.createObjectURL(fileContent);
- }, [mx, url, useAuthentication, mimeType, encInfo])
+ }, [mx, url, useAuthentication, mimeType, encInfo, mediaToken])
);
return (
@@ -247,19 +258,24 @@ export type DownloadFileProps = {
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
+ const mediaToken = useMediaDownloadToken();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
- : await downloadMedia(mediaUrl);
+ ? await downloadEncryptedMedia(
+ mediaUrl,
+ (encBuf) => decryptFile(encBuf, mimeType, encInfo),
+ mediaToken
+ )
+ : await downloadMedia(mediaUrl, mediaToken);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body);
return fileURL;
- }, [mx, url, useAuthentication, mimeType, encInfo, body])
+ }, [mx, url, useAuthentication, mimeType, encInfo, body, mediaToken])
);
return downloadState.status === AsyncStatus.Error ? (
diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx
index 017569662..f3ac87299 100644
--- a/src/app/components/message/content/ImageContent.tsx
+++ b/src/app/components/message/content/ImageContent.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useCallback, useEffect, useState } from 'react';
+import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import {
Badge,
Box,
@@ -28,6 +28,7 @@ import { FALLBACK_MIMETYPE } from '$utils/mimeTypes';
import { stopPropagation } from '$utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useMediaSrc, useMediaDownloadToken } from '$hooks/useMediaSrc';
import { ModalWide } from '$styles/Modal.css';
import { validBlurHash } from '$utils/blurHash';
import * as css from './style.css';
@@ -78,6 +79,7 @@ export const ImageContent = as<'div', ImageContentProps>(
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
+ const mediaToken = useMediaDownloadToken();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false);
@@ -85,28 +87,48 @@ export const ImageContent = as<'div', ImageContentProps>(
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
+ const rawMediaUrl = useMemo(() => {
+ if (url.startsWith('http')) return url;
+ return mxcUrlToHttp(mx, url, useAuthentication) ?? undefined;
+ }, [mx, url, useAuthentication]);
+
+ const resolvedMediaUrl = useMediaSrc(encInfo ? undefined : rawMediaUrl);
+
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
- if (url.startsWith('http')) return url;
-
- const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
- if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) {
- const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
- decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
+ if (!rawMediaUrl) throw new Error('Invalid media URL');
+ const fileContent = await downloadEncryptedMedia(
+ rawMediaUrl,
+ (encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo),
+ mediaToken
);
return URL.createObjectURL(fileContent);
}
- return mediaUrl;
- }, [mx, url, useAuthentication, mimeType, encInfo])
+ return resolvedMediaUrl ?? rawMediaUrl ?? url;
+ }, [rawMediaUrl, resolvedMediaUrl, url, mimeType, encInfo, mediaToken])
);
+ // When the source download succeeds, reset image-element error state so the
+ // Retry button doesn't flash before the has had a chance to load.
+ useEffect(() => {
+ if (srcState.status === AsyncStatus.Success) {
+ setError(false);
+ }
+ }, [srcState.status]);
+
const handleLoad = () => {
setLoad(true);
+ setError(false);
};
const handleError = () => {
- setLoad(false);
- setError(true);
+ // Only show the error if the source download already succeeded — if
+ // it's still loading the image element may fire a transient error
+ // before the blob URL is ready.
+ if (srcState.status === AsyncStatus.Success) {
+ setLoad(false);
+ setError(true);
+ }
};
const handleRetry = () => {
@@ -217,12 +239,13 @@ export const ImageContent = as<'div', ImageContentProps>(
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
+ !error &&
!blurred && (
)}
- {(error || srcState.status === AsyncStatus.Error) && (
+ {!load && (error || srcState.status === AsyncStatus.Error) && (
{
+ if (typeof thumbMxcUrl !== 'string') return undefined;
+ return mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? undefined;
+ }, [mx, thumbMxcUrl, useAuthentication]);
+
+ const resolvedMediaUrl = useMediaSrc(encInfo ? undefined : rawMediaUrl);
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
useCallback(async () => {
const thumbInfo = info.thumbnail_info;
- const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
- const encInfo = info.thumbnail_file;
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
throw new Error('Failed to load thumbnail');
}
-
- const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication);
- if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) {
- const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
- decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
+ if (!rawMediaUrl) throw new Error('Invalid media URL');
+ const fileContent = await downloadEncryptedMedia(
+ rawMediaUrl,
+ (encBuf) => decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo),
+ mediaToken
);
return URL.createObjectURL(fileContent);
}
-
- return mediaUrl;
- }, [mx, info, useAuthentication])
+ return resolvedMediaUrl ?? rawMediaUrl ?? thumbMxcUrl;
+ }, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, encInfo, mediaToken])
);
useEffect(() => {
diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx
index bb267d70d..37e82d2fc 100644
--- a/src/app/components/message/content/VideoContent.tsx
+++ b/src/app/components/message/content/VideoContent.tsx
@@ -25,6 +25,7 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { bytesToSize, millisecondsToMinutesAndSeconds } from '$utils/common';
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useMediaDownloadToken } from '$hooks/useMediaSrc';
import { validBlurHash } from '$utils/blurHash';
import * as css from './style.css';
@@ -68,6 +69,7 @@ export const VideoContent = as<'div', VideoContentProps>(
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
+ const mediaToken = useMediaDownloadToken();
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false);
@@ -81,20 +83,36 @@ export const VideoContent = as<'div', VideoContentProps>(
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
- decryptFile(encBuf, mimeType, encInfo)
+ ? await downloadEncryptedMedia(
+ mediaUrl,
+ (encBuf) => decryptFile(encBuf, mimeType, encInfo),
+ mediaToken
)
- : await downloadMedia(mediaUrl);
+ : await downloadMedia(mediaUrl, mediaToken);
return URL.createObjectURL(fileContent);
- }, [mx, url, useAuthentication, mimeType, encInfo])
+ }, [mx, url, useAuthentication, mimeType, encInfo, mediaToken])
);
+ // When the source download succeeds, reset video-element error state so the
+ // Retry button doesn't flash before the has had a chance to load.
+ useEffect(() => {
+ if (srcState.status === AsyncStatus.Success) {
+ setError(false);
+ }
+ }, [srcState.status]);
+
const handleLoad = () => {
setLoad(true);
+ setError(false);
};
const handleError = () => {
- setLoad(false);
- setError(true);
+ // Only show the error if the source download already succeeded — if
+ // it's still loading the video element may fire a transient error
+ // before the blob URL is ready.
+ if (srcState.status === AsyncStatus.Success) {
+ setLoad(false);
+ setError(true);
+ }
};
const handleRetry = () => {
@@ -184,12 +202,13 @@ export const VideoContent = as<'div', VideoContentProps>(
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
+ !error &&
!blurred && (
)}
- {(error || srcState.status === AsyncStatus.Error) && (
+ {!load && (error || srcState.status === AsyncStatus.Error) && (
{iconSrc}
) : (
-
+
);
}
diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx
index e9072fb0b..5d32b5c07 100644
--- a/src/app/components/room-avatar/AvatarImage.tsx
+++ b/src/app/components/room-avatar/AvatarImage.tsx
@@ -3,6 +3,7 @@ import { ReactEventHandler, useState } from 'react';
import bgColorImg from '$utils/bgColorImg';
import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
+import { useMediaSrc } from '$hooks/useMediaSrc';
import * as css from './RoomAvatar.css';
type AvatarImageProps = {
@@ -17,6 +18,7 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp
const [image, setImage] = useState(undefined);
const normalizedBg = image ? bgColorImg(image) : undefined;
const useUniformIcons = uniformIconsSetting && uniformIcons === true;
+ const resolvedSrc = useMediaSrc(src);
const handleLoad: ReactEventHandler = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
@@ -27,7 +29,7 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp
{
diff --git a/src/app/components/tauri/TauriFrontendReady.tsx b/src/app/components/tauri/TauriFrontendReady.tsx
new file mode 100644
index 000000000..611907c36
--- /dev/null
+++ b/src/app/components/tauri/TauriFrontendReady.tsx
@@ -0,0 +1,60 @@
+import { useEffect } from 'react';
+import { getCurrentWindow } from '@tauri-apps/api/window';
+import { isTauri } from '@tauri-apps/api/core';
+import { type as osType } from '@tauri-apps/plugin-os';
+import splashscreen from 'tauri-plugin-splashscreen-api';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { createLogger } from '$utils/debug';
+import { commands } from '$generated/tauri';
+
+const log = createLogger('TauriFrontendReady');
+
+export function TauriFrontendReady() {
+ const [closeToTray] = useSetting(settingsAtom, 'closeToTray');
+
+ useEffect(() => {
+ if (!isTauri()) return undefined;
+
+ const os = osType();
+
+ if (os === 'android' || os === 'ios') {
+ // Wait for the first painted frame before signalling the plugin,
+ // otherwise the WebView content isn't visible yet when the native
+ // splash screen is dismissed — causing a blank-screen flash.
+ const rafId = window.requestAnimationFrame(() => {
+ log.log('Frontend is ready, sending close command to splashscreen plugin');
+ splashscreen.close().catch((error: unknown) => {
+ log.warn('Splashscreen close failed:', error);
+ });
+ });
+ return () => window.cancelAnimationFrame(rafId);
+ }
+
+ if (os === 'windows' || os === 'linux' || os === 'macos') {
+ const appWindow = getCurrentWindow();
+ const rafId = window.requestAnimationFrame(() => {
+ appWindow.show().catch((error) => {
+ log.warn('Failed to show main window after frontend mount:', error);
+ });
+ });
+ return () => window.cancelAnimationFrame(rafId);
+ }
+
+ return undefined;
+ }, []);
+
+ useEffect(() => {
+ if (!isTauri()) return undefined;
+
+ const os = osType();
+ if (os !== 'windows' && os !== 'linux' && os !== 'macos') return undefined;
+
+ commands.setCloseToTrayEnabled(closeToTray).catch((error) => {
+ log.warn('Failed to sync desktop close behavior:', error);
+ });
+ return undefined;
+ }, [closeToTray]);
+
+ return null;
+}
diff --git a/src/app/components/tauri/WindowsTitleBar.tsx b/src/app/components/tauri/WindowsTitleBar.tsx
new file mode 100644
index 000000000..a9b287a09
--- /dev/null
+++ b/src/app/components/tauri/WindowsTitleBar.tsx
@@ -0,0 +1,279 @@
+import { useEffect, useRef, useState } from 'react';
+import { isTauri } from '@tauri-apps/api/core';
+import { getCurrentWindow, type Window } from '@tauri-apps/api/window';
+import { listen } from '@tauri-apps/api/event';
+import { type as osType } from '@tauri-apps/plugin-os';
+import { useAtomValue } from 'jotai';
+import { createLogger } from '$utils/debug';
+import { titlebarStatusAtom } from '$state/titlebarStatus';
+import { SyncConnectionStatusTitlebar } from '$components/SyncConnectionStatus';
+import { commands } from '$generated/tauri';
+
+const log = createLogger('WindowsTitleBar');
+const SNAP_OVERLAY_DELAY_MS = 620;
+const SNAP_POPUP_WINDOW_CLASS = 'Xaml_WindowedPopupClass';
+const SNAP_POPUP_EXE = 'explorer.exe';
+
+type TrackingEventType = 'Started' | 'TargetLost' | 'Timeout' | 'Stopped';
+type TrackingEventPayload = {
+ event_type: TrackingEventType;
+};
+
+function MinimizeIcon() {
+ return (
+
+
+
+ );
+}
+
+function MaximizeIcon() {
+ return (
+
+
+
+ );
+}
+
+function RestoreIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function CloseIcon() {
+ return (
+
+
+
+ );
+}
+
+export function WindowsTitleBar() {
+ const [maximized, setMaximized] = useState(false);
+ const appWindowRef = useRef(null);
+ const snapTimerRef = useRef(undefined);
+ const isWindowsDesktopTauri = isTauri() && osType() === 'windows';
+ const titlebarStatus = useAtomValue(titlebarStatusAtom);
+
+ const hideSnapOverlay = async () => {
+ try {
+ await commands.hideSnapOverlay();
+ } catch (error) {
+ log.warn('Failed to hide snap overlay:', error);
+ }
+ };
+
+ useEffect(() => {
+ if (!isWindowsDesktopTauri) return undefined;
+
+ const appWindow = getCurrentWindow();
+ appWindowRef.current = appWindow;
+
+ let mounted = true;
+ let unlistenResize: (() => void) | undefined;
+ let unlistenTracking: (() => void) | undefined;
+
+ const syncMaximized = async () => {
+ try {
+ const isMaximizedState = await appWindow.isMaximized();
+ if (mounted) setMaximized(isMaximizedState);
+ } catch (error) {
+ log.warn('Failed to sync maximized state:', error);
+ }
+ };
+
+ syncMaximized().catch((error) => {
+ log.warn('Failed to sync maximized state:', error);
+ });
+ appWindow
+ .onResized(() => {
+ syncMaximized().catch((error) => {
+ log.warn('Failed to sync maximized state:', error);
+ });
+ })
+ .then((removeListener) => {
+ unlistenResize = removeListener;
+ })
+ .catch((error) => {
+ log.warn('Failed to subscribe to window resize:', error);
+ });
+
+ listen('window-tracking', (event) => {
+ const eventType = event.payload?.event_type;
+ if (eventType === 'TargetLost' || eventType === 'Timeout') {
+ hideSnapOverlay();
+ }
+ })
+ .then((removeListener) => {
+ unlistenTracking = removeListener;
+ })
+ .catch((error) => {
+ log.warn('Failed to subscribe to window-tracking event:', error);
+ });
+
+ return () => {
+ mounted = false;
+ if (snapTimerRef.current !== undefined) {
+ window.clearTimeout(snapTimerRef.current);
+ }
+ commands.stopWindowTracking().catch(() => {});
+ hideSnapOverlay();
+ unlistenResize?.();
+ unlistenTracking?.();
+ };
+ }, [isWindowsDesktopTauri]);
+
+ if (!isWindowsDesktopTauri) return null;
+
+ const minimize = () => {
+ if (!isWindowsDesktopTauri) return;
+
+ commands.stopWindowTracking().catch(() => {});
+ hideSnapOverlay();
+ appWindowRef.current?.minimize().catch((error) => {
+ log.warn('Failed to minimize window:', error);
+ });
+ };
+
+ const toggleMaximize = () => {
+ if (!isWindowsDesktopTauri) return;
+
+ if (snapTimerRef.current !== undefined) {
+ window.clearTimeout(snapTimerRef.current);
+ snapTimerRef.current = undefined;
+ }
+
+ commands.stopWindowTracking().catch(() => {});
+ hideSnapOverlay();
+ appWindowRef.current?.toggleMaximize().catch((error) => {
+ log.warn('Failed to toggle maximize:', error);
+ });
+ };
+
+ const close = () => {
+ if (!isWindowsDesktopTauri) return;
+
+ commands.stopWindowTracking().catch(() => {});
+ hideSnapOverlay();
+ appWindowRef.current?.close().catch((error) => {
+ log.warn('Failed to close window:', error);
+ });
+ };
+
+ const showSnapOverlay = () => {
+ if (!isWindowsDesktopTauri) return;
+
+ if (snapTimerRef.current !== undefined) {
+ window.clearTimeout(snapTimerRef.current);
+ }
+ commands.stopWindowTracking().catch(() => {});
+
+ snapTimerRef.current = window.setTimeout(() => {
+ appWindowRef.current
+ ?.setFocus()
+ .then(() => commands.showSnapOverlay())
+ .catch((error) => {
+ log.warn('Failed to show snap overlay:', error);
+ });
+ }, SNAP_OVERLAY_DELAY_MS);
+ };
+
+ const cancelSnapOverlay = () => {
+ if (!isWindowsDesktopTauri) return;
+
+ if (snapTimerRef.current !== undefined) {
+ window.clearTimeout(snapTimerRef.current);
+ snapTimerRef.current = undefined;
+ }
+
+ commands
+ .stopWindowTracking()
+ .then(() =>
+ commands.startWindowTrackingWithTarget({
+ window_class: SNAP_POPUP_WINDOW_CLASS,
+ exe_name: SNAP_POPUP_EXE,
+ })
+ )
+ .catch((error) => {
+ log.warn('Failed to start snap popup tracking:', error);
+ });
+ };
+
+ return (
+
+
+
+ sable
+
+
+
+
+
+
+
+
+
+
+
+ {maximized ? : }
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx
index 3659cb305..629f4a654 100644
--- a/src/app/components/user-avatar/UserAvatar.tsx
+++ b/src/app/components/user-avatar/UserAvatar.tsx
@@ -2,6 +2,7 @@ import { AvatarFallback, AvatarImage, color } from 'folds';
import { ReactEventHandler, ReactNode, useState } from 'react';
import classNames from 'classnames';
import colorMXID from '$utils/colorMXID';
+import { useMediaSrc } from '$hooks/useMediaSrc';
import * as css from './UserAvatar.css';
type UserAvatarProps = {
@@ -13,6 +14,7 @@ type UserAvatarProps = {
};
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
const [error, setError] = useState(false);
+ const resolvedSrc = useMediaSrc(src);
const handleLoad: ReactEventHandler = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
@@ -32,7 +34,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
return (
setError(true)}
onLoad={handleLoad}
diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx
index 0acdc3618..903552ac3 100644
--- a/src/app/components/user-profile/UserHero.tsx
+++ b/src/app/components/user-profile/UserHero.tsx
@@ -24,6 +24,7 @@ import { useRoom } from '$hooks/useRoom';
import { useSableCosmetics } from '$hooks/useSableCosmetics';
import { useNickname } from '$hooks/useNickname';
import { useBlobCache } from '$hooks/useBlobCache';
+import { useMediaDownloadToken } from '$hooks/useMediaSrc';
import { ImageViewer } from '$components/image-viewer';
import { AvatarPresence, PresenceBadge } from '$components/presence';
import { UserAvatar } from '$components/user-avatar';
@@ -41,8 +42,9 @@ export function UserHero({ userId, avatarUrl, bannerUrl, presence, autoplayGifs
const [viewAvatar, setViewAvatar] = useState();
const [isFullStatus, setIsFullStatus] = useState(false);
- const cachedBannerUrl = useBlobCache(bannerUrl);
- const cachedAvatarUrl = useBlobCache(avatarUrl);
+ const mediaToken = useMediaDownloadToken();
+ const cachedBannerUrl = useBlobCache(bannerUrl, mediaToken);
+ const cachedAvatarUrl = useBlobCache(avatarUrl, mediaToken);
const coverUrl = cachedBannerUrl || cachedAvatarUrl;
const isFallbackCover = !cachedBannerUrl && !!cachedAvatarUrl;
diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
index 4aacd518d..12d0e499c 100644
--- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
+++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
@@ -32,6 +32,7 @@ import { SettingTile } from '$components/setting-tile';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useMediaSrc } from '$hooks/useMediaSrc';
import { usePowerLevels } from '$hooks/usePowerLevels';
import { StateEvent } from '$types/matrix/room';
import { suffixRename } from '$utils/common';
@@ -41,6 +42,11 @@ import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { SequenceCardStyle } from '$features/common-settings/styles.css';
+function PackAvatarImage({ url }: { url: string }) {
+ const resolved = useMediaSrc(url);
+ return ;
+}
+
type CreatePackTileProps = {
packs: ImagePack[];
roomId: string;
@@ -236,7 +242,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
))}
{avatarUrl ? (
-
+
) : (
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 0bf4317eb..878fa6c03 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -72,6 +72,7 @@ import { mobileOrTablet } from '$utils/user-agent';
import { useUserProfile } from '$hooks/useUserProfile';
import { useSetting } from '$state/hooks/settings';
import { useBlobCache } from '$hooks/useBlobCache';
+import { useMediaDownloadToken } from '$hooks/useMediaSrc';
import { MessageAllReactionItem } from '$components/message/modals/MessageReactions';
import { MessageReadReceiptItem } from '$components/message/modals/MessageReadRecipts';
import { MessageEditHistoryItem } from '$components/message/modals/MessageEditHistory';
@@ -355,6 +356,7 @@ function MessageInternal(
) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
+ const mediaToken = useMediaDownloadToken();
const pmp = useMemo(
() =>
@@ -386,7 +388,7 @@ function MessageInternal(
[pmp, senderDisplayName]
);
- const cachedAvatar = useBlobCache(avatarUrl ?? undefined);
+ const cachedAvatar = useBlobCache(avatarUrl ?? undefined, mediaToken);
// UI State
const [isDesktopHover, setIsDesktopHover] = useState(false);
diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx
index 34f350b3d..9f1781f5a 100644
--- a/src/app/features/settings/Settings.tsx
+++ b/src/app/features/settings/Settings.tsx
@@ -14,6 +14,7 @@ import {
OverlayCenter,
Text,
} from 'folds';
+import { isTauri } from '@tauri-apps/api/core';
import FocusTrap from 'focus-trap-react';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '$components/page';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
@@ -36,12 +37,14 @@ import { General } from './general';
import { Cosmetics } from './cosmetics/Cosmetics';
import { Experimental } from './experimental/Experimental';
import { KeyboardShortcuts } from './keyboard-shortcuts';
+import { Desktop } from './desktop';
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
DevicesPage,
+ DesktopPage,
EmojisStickersPage,
CosmeticsPage,
DeveloperToolsPage,
@@ -86,6 +89,15 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
name: 'Devices',
icon: Icons.Monitor,
},
+ ...(isTauri()
+ ? [
+ {
+ page: SettingsPages.DesktopPage,
+ name: 'Desktop',
+ icon: Icons.Monitor,
+ },
+ ]
+ : []),
{
page: SettingsPages.EmojisStickersPage,
name: 'Emojis & Stickers',
@@ -256,6 +268,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.DevicesPage && (
)}
+ {activePage === SettingsPages.DesktopPage && (
+
+ )}
{activePage === SettingsPages.EmojisStickersPage && (
)}
diff --git a/src/app/features/settings/desktop/Desktop.tsx b/src/app/features/settings/desktop/Desktop.tsx
new file mode 100644
index 000000000..0efaf3cf4
--- /dev/null
+++ b/src/app/features/settings/desktop/Desktop.tsx
@@ -0,0 +1,61 @@
+import { isTauri } from '@tauri-apps/api/core';
+import { Box, Text, IconButton, Icon, Icons, Scroll, Switch } from 'folds';
+import { Page, PageContent, PageHeader } from '$components/page';
+import { SequenceCard } from '$components/sequence-card';
+import { SettingTile } from '$components/setting-tile';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { SequenceCardStyle } from '$features/settings/styles.css';
+
+type DesktopProps = {
+ requestClose: () => void;
+};
+
+export function Desktop({ requestClose }: DesktopProps) {
+ const [closeToTray, setCloseToTray] = useSetting(settingsAtom, 'closeToTray');
+
+ if (!isTauri()) return null;
+
+ return (
+
+
+
+
+
+ Desktop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Window
+
+
+ }
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/settings/desktop/index.ts b/src/app/features/settings/desktop/index.ts
new file mode 100644
index 000000000..71f2ecc9f
--- /dev/null
+++ b/src/app/features/settings/desktop/index.ts
@@ -0,0 +1 @@
+export * from './Desktop';
diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx
index 1c66d4c6f..66df642eb 100644
--- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx
+++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx
@@ -29,6 +29,7 @@ import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
import { mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useMediaSrc } from '$hooks/useMediaSrc';
import { useMatrixClient } from '$hooks/useMatrixClient';
import {
EmoteRoomsContent,
@@ -44,6 +45,11 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { stopPropagation } from '$utils/keyboard';
import { SequenceCardStyle } from '$features/settings/styles.css';
+function PackAvatarImage({ url }: { url: string }) {
+ const resolved = useMediaSrc(url);
+ return ;
+}
+
function GlobalPackSelector({
packs,
useAuthentication,
@@ -190,7 +196,7 @@ function GlobalPackSelector({
{avatarUrl ? (
-
+
) : (
@@ -389,7 +395,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
)}
{avatarUrl ? (
-
+
) : (
diff --git a/src/app/features/settings/emojis-stickers/UserPack.tsx b/src/app/features/settings/emojis-stickers/UserPack.tsx
index 03c80da2f..36b71e29a 100644
--- a/src/app/features/settings/emojis-stickers/UserPack.tsx
+++ b/src/app/features/settings/emojis-stickers/UserPack.tsx
@@ -6,6 +6,7 @@ import { ImagePack, ImageUsage } from '$plugins/custom-emoji';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useMediaSrc } from '$hooks/useMediaSrc';
import { SequenceCardStyle } from '$features/settings/styles.css';
type UserPackProps = {
@@ -18,6 +19,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
const userPack = useUserImagePack();
const avatarMxc = userPack?.getAvatarUrl(ImageUsage.Emoticon);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
+ const resolvedAvatarUrl = useMediaSrc(avatarUrl ?? undefined);
const handleView = () => {
if (userPack) {
@@ -43,7 +45,10 @@ export function UserPack({ onViewPack }: UserPackProps) {
before={
{avatarUrl ? (
-
+
) : (
diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx
index ef84ae59e..bdea9fe2f 100644
--- a/src/app/features/settings/notifications/Notifications.tsx
+++ b/src/app/features/settings/notifications/Notifications.tsx
@@ -3,10 +3,12 @@ import { Page, PageContent, PageHeader } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
import { SequenceCardStyle } from '$features/settings/styles.css';
+import { isTauri } from '@tauri-apps/api/core';
import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages';
import { SpecialMessagesNotifications } from './SpecialMessages';
import { KeywordMessagesNotifications } from './KeywordMessages';
+import UnifiedPushNotificationSetting from './UnifiedPushNotificationSetting';
type NotificationsProps = {
requestClose: () => void;
@@ -33,6 +35,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
+ {isTauri() && }
diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx
index 08310d28d..89c478e5d 100644
--- a/src/app/features/settings/notifications/PushNotifications.tsx
+++ b/src/app/features/settings/notifications/PushNotifications.tsx
@@ -1,4 +1,5 @@
import { MatrixClient } from '$types/matrix-sdk';
+import { isTauri } from '@tauri-apps/api/core';
import { ClientConfig } from '../../../hooks/useClientConfig';
type PushSubscriptionState = [
@@ -23,6 +24,9 @@ export async function enablePushNotifications(
clientConfig: ClientConfig,
pushSubscriptionAtom: PushSubscriptionState
): Promise {
+ if (isTauri()) {
+ throw new Error('Push notifications are disabled in Tauri runtime.');
+ }
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push messaging is not supported in this browser.');
}
@@ -113,6 +117,8 @@ export async function disablePushNotifications(
clientConfig: ClientConfig,
pushSubscriptionAtom: PushSubscriptionState
): Promise {
+ if (isTauri()) return;
+
const [pushSubAtom] = pushSubscriptionAtom;
const pusherData = {
diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx
index 87ab5fc09..7add76d52 100644
--- a/src/app/features/settings/notifications/SystemNotification.tsx
+++ b/src/app/features/settings/notifications/SystemNotification.tsx
@@ -224,18 +224,20 @@ export function SystemNotification() {
return (
System & Notifications
-
- }
- />
-
+ {mobileOrTablet() && (
+
+ }
+ />
+
+ )}
{mobileOrTablet() && (
(null);
+ const [distributors, setDistributors] = useState([]);
+ const [currentDistributor, setCurrentDistributor] = useState('');
+ const [menuCords, setMenuCords] = useState();
+
+ useEffect(() => {
+ Promise.all([
+ getUnifiedPushDistributors().catch(() => ({ distributors: [] as string[] })),
+ getUnifiedPushDistributor().catch(() => ({ distributor: '' })),
+ ]).then(async ([distResult, savedResult]) => {
+ setDistributors(distResult.distributors);
+ setCurrentDistributor(savedResult.distributor);
+
+ // Auto-save the only available distributor when none is saved yet.
+ // UP connector 3.x requires an explicit selection before register() works.
+ if (!savedResult.distributor && distResult.distributors.length === 1) {
+ await saveUnifiedPushDistributor(distResult.distributors[0]);
+ setCurrentDistributor(distResult.distributors[0]);
+ }
+ });
+ }, []);
+
+ const handleToggle = async (wantsUP: boolean) => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ if (wantsUP) {
+ if (!currentDistributor && distributors.length > 0) {
+ await saveUnifiedPushDistributor(distributors[0]);
+ setCurrentDistributor(distributors[0]);
+ }
+
+ const result = await enableUnifiedPush(mx, clientConfig);
+ setUpEndpoint(result);
+ setUseUP(true);
+ } else {
+ await disableUnifiedPush(mx, clientConfig, upEndpoint?.endpoint);
+ setUpEndpoint(null);
+ setUseUP(false);
+ }
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error('UnifiedPush toggle failed:', e);
+ setError(msg);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleSelectDistributor = async (distributor: string) => {
+ setMenuCords(undefined);
+ if (distributor === currentDistributor) return;
+ setIsLoading(true);
+ try {
+ if (useUP) {
+ await disableUnifiedPush(mx, clientConfig, upEndpoint?.endpoint);
+ }
+ await saveUnifiedPushDistributor(distributor);
+ setCurrentDistributor(distributor);
+ if (useUP) {
+ const result = await enableUnifiedPush(mx, clientConfig);
+ setUpEndpoint(result);
+ }
+ } catch (e) {
+ console.error('Distributor switch failed:', e);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const distributorLabel = (pkg: string) => {
+ const parts = pkg.split('.');
+ return parts[parts.length - 1] ?? pkg;
+ };
+
+ const handleDistributorMenu: MouseEventHandler = (evt) => {
+ setMenuCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <>
+
+ {error}
+
+ ) : distributors.length === 0 ? (
+
+ No UnifiedPush distributor installed. Install one (e.g. ntfy, NextPush) to use this
+ feature.
+
+ ) : (
+ 'Receive background notifications via UnifiedPush without Google Services.'
+ )
+ }
+ after={
+ isLoading ? (
+
+ ) : (
+
+ )
+ }
+ />
+ {distributors.length > 1 && (
+
+ setMenuCords(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+ {distributors.map((d) => (
+ handleSelectDistributor(d)}
+ aria-pressed={d === currentDistributor}
+ >
+ {distributorLabel(d)}
+
+ ))}
+
+
+ }
+ />
+
+ Change
+
+ >
+ }
+ />
+ )}
+ >
+ );
+}
diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts
new file mode 100644
index 000000000..a6ce21c35
--- /dev/null
+++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts
@@ -0,0 +1,529 @@
+import { MatrixClient } from '$types/matrix-sdk';
+import { ClientConfig } from '$hooks/useClientConfig';
+import {
+ registerForUnifiedPush,
+ unregisterFromUnifiedPush,
+ getUnifiedPushDistributors,
+ saveUnifiedPushDistributor,
+ getUnifiedPushDistributor,
+ onUnifiedPushMessage,
+ onUnifiedPushEndpoint,
+ sendNotification,
+ removeActive,
+ createChannel,
+ Importance,
+} from '@sableclient/tauri-plugin-notifications-api';
+import type {
+ MessagingStyleMessage,
+ MessagingStylePerson,
+} from '@sableclient/tauri-plugin-notifications-api';
+import { EventType } from 'matrix-js-sdk/lib/@types/event';
+import { resolveNotificationPreviewText } from '$utils/notificationStyle';
+import { getMxIdLocalPart } from '$utils/matrix';
+import { getStateEvent, getMemberAvatarMxc } from '$utils/room';
+import { StateEvent } from '$types/matrix/room';
+
+export { getUnifiedPushDistributors, getUnifiedPushDistributor, saveUnifiedPushDistributor };
+
+const UP_PUBLIC_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
+
+/**
+ * Probes the UP endpoint for a Matrix-compatible push gateway.
+ * Falls back to the configured or public UP gateway.
+ * Note: pushNotifyUrl (Sygnal) is NOT suitable — only a proper UP gateway works.
+ */
+async function discoverGateway(upEndpoint: string, unifiedPushGateway?: string): Promise {
+ try {
+ const probeUrl = new URL(upEndpoint);
+ probeUrl.pathname = '/_matrix/push/v1/notify';
+ probeUrl.search = '';
+ const res = await fetch(probeUrl.toString());
+ if (res.ok) {
+ const body = await res.json();
+ if (
+ body?.gateway === 'matrix' ||
+ (body?.unifiedpush && body.unifiedpush.gateway === 'matrix')
+ ) {
+ return probeUrl.toString();
+ }
+ }
+ } catch {
+ // probe failed
+ }
+ return unifiedPushGateway ?? UP_PUBLIC_GATEWAY;
+}
+
+const UP_REGISTER_TIMEOUT_MS = 30_000;
+
+export async function enableUnifiedPush(
+ mx: MatrixClient,
+ clientConfig: ClientConfig
+): Promise<{ endpoint: string; instance: string }> {
+ await createChannel({
+ id: 'messages',
+ name: 'Messages',
+ description: 'Matrix message and invite notifications',
+ importance: Importance.Default,
+ vibration: true,
+ });
+
+ const timeout = new Promise((_, reject) => {
+ const id = setTimeout(() => {
+ reject(new Error('UnifiedPush registration timed out'));
+ clearTimeout(id);
+ }, UP_REGISTER_TIMEOUT_MS);
+ });
+ const { endpoint, instance, pubKeySet } = await Promise.race([registerForUnifiedPush(), timeout]);
+
+ const appId = clientConfig.pushNotificationDetails?.unifiedPushAppID ?? 'moe.sable.up';
+
+ // Don't fall back to pushNotifyUrl (Sygnal) — it cannot forward to UP endpoints.
+ const unifiedPushGateway = clientConfig.pushNotificationDetails?.unifiedPushGatewayUrl;
+
+ const gatewayUrl = await discoverGateway(endpoint, unifiedPushGateway);
+
+ const pusherData: Record = {
+ url: gatewayUrl,
+ };
+
+ // VAPID-capable distributors (e.g. NextPush) provide keys for RFC 8291 encryption.
+ if (pubKeySet) {
+ pusherData.p256dh = pubKeySet.pubKey;
+ pusherData.auth = pubKeySet.auth;
+ }
+
+ await mx.setPusher({
+ kind: 'http',
+ app_id: appId,
+ pushkey: endpoint,
+ app_display_name: 'Sable (UnifiedPush)',
+ device_display_name:
+ (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Android Device',
+ lang: navigator.language || 'en',
+ data: pusherData,
+ append: false,
+ } as any);
+
+ return { endpoint, instance };
+}
+
+export async function disableUnifiedPush(
+ mx: MatrixClient,
+ clientConfig: ClientConfig,
+ pushkey?: string
+): Promise {
+ const appId = clientConfig.pushNotificationDetails?.unifiedPushAppID ?? 'moe.sable.up';
+
+ if (pushkey) {
+ await mx.setPusher({
+ kind: null,
+ app_id: appId,
+ pushkey,
+ } as any);
+ }
+
+ await unregisterFromUnifiedPush();
+}
+
+type NotificationSettings = {
+ mx: MatrixClient;
+ showMessageContent: boolean;
+ showEncryptedMessageContent: boolean;
+ notificationSoundEnabled: boolean;
+ useInAppNotifications: boolean;
+};
+
+// One MessagingStyle notification per room, accumulated into a single Android group.
+
+const NOTIF_GROUP_KEY = 'matrix_messages';
+const MAX_MESSAGES = 10;
+
+function hashCode(str: string): number {
+ let hash = 0;
+ for (let i = 0; i < str.length; i += 1) {
+ // eslint-disable-next-line no-bitwise
+ hash = (Math.imul(31, hash) + str.charCodeAt(i)) | 0;
+ }
+ return Math.abs(hash);
+}
+
+const roomNotifId = (roomId: string) => hashCode(roomId);
+const SUMMARY_NOTIF_ID = hashCode('sable-group-summary');
+
+/** Accumulated messages per room, cleared when unread drops to 0. */
+type RoomNotifCache = {
+ roomName: string;
+ messages: MessagingStyleMessage[];
+ seenEventIds: Set;
+ isGroupConversation: boolean;
+ latestEventId?: string;
+};
+
+const roomNotifCaches = new Map();
+
+/**
+ * Resolves a user avatar to an HTTP URL for notification display.
+ *
+ * Returns an authenticated media URL (/_matrix/client/v1/media/).
+ * The plugin's Kotlin layer downloads the image using the `authToken`
+ * supplied in `MessagingStyleConfig`, so authenticated endpoints work.
+ */
+function resolveAvatarUrl(mx: MatrixClient, roomId: string, userId: string): string | undefined {
+ const room = mx.getRoom(roomId);
+ if (!room) return undefined;
+ const mxcUrl = getMemberAvatarMxc(room, userId);
+ if (!mxcUrl) return undefined;
+ return mx.mxcUrlToHttp(mxcUrl, 96, 96, 'crop', false, true, true) ?? undefined;
+}
+
+function getOrCreateRoomCache(roomId: string, roomName: string): RoomNotifCache {
+ let cache = roomNotifCaches.get(roomId);
+ if (!cache) {
+ cache = { roomName, messages: [], seenEventIds: new Set(), isGroupConversation: false };
+ roomNotifCaches.set(roomId, cache);
+ }
+ cache.roomName = roomName;
+ return cache;
+}
+
+/** Clears accumulated messages for a room and dismisses its notification. */
+export async function clearRoomNotification(roomId: string) {
+ roomNotifCaches.delete(roomId);
+ try {
+ await removeActive([{ id: roomNotifId(roomId) }]);
+ } catch {
+ // already dismissed
+ }
+ if (roomNotifCaches.size <= 1) {
+ try {
+ await removeActive([{ id: SUMMARY_NOTIF_ID }]);
+ } catch {
+ // ignore
+ }
+ }
+}
+
+/** Posts (or updates) the per-room MessagingStyle notification and the group summary. */
+async function postRoomNotification(
+ roomId: string,
+ cache: RoomNotifCache,
+ selfUser: MessagingStylePerson,
+ isSilent: boolean,
+ extra: Record,
+ authToken?: string | null
+) {
+ const { messages, roomName, isGroupConversation } = cache;
+ const latestMsg = messages[messages.length - 1];
+ const latestBody = latestMsg ? `${latestMsg.sender?.name ?? 'You'}: ${latestMsg.text}` : '';
+
+ await sendNotification({
+ id: roomNotifId(roomId),
+ title: roomName,
+ body: latestBody,
+ channelId: 'messages',
+ group: NOTIF_GROUP_KEY,
+ icon: 'notification_icon',
+ silent: isSilent,
+ autoCancel: true,
+ extra,
+ messagingStyle: {
+ user: selfUser,
+ conversationTitle: isGroupConversation ? roomName : undefined,
+ isGroupConversation,
+ messages,
+ authToken: authToken ?? undefined,
+ },
+ });
+
+ // App-wide group summary — Android uses this when 4+ child notifications
+ // exist. With only one room there's nothing to summarise, and posting a
+ // summary can cause the OS to show the summary *instead of* the child
+ // MessagingStyle notification on some devices.
+ const roomCount = roomNotifCaches.size;
+ if (roomCount > 1) {
+ const totalMessages = Array.from(roomNotifCaches.values()).reduce(
+ (sum, c) => sum + c.messages.length,
+ 0
+ );
+ const summaryText = `${totalMessages} messages in ${roomCount} chats`;
+ const inboxLines: string[] = [];
+ Array.from(roomNotifCaches.values()).forEach((c) => {
+ const latest = c.messages[c.messages.length - 1];
+ if (latest) {
+ inboxLines.push(`${c.roomName}: ${latest.sender?.name ?? 'You'}: ${latest.text}`);
+ }
+ });
+ await sendNotification({
+ id: SUMMARY_NOTIF_ID,
+ title: summaryText,
+ body: '',
+ summary: summaryText,
+ inboxLines: inboxLines.slice(-5),
+ channelId: 'messages',
+ group: NOTIF_GROUP_KEY,
+ groupSummary: true,
+ icon: 'notification_icon',
+ silent: true,
+ autoCancel: true,
+ });
+ }
+}
+
+/** Handles a rich push payload containing full event details (type, room_name, content, etc.). */
+async function handleRichPushPayload(
+ pushData: Record,
+ settings: NotificationSettings
+) {
+ const eventType = pushData.type as EventType;
+
+ switch (eventType) {
+ case EventType.RoomMessage:
+ case EventType.Sticker:
+ case EventType.RoomMessageEncrypted: {
+ const isEncrypted = eventType === EventType.RoomMessageEncrypted;
+
+ const previewText = resolveNotificationPreviewText({
+ content: pushData?.content,
+ eventType: pushData?.type,
+ isEncryptedRoom: isEncrypted,
+ showMessageContent: settings.showMessageContent,
+ showEncryptedMessageContent: settings.showEncryptedMessageContent,
+ });
+
+ const roomId: string | undefined = pushData?.room_id;
+ const roomName: string = pushData?.room_name ?? 'Unknown Room';
+ const senderName: string | undefined = pushData?.sender_display_name;
+ const senderId: string | undefined = pushData?.sender;
+ const isSilent = !settings.notificationSoundEnabled;
+
+ const selfUserId = settings.mx.getUserId() ?? undefined;
+ const selfUser: MessagingStylePerson = {
+ name: 'You',
+ key: selfUserId,
+ iconUrl:
+ selfUserId && roomId ? resolveAvatarUrl(settings.mx, roomId, selfUserId) : undefined,
+ };
+
+ if (!roomId) {
+ await sendNotification({
+ title: roomName,
+ body: senderName ? `${senderName}: ${previewText}` : previewText,
+ channelId: 'messages',
+ icon: 'notification_icon',
+ silent: isSilent,
+ autoCancel: true,
+ });
+ break;
+ }
+
+ const sender: MessagingStylePerson | undefined = senderName
+ ? {
+ name: senderName,
+ key: senderId,
+ iconUrl: senderId ? resolveAvatarUrl(settings.mx, roomId, senderId) : undefined,
+ }
+ : undefined;
+
+ const message: MessagingStyleMessage = {
+ text: previewText,
+ timestamp: Date.now(),
+ sender,
+ };
+
+ const cache = getOrCreateRoomCache(roomId, roomName);
+
+ const eventId: string | undefined = pushData?.event_id;
+ if (eventId && cache.seenEventIds.has(eventId)) break;
+ if (eventId) cache.seenEventIds.add(eventId);
+
+ cache.messages.push(message);
+ if (cache.messages.length > MAX_MESSAGES) {
+ cache.messages = cache.messages.slice(-MAX_MESSAGES);
+ }
+ cache.latestEventId = eventId;
+
+ const room = settings.mx.getRoom(roomId);
+ if (room) {
+ cache.isGroupConversation = (room.getJoinedMemberCount() ?? 0) > 2;
+ }
+
+ await postRoomNotification(
+ roomId,
+ cache,
+ selfUser,
+ isSilent,
+ {
+ room_id: roomId,
+ event_id: pushData?.event_id,
+ user_id: pushData?.user_id,
+ },
+ settings.mx.getAccessToken()
+ );
+ break;
+ }
+ case EventType.RoomMember: {
+ if (pushData?.content?.membership !== 'invite') break;
+ const senderName: string | undefined = pushData?.sender_display_name;
+ const roomName: string | undefined = pushData?.room_name;
+ let body = '';
+ if (senderName && roomName) body = `${senderName} invites you to ${roomName}`;
+ else if (senderName) body = `from ${senderName}`;
+ else if (roomName) body = `to ${roomName}`;
+
+ await sendNotification({
+ title: 'New Invitation',
+ body,
+ channelId: 'messages',
+ group: NOTIF_GROUP_KEY,
+ icon: 'notification_icon',
+ autoCancel: true,
+ extra: {
+ room_id: pushData?.room_id,
+ event_id: pushData?.event_id,
+ user_id: pushData?.user_id,
+ },
+ });
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+/**
+ * Handles a minimal push payload (event_id + room_id + counts) from
+ * the public UnifiedPush gateway, looking up context from local SDK state.
+ */
+async function handleMinimalPushPayload(
+ pushData: Record,
+ settings: NotificationSettings
+) {
+ const roomId: string | undefined = pushData?.room_id;
+ const eventId: string | undefined = pushData?.event_id;
+ const unread: number | undefined =
+ typeof pushData?.counts?.unread === 'number' ? pushData.counts.unread : undefined;
+
+ if (!roomId) return;
+
+ // Unread count of zero means the room was read — dismiss the notification.
+ if (unread === 0) {
+ await clearRoomNotification(roomId);
+ return;
+ }
+
+ const room = settings.mx.getRoom(roomId);
+ const roomName = room?.name ?? 'Unknown Room';
+ const isEncryptedRoom = room ? !!getStateEvent(room, StateEvent.RoomEncryption) : false;
+
+ let senderName: string | undefined;
+ let senderId: string | undefined;
+ let previewText: string | undefined;
+ if (room && eventId) {
+ const timeline = room.getLiveTimeline().getEvents();
+ const mEvent = timeline.find((e) => e.getId() === eventId);
+ if (mEvent) {
+ const sender = mEvent.getSender();
+ if (sender) {
+ const member = room.getMember(sender);
+ senderName = member?.name ?? getMxIdLocalPart(sender) ?? sender;
+ senderId = sender;
+ }
+
+ previewText = resolveNotificationPreviewText({
+ content: mEvent.getContent(),
+ eventType: mEvent.getType(),
+ isEncryptedRoom,
+ showMessageContent: settings.showMessageContent,
+ showEncryptedMessageContent: settings.showEncryptedMessageContent,
+ });
+ }
+ }
+
+ if (!previewText) {
+ previewText = isEncryptedRoom ? 'Encrypted message' : 'New message';
+ }
+
+ const selfUserId = settings.mx.getUserId() ?? undefined;
+ const selfUser: MessagingStylePerson = {
+ name: 'You',
+ key: selfUserId,
+ iconUrl: selfUserId && roomId ? resolveAvatarUrl(settings.mx, roomId, selfUserId) : undefined,
+ };
+
+ const sender: MessagingStylePerson | undefined = senderName
+ ? {
+ name: senderName,
+ key: senderId,
+ iconUrl: senderId && roomId ? resolveAvatarUrl(settings.mx, roomId, senderId) : undefined,
+ }
+ : undefined;
+
+ const message: MessagingStyleMessage = {
+ text: previewText,
+ timestamp: Date.now(),
+ sender,
+ };
+
+ const cache = getOrCreateRoomCache(roomId, roomName);
+
+ if (eventId && cache.seenEventIds.has(eventId)) return;
+ if (eventId) cache.seenEventIds.add(eventId);
+
+ cache.messages.push(message);
+ if (cache.messages.length > MAX_MESSAGES) {
+ cache.messages = cache.messages.slice(-MAX_MESSAGES);
+ }
+ cache.latestEventId = eventId;
+
+ if (room) {
+ cache.isGroupConversation = (room.getJoinedMemberCount() ?? 0) > 2;
+ }
+
+ await postRoomNotification(
+ roomId,
+ cache,
+ selfUser,
+ !settings.notificationSoundEnabled,
+ {
+ room_id: roomId,
+ event_id: eventId,
+ },
+ settings.mx.getAccessToken()
+ );
+}
+
+async function handleUnifiedPushPayload(
+ raw: Record,
+ getSettings: () => NotificationSettings
+) {
+ const settings = getSettings();
+
+ // Skip system notification when in-app banners are active and visible.
+ if (document.visibilityState === 'visible' && settings.useInAppNotifications) {
+ return;
+ }
+
+ // The UP gateway wraps the Matrix push in a `notification` field.
+ const pushData = (raw.notification ?? raw) as Record;
+ const eventType = pushData?.type as EventType | undefined;
+
+ if (eventType) {
+ await handleRichPushPayload(pushData, settings);
+ } else {
+ await handleMinimalPushPayload(pushData, settings);
+ }
+}
+
+export function listenForUnifiedPushMessages(getSettings: () => NotificationSettings) {
+ return onUnifiedPushMessage((data: Record) => {
+ handleUnifiedPushPayload(data, getSettings);
+ });
+}
+
+export function listenForUnifiedPushEndpointChanges(
+ onEndpointChanged: (endpoint: string, instance: string) => void
+) {
+ return onUnifiedPushEndpoint(({ endpoint, instance }) => {
+ onEndpointChanged(endpoint, instance);
+ });
+}
diff --git a/src/app/generated/tauri.ts b/src/app/generated/tauri.ts
new file mode 100644
index 000000000..049233e2d
--- /dev/null
+++ b/src/app/generated/tauri.ts
@@ -0,0 +1,113 @@
+/* eslint-disable */
+// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
+
+/** user-defined commands **/
+
+
+export const commands = {
+async setCloseToTrayEnabled(enabled: boolean) : Promise {
+ await TAURI_INVOKE("set_close_to_tray_enabled", { enabled });
+},
+async showSnapOverlay() : Promise {
+ await TAURI_INVOKE("show_snap_overlay");
+},
+async hideSnapOverlay() : Promise {
+ await TAURI_INVOKE("hide_snap_overlay");
+},
+async startWindowTrackingWithTarget(target: WindowTarget) : Promise> {
+ try {
+ return { status: "ok", data: await TAURI_INVOKE("start_window_tracking_with_target", { target }) };
+} catch (e) {
+ if(e instanceof Error) throw e;
+ else return { status: "error", error: e as any };
+}
+},
+async stopWindowTracking() : Promise> {
+ try {
+ return { status: "ok", data: await TAURI_INVOKE("stop_window_tracking") };
+} catch (e) {
+ if(e instanceof Error) throw e;
+ else return { status: "error", error: e as any };
+}
+},
+async isWindowTrackingActive() : Promise> {
+ try {
+ return { status: "ok", data: await TAURI_INVOKE("is_window_tracking_active") };
+} catch (e) {
+ if(e instanceof Error) throw e;
+ else return { status: "error", error: e as any };
+}
+}
+}
+
+/** user-defined events **/
+
+
+
+/** user-defined constants **/
+
+
+
+/** user-defined types **/
+
+export type WindowTarget = { window_class: string | null; exe_name: string | null }
+
+/** tauri-specta globals **/
+
+import {
+ invoke as TAURI_INVOKE,
+ Channel as TAURI_CHANNEL,
+} from "@tauri-apps/api/core";
+import * as TAURI_API_EVENT from "@tauri-apps/api/event";
+import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
+
+type __EventObj__ = {
+ listen: (
+ cb: TAURI_API_EVENT.EventCallback,
+ ) => ReturnType>;
+ once: (
+ cb: TAURI_API_EVENT.EventCallback,
+ ) => ReturnType>;
+ emit: null extends T
+ ? (payload?: T) => ReturnType
+ : (payload: T) => ReturnType;
+};
+
+export type Result =
+ | { status: "ok"; data: T }
+ | { status: "error"; error: E };
+
+function __makeEvents__>(
+ mappings: Record,
+) {
+ return new Proxy(
+ {} as unknown as {
+ [K in keyof T]: __EventObj__ & {
+ (handle: __WebviewWindow__): __EventObj__;
+ };
+ },
+ {
+ get: (_, event) => {
+ const name = mappings[event as keyof T];
+
+ return new Proxy((() => {}) as any, {
+ apply: (_, __, [window]: [__WebviewWindow__]) => ({
+ listen: (arg: any) => window.listen(name, arg),
+ once: (arg: any) => window.once(name, arg),
+ emit: (arg: any) => window.emit(name, arg),
+ }),
+ get: (_, command: keyof __EventObj__) => {
+ switch (command) {
+ case "listen":
+ return (arg: any) => TAURI_API_EVENT.listen(name, arg);
+ case "once":
+ return (arg: any) => TAURI_API_EVENT.once(name, arg);
+ case "emit":
+ return (arg: any) => TAURI_API_EVENT.emit(name, arg);
+ }
+ },
+ });
+ },
+ },
+ );
+}
diff --git a/src/app/hooks/useAuthenticatedMediaUrl.ts b/src/app/hooks/useAuthenticatedMediaUrl.ts
new file mode 100644
index 000000000..6d378c6de
--- /dev/null
+++ b/src/app/hooks/useAuthenticatedMediaUrl.ts
@@ -0,0 +1,90 @@
+import { useState, useEffect } from 'react';
+import { hasServiceWorker } from '$utils/platform';
+import { authenticatedMediaFetch } from '$utils/matrix';
+import { getFromMediaCache, putInMediaCache } from '$utils/mediaCache';
+
+const blobCache = new Map();
+const inflightRequests = new Map>();
+
+/**
+ * On platforms without a service worker (e.g. Android WebViews), fetches
+ * media with the access token and returns a blob: URL.
+ * When a SW is available the original URL is returned unchanged.
+ */
+export function useAuthenticatedMediaUrl(
+ url: string | undefined,
+ accessToken: string | null | undefined
+): string | undefined {
+ const needsBlob = !hasServiceWorker();
+
+ const [blobUrl, setBlobUrl] = useState(() => {
+ if (!url) return undefined;
+ if (!needsBlob) return url;
+ return blobCache.get(url) ?? undefined;
+ });
+
+ const [sourceUrl, setSourceUrl] = useState(url);
+ if (url !== sourceUrl) {
+ setSourceUrl(url);
+ if (!url) {
+ setBlobUrl(undefined);
+ } else if (!needsBlob) {
+ setBlobUrl(url);
+ } else {
+ setBlobUrl(blobCache.get(url) ?? undefined);
+ }
+ }
+
+ useEffect(() => {
+ if (!url || !needsBlob || blobCache.has(url)) return undefined;
+
+ let cancelled = false;
+
+ const fetchBlob = async () => {
+ let promise = inflightRequests.get(url);
+ if (!promise) {
+ promise = (async () => {
+ const cachedBlob = await getFromMediaCache(url);
+ if (cachedBlob) {
+ const objectUrl = URL.createObjectURL(cachedBlob);
+ blobCache.set(url, objectUrl);
+ return objectUrl;
+ }
+
+ const res = await authenticatedMediaFetch(url, accessToken);
+ if (!res.ok) {
+ inflightRequests.delete(url);
+ return undefined;
+ }
+ const blob = await res.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ blobCache.set(url, objectUrl);
+
+ putInMediaCache(url, blob);
+
+ return objectUrl;
+ })();
+ inflightRequests.set(url, promise);
+ }
+
+ try {
+ const result = await promise;
+ if (!cancelled && result) setBlobUrl(result);
+ } catch {
+ // Silently fail — the image will show as broken.
+ } finally {
+ inflightRequests.delete(url);
+ }
+ };
+
+ fetchBlob();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [url, needsBlob, accessToken]);
+
+ if (!url) return undefined;
+ if (!needsBlob) return url;
+ return blobUrl;
+}
diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts
index a3d5493cf..e574c03c6 100644
--- a/src/app/hooks/useBlobCache.ts
+++ b/src/app/hooks/useBlobCache.ts
@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
+import { authenticatedMediaFetch } from '$utils/matrix';
+import { getFromMediaCache, putInMediaCache } from '$utils/mediaCache';
const imageBlobCache = new Map();
const inflightRequests = new Map>();
-export function useBlobCache(url?: string): string | undefined {
+export function useBlobCache(url?: string, accessToken?: string | null): string | undefined {
const [cacheState, setCacheState] = useState<{ sourceUrl?: string; blobUrl?: string }>({
sourceUrl: url,
blobUrl: url ? imageBlobCache.get(url) : undefined,
@@ -29,18 +31,22 @@ export function useBlobCache(url?: string): string | undefined {
}
const requestPromise = (async () => {
- try {
- const res = await fetch(url, { mode: 'cors' });
- if (!res.ok) throw new Error();
- const blob = await res.blob();
- const objectUrl = URL.createObjectURL(blob);
-
+ const cachedBlob = await getFromMediaCache(url);
+ if (cachedBlob) {
+ const objectUrl = URL.createObjectURL(cachedBlob);
imageBlobCache.set(url, objectUrl);
return objectUrl;
- } catch (e) {
- inflightRequests.delete(url);
- throw e;
}
+
+ const res = await authenticatedMediaFetch(url, accessToken);
+ if (!res.ok) throw new Error();
+ const blob = await res.blob();
+ const objectUrl = URL.createObjectURL(blob);
+
+ imageBlobCache.set(url, objectUrl);
+ putInMediaCache(url, blob);
+
+ return objectUrl;
})();
inflightRequests.set(url, requestPromise);
@@ -51,7 +57,7 @@ export function useBlobCache(url?: string): string | undefined {
setCacheState({ sourceUrl: url, blobUrl: finalBlobUrl });
}
} catch {
- // silency fail... mrow
+ // silently fail
} finally {
inflightRequests.delete(url);
}
@@ -62,7 +68,7 @@ export function useBlobCache(url?: string): string | undefined {
return () => {
isMounted = false;
};
- }, [url]);
+ }, [url, accessToken]);
return cacheState.blobUrl || url;
}
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index 4d6cec62a..c5a2f61b9 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -18,6 +18,8 @@ export type ClientConfig = {
pushNotifyUrl?: string;
vapidPublicKey?: string;
webPushAppID?: string;
+ unifiedPushAppID?: string;
+ unifiedPushGatewayUrl?: string;
};
slidingSync?: {
diff --git a/src/app/hooks/useMediaSrc.ts b/src/app/hooks/useMediaSrc.ts
new file mode 100644
index 000000000..ab1e71eff
--- /dev/null
+++ b/src/app/hooks/useMediaSrc.ts
@@ -0,0 +1,16 @@
+import { hasServiceWorker } from '$utils/platform';
+import { useMatrixClient } from './useMatrixClient';
+import { useAuthenticatedMediaUrl } from './useAuthenticatedMediaUrl';
+
+/** Wraps a media URL for ` ` / `` src. Returns a blob: URL on platforms without a SW. */
+export function useMediaSrc(url: string | undefined): string | undefined {
+ const mx = useMatrixClient();
+ return useAuthenticatedMediaUrl(url, mx.getAccessToken());
+}
+
+/** Returns the access token for media downloads, or `undefined` when a SW handles auth. */
+export function useMediaDownloadToken(): string | null | undefined {
+ const mx = useMatrixClient();
+ if (hasServiceWorker()) return undefined;
+ return mx.getAccessToken();
+}
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
index bfe51f2a9..69db8f5f9 100644
--- a/src/app/pages/App.tsx
+++ b/src/app/pages/App.tsx
@@ -3,8 +3,12 @@ import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProv
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import { isTauri } from '@tauri-apps/api/core';
+import { type as osType } from '@tauri-apps/plugin-os';
import { ClientConfigLoader } from '$components/ClientConfigLoader';
+import { TauriFrontendReady } from '$components/tauri/TauriFrontendReady';
+import { WindowsTitleBar } from '$components/tauri/WindowsTitleBar';
import { ClientConfigProvider } from '$hooks/useClientConfig';
import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize';
import { useCompositionEndTracking } from '$hooks/useComposingCheck';
@@ -17,6 +21,7 @@ const queryClient = new QueryClient();
function App() {
const screenSize = useScreenSize();
useCompositionEndTracking();
+ const useCustomWindowsTitleBar = isTauri() && osType() === 'windows';
const portalContainer = document.getElementById('portalContainer') ?? undefined;
@@ -36,7 +41,21 @@ function App() {
-
+
+ {useCustomWindowsTitleBar && }
+
+
+
diff --git a/src/app/pages/LandingRouter.tsx b/src/app/pages/LandingRouter.tsx
new file mode 100644
index 000000000..9ab8eff63
--- /dev/null
+++ b/src/app/pages/LandingRouter.tsx
@@ -0,0 +1,24 @@
+import { createBrowserRouter, RouterProvider } from 'react-router-dom';
+import { UnAuthRouteThemeManager } from '$pages/ThemeManager';
+import { SSOCallback } from './auth/SSOCallback';
+import { SSO_CALLBACK_PATH } from './paths';
+
+const router = createBrowserRouter([
+ {
+ path: SSO_CALLBACK_PATH,
+ element: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ path: '/lp/*',
+ element: Page not found
,
+ },
+]);
+
+export function LandingRouter() {
+ return ;
+}
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index f14567f7d..8f8578c72 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -75,6 +75,7 @@ import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
import { ToRoomEvent } from './client/ToRoomEvent';
import { CallStatusRenderer } from './CallStatusRenderer';
+import { TauriDeepLinkBridge } from './TauriDeepLinkBridge';
/**
* Returns true if there is at least one stored session.
@@ -100,249 +101,258 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
const routes = createRoutesFromElements(
{
- if (hasStoredSession()) return redirect(getHomePath());
- const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
- if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
- return redirect(getLoginPath());
- }}
- />
- {
- // Allow reaching the login page with ?addAccount=1 even when already logged in
- const url = new URL(request.url);
- if (url.searchParams.get('addAccount') === '1') return null;
- if (hasStoredSession()) return redirect(getHomePath());
- return null;
- }}
element={
<>
-
-
+
+
>
}
>
- } />
- } />
- } />
-
-
- {
- const session = getFirstSession();
- if (!session) {
- const afterLoginPath = getAppPathFromHref(
- getOriginBaseUrl(hashRouter),
- window.location.href
- );
+ {
+ if (hasStoredSession()) return redirect(getHomePath());
+ const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath());
- }
- return null;
- }}
- element={
-
- {/* HandleNotificationClick must live outside ClientRoot's loading gate so
- SW notification-click postMessages are never dropped during client
- reloads (e.g., account switches). It only needs navigate + Jotai atoms. */}
-
-
-
-
-
-
-
-
-
-
-
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Screen reader live region — populated by announce() in utils/announce.ts */}
-
-
-
-
-
-
-
-
-
- }
- >
+ }}
+ />
{
+ // Allow reaching the login page with ?addAccount=1 even when already logged in
+ const url = new URL(request.url);
+ if (url.searchParams.get('addAccount') === '1') return null;
+ if (hasStoredSession()) return redirect(getHomePath());
+ return null;
+ }}
element={
-
-
-
- }
- >
-
-
+ <>
+
+
+ >
}
>
- {mobile ? null : } />}
- } />
- join
} />
- } />
-
-
-
- }
- />
+ } />
+ } />
+ } />
+
{
+ const session = getFirstSession();
+ if (!session) {
+ const afterLoginPath = getAppPathFromHref(
+ getOriginBaseUrl(hashRouter),
+ window.location.href
+ );
+ if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
+ return redirect(getLoginPath());
+ }
+ return null;
+ }}
element={
-
-
-
- }
- >
-
-
+
+ {/* HandleNotificationClick must live outside ClientRoot's loading gate so
+ SW notification-click postMessages are never dropped during client
+ reloads (e.g., account switches). It only needs navigate + Jotai atoms. */}
+
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Screen reader live region — populated by announce() in utils/announce.ts */}
+
+
+
+
+
+
+
+
+
}
>
- {mobile ? null : } />}
- } />
-
-
- }
- />
-
-
-
+
+
}
>
-
- }
- >
- {mobile ? null : (
+ }
+ >
+ {mobile ? null : } />}
+ } />
+ join} />
+ } />
{
- const encodedSpaceIdOrAlias = params.spaceIdOrAlias;
- const decodedSpaceIdOrAlias =
- encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias);
-
- if (decodedSpaceIdOrAlias) {
- return redirect(getSpaceLobbyPath(decodedSpaceIdOrAlias));
- }
- return null;
- }}
- element={ }
+ path={ROOM_PATH_SEGMENT}
+ element={
+
+
+
+ }
/>
- )}
- } />
- } />
+
-
-
+
+
+
+ }
+ >
+
+
}
- />
-
-
-
-
- }
- >
-
-
- }
- >
- {mobile ? null : (
+ >
+ {mobile ? null : } />}
+ } />
redirect(getExploreFeaturedPath())}
- element={ }
- />
- )}
- } />
- } />
-
- } />
-
-
-
+ path={ROOM_PATH_SEGMENT}
+ element={
+
+
+
}
- >
-
-
- }
- >
- {mobile ? null : (
+ />
+
+
+
+
+
+ }
+ >
+
+
+
+ }
+ >
+ {mobile ? null : (
+ {
+ const encodedSpaceIdOrAlias = params.spaceIdOrAlias;
+ const decodedSpaceIdOrAlias =
+ encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias);
+
+ if (decodedSpaceIdOrAlias) {
+ return redirect(getSpaceLobbyPath(decodedSpaceIdOrAlias));
+ }
+ return null;
+ }}
+ element={ }
+ />
+ )}
+ } />
+ } />
redirect(getInboxNotificationsPath())}
- element={ }
+ path={ROOM_PATH_SEGMENT}
+ element={
+
+
+
+ }
/>
- )}
- } />
- } />
+
+
+
+
+ }
+ >
+
+
+ }
+ >
+ {mobile ? null : (
+ redirect(getExploreFeaturedPath())}
+ element={ }
+ />
+ )}
+ } />
+ } />
+
+ } />
+
+
+
+ }
+ >
+
+
+ }
+ >
+ {mobile ? null : (
+ redirect(getInboxNotificationsPath())}
+ element={ }
+ />
+ )}
+ } />
+ } />
+
+ } />
- } />
Page not found} />
diff --git a/src/app/pages/TauriDeepLinkBridge.tsx b/src/app/pages/TauriDeepLinkBridge.tsx
new file mode 100644
index 000000000..2a91283a4
--- /dev/null
+++ b/src/app/pages/TauriDeepLinkBridge.tsx
@@ -0,0 +1,61 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { isTauri } from '@tauri-apps/api/core';
+import { createLogger } from '$utils/debug';
+import { parseTauriSsoCallback } from '$pages/auth/SSOTauri';
+import { getLoginPath, withSearchParam } from './pathUtils';
+
+const log = createLogger('TauriDeepLinkBridge');
+
+const mapDeepLinkToLoginPath = (rawUrl: string): string | undefined => {
+ const callback = parseTauriSsoCallback(rawUrl);
+ if (!callback) return undefined;
+
+ return withSearchParam(getLoginPath(callback.server), { loginToken: callback.loginToken });
+};
+
+export function TauriDeepLinkBridge() {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (!isTauri()) return undefined;
+
+ let mounted = true;
+ let unlisten: (() => void) | undefined;
+
+ const applyUrls = (urls: string[]) => {
+ const loginPath = urls.map(mapDeepLinkToLoginPath).find((path): path is string => !!path);
+ if (loginPath) {
+ navigate(loginPath, { replace: true });
+ }
+ };
+
+ (async () => {
+ try {
+ const { getCurrent, onOpenUrl } = await import('@tauri-apps/plugin-deep-link');
+
+ const current = await getCurrent();
+ applyUrls(current ?? []);
+
+ const removeListener = await onOpenUrl((urls) => {
+ applyUrls(urls);
+ });
+
+ if (mounted) {
+ unlisten = removeListener;
+ } else {
+ removeListener();
+ }
+ } catch (error) {
+ log.warn('Failed to initialize deep link bridge:', error);
+ }
+ })();
+
+ return () => {
+ mounted = false;
+ unlisten?.();
+ };
+ }, [navigate]);
+
+ return null;
+}
diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx
index edc913a68..8e67761df 100644
--- a/src/app/pages/auth/AuthLayout.tsx
+++ b/src/app/pages/auth/AuthLayout.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect } from 'react';
-import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
+import { Box, Spinner, Text, color } from 'folds';
import {
Outlet,
generatePath,
@@ -9,12 +9,9 @@ import {
useParams,
useSearchParams,
} from 'react-router-dom';
-import classNames from 'classnames';
-import * as PatternsCss from '$styles/Patterns.css';
import { clientAllowedServer, clientDefaultServer, useClientConfig } from '$hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
-import CinnySVG from '$public/favicon.png';
import { SpecVersionsLoader } from '$components/SpecVersionsLoader';
import { SpecVersionsProvider } from '$hooks/useSpecVersions';
import { AutoDiscoveryInfoProvider } from '$hooks/useAutoDiscoveryInfo';
@@ -24,8 +21,7 @@ import { AuthServerProvider } from '$hooks/useAuthServer';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '$pages/paths';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { ServerPicker } from './ServerPicker';
-import * as css from './styles.css';
-import { AuthFooter } from './AuthFooter';
+import { AuthShell } from './AuthShell';
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) {
@@ -124,92 +120,66 @@ export function AuthLayout() {
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
return (
-
-
-
-
-
-
- Sable
-
- {isAddingAccount && (
-
- Adding account
-
- )}
-
-
-
-
- Homeserver
-
-
-
- {discoveryState.status === AsyncStatus.Loading && (
-
- )}
- {discoveryState.status === AsyncStatus.Error && (
-
- )}
- {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
-
- )}
- {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
-
- )}
- {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
-
-
- (
-
- )}
+
+
+
+ Homeserver
+
+
+
+ {discoveryState.status === AsyncStatus.Loading && (
+
+ )}
+ {discoveryState.status === AsyncStatus.Error && (
+
+ )}
+ {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
+
+ )}
+ {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
+
+ )}
+ {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
+
+
+ (
+
+ )}
+ error={() => (
+
+ )}
+ >
+ {(specVersions) => (
+
+ }
error={() => (
-
+
)}
>
- {(specVersions) => (
-
- (
-
- )}
- error={() => (
-
- )}
- >
- {(authFlows) => (
-
-
-
- )}
-
-
+ {(authFlows) => (
+
+
+
)}
-
-
-
- )}
-
-
-
-
-
+
+
+ )}
+
+
+
+ )}
+
);
}
diff --git a/src/app/pages/auth/AuthShell.tsx b/src/app/pages/auth/AuthShell.tsx
new file mode 100644
index 000000000..6aef5907c
--- /dev/null
+++ b/src/app/pages/auth/AuthShell.tsx
@@ -0,0 +1,44 @@
+import { Box, Header, Scroll, Text } from 'folds';
+import classNames from 'classnames';
+import * as PatternsCss from '$styles/Patterns.css';
+import CinnySVG from '$public/favicon.png';
+import { AuthFooter } from './AuthFooter';
+import * as css from './styles.css';
+
+type AuthShellProps = {
+ children: React.ReactNode;
+ isAddingAccount?: boolean;
+};
+
+export function AuthShell({ children, isAddingAccount }: AuthShellProps) {
+ return (
+
+
+
+
+
+
+ Sable
+
+ {isAddingAccount && (
+
+ Adding account
+
+ )}
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/src/app/pages/auth/SSOCallback.tsx b/src/app/pages/auth/SSOCallback.tsx
new file mode 100644
index 000000000..22c676fba
--- /dev/null
+++ b/src/app/pages/auth/SSOCallback.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useState } from 'react';
+import { Box, Spinner, Text, color } from 'folds';
+import { AuthShell } from './AuthShell';
+
+type SSOCallbackState = 'redirecting' | 'waiting' | 'done' | 'error';
+
+export function SSOCallback() {
+ const [state, setState] = useState('waiting');
+
+ useEffect(() => {
+ const { search } = window.location;
+ const params = new URLSearchParams(search);
+
+ if (!params.has('loginToken')) {
+ setState('error');
+ return undefined;
+ }
+
+ window.location.href = `sable://login${search}`;
+
+ const loadedAt = Date.now();
+ const handleHide = () => {
+ if (Date.now() - loadedAt < 500) return;
+ setState('done');
+ };
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'hidden') handleHide();
+ };
+ window.addEventListener('blur', handleHide);
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ return () => {
+ window.removeEventListener('blur', handleHide);
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ };
+ }, []);
+
+ return (
+
+
+
+ Logging In
+
+
+ {state === 'redirecting' && (
+
+
+
+
+ Opening Sable...
+
+
+
+ )}
+
+ {state === 'waiting' && (
+
+
+
+
+ Waiting for you to approve the popup...
+
+
+
+ Your browser should be showing a confirmation dialog asking to open Sable. Click{' '}
+ Open or Allow to continue logging in.
+
+
+ If nothing appeared,{' '}
+
+ click here to try again
+
+ .
+
+
+ )}
+
+ {state === 'done' && (
+
+
+ ✓ Sable opened successfully.
+
+
+ You are now logged in. You can close this tab.
+
+
+ )}
+
+ {state === 'error' && (
+
+
+ Something went wrong — no login token was found in the URL.
+
+
+ Please return to Sable and try logging in again.
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/pages/auth/SSOLogin.tsx b/src/app/pages/auth/SSOLogin.tsx
index eabb23602..006073db7 100644
--- a/src/app/pages/auth/SSOLogin.tsx
+++ b/src/app/pages/auth/SSOLogin.tsx
@@ -1,7 +1,10 @@
import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
import { IIdentityProvider, SSOAction, createClient } from '$types/matrix-sdk';
-import { useMemo } from 'react';
+import { MouseEvent, useMemo } from 'react';
+import { isTauri } from '@tauri-apps/api/core';
+import { openUrl } from '@tauri-apps/plugin-opener';
import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo';
+import { type as osType } from '@tauri-apps/plugin-os';
type SSOLoginProps = {
providers?: IIdentityProvider[];
@@ -25,6 +28,14 @@ export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SS
const renderAsIcons = withoutIcon ? false : saveScreenSpace && providers && providers.length > 2;
+ const openSso = async (event: MouseEvent, url: string) => {
+ if (!isTauri()) return;
+ event.preventDefault();
+ const os = osType();
+ const urlProgram = os === 'ios' || os === 'android' ? 'inAppBrowser' : undefined;
+ await openUrl(url, urlProgram);
+ };
+
return (
{providers ? (
@@ -41,6 +52,7 @@ export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SS
key={id}
as="a"
href={getSSOIdUrl(id)}
+ onClick={(event) => openSso(event, getSSOIdUrl(id))}
aria-label={buttonTitle}
size="300"
radii="300"
@@ -56,6 +68,7 @@ export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SS
key={id}
as="a"
href={getSSOIdUrl(id)}
+ onClick={(event) => openSso(event, getSSOIdUrl(id))}
size="500"
variant="Secondary"
fill="Soft"
@@ -79,6 +92,7 @@ export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SS
style={{ width: '100%' }}
as="a"
href={getSSOIdUrl()}
+ onClick={(event) => openSso(event, getSSOIdUrl())}
size="500"
variant="Secondary"
fill="Soft"
diff --git a/src/app/pages/auth/SSOTauri.ts b/src/app/pages/auth/SSOTauri.ts
new file mode 100644
index 000000000..e045ccb53
--- /dev/null
+++ b/src/app/pages/auth/SSOTauri.ts
@@ -0,0 +1,51 @@
+import { SSO_CALLBACK_PATH } from '$pages/paths';
+import { type as osType } from '@tauri-apps/plugin-os';
+
+const TAURI_SSO_PROTOCOL = 'sable:';
+const TAURI_SSO_HOST = 'login';
+
+const getAppBaseUrl = (): string => {
+ const os = osType();
+ if (os === 'ios' || os === 'android') {
+ return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`;
+ }
+
+ if (import.meta.env.DEV) {
+ return window.location.origin;
+ }
+
+ return 'https://app.sable.moe';
+};
+
+type TauriSsoCallback = {
+ loginToken: string;
+ server?: string;
+};
+
+export const buildTauriSsoRedirectUrl = (server?: string): string => {
+ const redirectUrl = new URL(SSO_CALLBACK_PATH, getAppBaseUrl());
+
+ if (server) {
+ redirectUrl.searchParams.set('server', server);
+ }
+
+ return redirectUrl.toString();
+};
+
+export const parseTauriSsoCallback = (rawUrl: string): TauriSsoCallback | undefined => {
+ try {
+ const callbackUrl = new URL(rawUrl);
+ if (callbackUrl.protocol !== TAURI_SSO_PROTOCOL) return undefined;
+ if (callbackUrl.hostname !== TAURI_SSO_HOST) return undefined;
+
+ const loginToken = callbackUrl.searchParams.get('loginToken');
+ if (!loginToken) return undefined;
+
+ return {
+ loginToken,
+ server: callbackUrl.searchParams.get('server') ?? undefined,
+ };
+ } catch {
+ return undefined;
+ }
+};
diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx
index fe895332b..dd69c156b 100644
--- a/src/app/pages/auth/login/Login.tsx
+++ b/src/app/pages/auth/login/Login.tsx
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { Box, Text, color } from 'folds';
import { Link, useSearchParams } from 'react-router-dom';
import { SSOAction } from '$types/matrix-sdk';
+import { isTauri } from '@tauri-apps/api/core';
import { useAuthFlows } from '$hooks/useAuthFlows';
import { useAuthServer } from '$hooks/useAuthServer';
import { useParsedLoginFlows } from '$hooks/useParsedLoginFlows';
@@ -11,6 +12,7 @@ import { LoginPathSearchParams } from '$pages/paths';
import { useClientConfig } from '$hooks/useClientConfig';
import { SSOLogin } from '$pages/auth/SSOLogin';
import { OrDivider } from '$pages/auth/OrDivider';
+import { buildTauriSsoRedirectUrl } from '$pages/auth/SSOTauri';
import { PasswordLoginForm } from './PasswordLoginForm';
import { TokenLogin } from './TokenLogin';
@@ -41,7 +43,8 @@ export function Login() {
const { loginFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
const loginSearchParams = useLoginSearchParams(searchParams);
- const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
+ const webSsoRedirectUrl = usePathWithOrigin(getLoginPath(server));
+ const ssoRedirectUrl = isTauri() ? buildTauriSsoRedirectUrl(server) : webSsoRedirectUrl;
const loginTokenForHashRouter = getLoginTokenSearchParam();
const absoluteLoginPath = usePathWithOrigin(getLoginPath(server));
diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx
index 11bee338e..82a83dbad 100644
--- a/src/app/pages/auth/register/Register.tsx
+++ b/src/app/pages/auth/register/Register.tsx
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { Box, Text, color } from 'folds';
import { Link, useSearchParams } from 'react-router-dom';
import { SSOAction } from '$types/matrix-sdk';
+import { isTauri } from '@tauri-apps/api/core';
import { useAuthServer } from '$hooks/useAuthServer';
import { RegisterFlowStatus, useAuthFlows } from '$hooks/useAuthFlows';
import { useParsedLoginFlows } from '$hooks/useParsedLoginFlows';
@@ -11,6 +12,7 @@ import { usePathWithOrigin } from '$hooks/usePathWithOrigin';
import { RegisterPathSearchParams } from '$pages/paths';
import { SSOLogin } from '$pages/auth/SSOLogin';
import { OrDivider } from '$pages/auth/OrDivider';
+import { buildTauriSsoRedirectUrl } from '$pages/auth/SSOTauri';
import { PasswordRegisterForm, SUPPORTED_REGISTER_STAGES } from './PasswordRegisterForm';
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
@@ -31,7 +33,8 @@ export function Register() {
const { sso } = useParsedLoginFlows(loginFlows.flows);
// redirect to /login because only that path handle m.login.token
- const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
+ const webSsoRedirectUrl = usePathWithOrigin(getLoginPath(server));
+ const ssoRedirectUrl = isTauri() ? buildTauriSsoRedirectUrl(server) : webSsoRedirectUrl;
return (
diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx
index 9ff5ab57f..290ea6a4f 100644
--- a/src/app/pages/client/BackgroundNotifications.tsx
+++ b/src/app/pages/client/BackgroundNotifications.tsx
@@ -11,6 +11,7 @@ import {
PushProcessor,
} from '$types/matrix-sdk';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { isTauri } from '@tauri-apps/api/core';
import {
sessionsAtom,
activeSessionIdAtom,
@@ -101,7 +102,6 @@ export function BackgroundNotifications() {
const nicknames = useAtomValue(nicknamesAtom);
const nicknamesRef = useRef(nicknames);
nicknamesRef.current = nicknames;
- // Refs so handleTimeline callbacks always read current settings without stale closures
const showNotificationsRef = useRef(showNotifications);
showNotificationsRef.current = showNotifications;
const notificationSoundRef = useRef(notificationSound);
@@ -115,7 +115,6 @@ export function BackgroundNotifications() {
const setPending = useSetAtom(pendingNotificationAtom);
const setBackgroundUnreads = useSetAtom(backgroundUnreadCountsAtom);
const setInAppBanner = useSetAtom(inAppBannerAtom);
- // Stable setter refs so async handleTimeline closures never go stale.
const setBackgroundUnreadsRef = useRef(setBackgroundUnreads);
setBackgroundUnreadsRef.current = setBackgroundUnreads;
const setInAppBannerRef = useRef(setInAppBanner);
@@ -126,22 +125,13 @@ export function BackgroundNotifications() {
);
interface NotifyOptions {
- /** Title shown in the notification banner. */
title: string;
- /** Body text. */
body?: string;
- /** URL to an icon (browser) – ignored on native where the app icon is used. */
icon?: string;
- /** Badge icon URL shown by supported platforms. */
badge?: string;
- /** If `true` the notification plays no sound. */
silent?: boolean;
- /** Arbitrary payload attached to the notification.
- * Must include { type, room_id, event_id, user_id } so the SW notificationclick
- * handler can route the tap through HandleNotificationClick for account switching. */
+ /** Must include { type, room_id, event_id, user_id } for SW notificationclick routing. */
data?: unknown;
- /** Optional callback invoked when the user clicks the notification (window.Notification
- * fallback path only; the SW path routes via its own notificationclick handler). */
onClick?: () => void;
}
@@ -154,11 +144,8 @@ export function BackgroundNotifications() {
const activeIds = new Set(inactiveSessions.map((s) => s.userId));
async function sendNotification(opts: NotifyOptions): Promise {
- // Prefer ServiceWorkerRegistration.showNotification so that taps are handled
- // by the SW notificationclick event. This routes through HandleNotificationClick
- // (postMessage path) which does the account switch + deep link reliably on all
- // platforms including iOS where window.Notification onclick is not fired.
- if ('serviceWorker' in navigator) {
+ // Prefer SW showNotification so taps route through the notificationclick handler.
+ if ('serviceWorker' in navigator && !isTauri()) {
try {
const reg = await navigator.serviceWorker.ready;
await reg.showNotification(opts.title, {
@@ -194,7 +181,6 @@ export function BackgroundNotifications() {
if (!activeIds.has(userId)) {
stopClient(mx);
current.delete(userId);
- // Clear the background unread badge when this session is no longer a background account.
setBackgroundUnreads((prev) => {
const next = { ...prev };
delete next[userId];
@@ -358,7 +344,6 @@ export function BackgroundNotifications() {
const loudByRule = Boolean(pushActions.tweaks?.sound);
- // Track background unread count for every notifiable event (loud or silent).
const isHighlight = Boolean(pushActions.tweaks?.highlight);
setBackgroundUnreadsRef.current((prev) => {
const cur = prev[session.userId] ?? { total: 0, highlight: 0 };
@@ -379,7 +364,6 @@ export function BackgroundNotifications() {
const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption);
notifiedEventsRef.current.add(dedupeId);
- // Cap the set so it doesn't grow unbounded
if (notifiedEventsRef.current.size > 200) {
const first = notifiedEventsRef.current.values().next().value;
if (first) notifiedEventsRef.current.delete(first);
@@ -397,7 +381,6 @@ export function BackgroundNotifications() {
showMessageContent: showMessageContentRef.current,
showEncryptedMessageContent: showEncryptedMessageContentRef.current,
}),
- // Play sound only if the push rule requests it and the user has sounds enabled.
silent: !notificationSoundRef.current || !loudByRule,
eventId,
data: {
@@ -410,7 +393,6 @@ export function BackgroundNotifications() {
const notifOnClick = () => {
window.focus();
- // Always switch to the background account – jotai ignores no-op updates
setActiveSessionId(session.userId);
setPending({ roomId: room.roomId, eventId, targetSessionId: session.userId });
};
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 5ec5d8806..43b738180 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -48,6 +48,7 @@ import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom';
import { getSlidingSyncManager } from '$client/initMatrix';
import { NotificationBanner } from '$components/notification-banner';
import { useCallSignaling } from '$hooks/useCallSignaling';
+import { isTauri } from '@tauri-apps/api/core';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -124,7 +125,7 @@ function FaviconUpdater() {
} else {
navigator.clearAppBadge();
}
- if (usePushNotifications) {
+ if (usePushNotifications && registration) {
if (total === 0) {
// All rooms read — clear every notification.
registration.getNotifications().then((notifs) => notifs.forEach((n) => n.close()));
@@ -538,7 +539,7 @@ export function HandleNotificationClick() {
const navigate = useNavigate();
useEffect(() => {
- if (!('serviceWorker' in navigator)) return undefined;
+ if (!('serviceWorker' in navigator) || isTauri()) return undefined;
const handleMessage = (ev: MessageEvent) => {
const { data } = ev;
@@ -594,7 +595,7 @@ function SyncNotificationSettingsWithServiceWorker() {
}, []);
useEffect(() => {
- if (!('serviceWorker' in navigator)) return;
+ if (!('serviceWorker' in navigator) || isTauri()) return;
// notificationSoundEnabled is intentionally excluded: push notification sound
// is governed by the push rule's tweakSound alone (OS/Sygnal handles it).
// The in-app sound setting only controls the in-page playback above.
@@ -634,6 +635,50 @@ function PresenceFeature() {
return null;
}
+function UnifiedPushManager() {
+ const mx = useMatrixClient();
+ const [useUP] = useSetting(settingsAtom, 'useUnifiedPush');
+ const [isNotificationSounds] = useSetting(settingsAtom, 'isNotificationSounds');
+ const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications');
+ const [showEncryptedMessageContent] = useSetting(
+ settingsAtom,
+ 'showMessageContentInEncryptedNotifications'
+ );
+ const [useInAppNotifications] = useSetting(settingsAtom, 'useInAppNotifications');
+
+ useEffect(() => {
+ if (!isTauri() || !useUP) return undefined;
+
+ let cleanup: (() => void) | undefined;
+
+ (async () => {
+ const { listenForUnifiedPushMessages } =
+ await import('$features/settings/notifications/UnifiedPushNotifications');
+ const listener = await listenForUnifiedPushMessages(() => ({
+ mx,
+ showMessageContent,
+ showEncryptedMessageContent,
+ notificationSoundEnabled: isNotificationSounds,
+ useInAppNotifications,
+ }));
+ cleanup = () => listener.unregister();
+ })();
+
+ return () => {
+ cleanup?.();
+ };
+ }, [
+ mx,
+ useUP,
+ isNotificationSounds,
+ showMessageContent,
+ showEncryptedMessageContent,
+ useInAppNotifications,
+ ]);
+
+ return null;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useCallSignaling();
return (
@@ -646,6 +691,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 8a9f23052..1efe8c86f 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -18,6 +18,8 @@ import FocusTrap from 'focus-trap-react';
import { useRef, MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { isTauri } from '@tauri-apps/api/core';
+import { type as osType } from '@tauri-apps/plugin-os';
import {
clearCacheAndReload,
clearLoginData,
@@ -74,6 +76,10 @@ type ClientRootOptionsProps = {
};
function ClientRootOptions({ mx, onLogout }: ClientRootOptionsProps) {
const [menuAnchor, setMenuAnchor] = useState();
+ const isWindowsTauri = isTauri() && osType() === 'windows';
+ const topOffset = isWindowsTauri
+ ? `calc(var(--tauri-titlebar-height) + ${config.space.S100})`
+ : config.space.S100;
const handleToggle: MouseEventHandler = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
@@ -87,7 +93,7 @@ function ClientRootOptions({ mx, onLogout }: ClientRootOptionsProps) {
{
+ if (import.meta.env.VITE_DEMO_SYNC_STATUS === '1') return true;
+ if (typeof window === 'undefined') return false;
+ return new URLSearchParams(window.location.search).get('demoSyncStatus') === '1';
+};
+
export function SyncStatus({ mx }: SyncStatusProps) {
const [stateData, setStateData] = useState({
current: null,
previous: undefined,
});
+ const [demoIndex, setDemoIndex] = useState(0);
+ const useDemoStatusLoop = isSyncStatusDemoEnabled();
+ const setTitlebarStatus = useSetAtom(titlebarStatusAtom);
+ const { current, previous } = stateData;
useSyncState(
mx,
- useCallback((current, previous) => {
+ useCallback((nextCurrent, nextPrevious) => {
setStateData((s) => {
- if (s.current === current && s.previous === previous) {
+ if (s.current === nextCurrent && s.previous === nextPrevious) {
return s;
}
- return { current, previous };
+ return { current: nextCurrent, previous: nextPrevious };
});
}, [])
);
- if (
- (stateData.current === SyncState.Prepared ||
- stateData.current === SyncState.Syncing ||
- stateData.current === SyncState.Catchup) &&
- stateData.previous !== SyncState.Syncing
- ) {
- return (
-
-
- Connecting...
-
-
-
- );
- }
+ useEffect(() => {
+ if (!useDemoStatusLoop) return undefined;
+ const intervalId = window.setInterval(() => {
+ setDemoIndex((index) => (index + 1) % DEMO_STATUS_SEQUENCE.length);
+ }, DEMO_STATUS_STEP_MS);
+
+ return () => {
+ window.clearInterval(intervalId);
+ };
+ }, [useDemoStatusLoop]);
+
+ const statusView = useMemo(() => {
+ if (useDemoStatusLoop) return DEMO_STATUS_SEQUENCE[demoIndex];
+ return getSyncConnectionStatusView(current, previous);
+ }, [current, demoIndex, previous, useDemoStatusLoop]);
- if (stateData.current === SyncState.Reconnecting) {
- return (
-
-
- Connection Lost! Reconnecting...
-
-
-
- );
- }
+ const useTitlebarSlot = isTauri() && osType() === 'windows';
+ useEffect(() => {
+ if (!useTitlebarSlot) return undefined;
+ setTitlebarStatus(statusView);
+ return () => {
+ setTitlebarStatus(null);
+ };
+ }, [statusView, setTitlebarStatus, useTitlebarSlot]);
- if (stateData.current === SyncState.Error) {
- return (
-
-
- Connection Lost!
-
-
-
- );
- }
+ if (useTitlebarSlot) return null;
- return null;
+ return ;
}
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
index 82f8c6dd2..ef404874c 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -94,3 +94,5 @@ export const TO_ROOM_EVENT_PATH = `${TO_PATH}/:user_id/:room_id/:event_id?`;
export const SPACE_SETTINGS_PATH = '/space-settings/';
export const ROOM_SETTINGS_PATH = '/room-settings/';
+
+export const SSO_CALLBACK_PATH = '/lp/sso-callback';
diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts
index a2cbc8ddc..94d0cfefb 100644
--- a/src/app/plugins/call/CallWidgetDriver.ts
+++ b/src/app/plugins/call/CallWidgetDriver.ts
@@ -26,6 +26,7 @@ import {
} from 'matrix-js-sdk';
import { getCallCapabilities } from './utils';
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
+import { hasServiceWorker } from '../../utils/platform';
export class CallWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set;
@@ -326,7 +327,10 @@ export class CallWidgetDriver extends WidgetDriver {
if (!httpUrl) {
throw new Error('Call widget failed to download file! No http url!');
}
- const blob = await downloadMedia(httpUrl);
+ const blob = await downloadMedia(
+ httpUrl,
+ hasServiceWorker() ? undefined : this.mx.getAccessToken()
+ );
return { file: blob };
}
diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx
index 12a869263..afe174780 100644
--- a/src/app/plugins/react-custom-html-parser.tsx
+++ b/src/app/plugins/react-custom-html-parser.tsx
@@ -36,6 +36,7 @@ import { findAndReplace } from '$utils/findAndReplace';
import { onEnterOrSpace } from '$utils/keyboard';
import { copyToClipboard } from '$utils/dom';
import { useTimeoutToggle } from '$hooks/useTimeoutToggle';
+import { useMediaSrc } from '$hooks/useMediaSrc';
import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze';
import {
parseMatrixToRoom,
@@ -325,11 +326,13 @@ export function CodeBlock({
*/
function FallbackImg({
fallback,
+ src,
...props
}: ComponentPropsWithoutRef<'img'> & { fallback: ReactNode }) {
const [failed, setFailed] = useState(false);
+ const resolvedSrc = useMediaSrc(src);
if (failed) return <>{fallback}>;
- return setFailed(true)} />;
+ return setFailed(true)} />;
}
export const getReactCustomHtmlParser = (
diff --git a/src/app/state/serviceWorkerRegistration.ts b/src/app/state/serviceWorkerRegistration.ts
index 66c0dda5c..fc062cdd5 100644
--- a/src/app/state/serviceWorkerRegistration.ts
+++ b/src/app/state/serviceWorkerRegistration.ts
@@ -1,3 +1,7 @@
import { atom } from 'jotai';
+import { hasServiceWorker } from '$utils/platform';
-export const registrationAtom = atom(async () => navigator.serviceWorker.ready);
+export const registrationAtom = atom(async () => {
+ if (!hasServiceWorker()) return null;
+ return navigator.serviceWorker.ready;
+});
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index ab4d6ef18..01ccfb18e 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -52,6 +52,7 @@ export interface Settings {
allowPipVideos: boolean;
usePushNotifications: boolean;
+ useUnifiedPush: boolean;
useInAppNotifications: boolean;
useSystemNotifications: boolean;
isNotificationSounds: boolean;
@@ -96,6 +97,8 @@ export interface Settings {
// furry stuff
renderAnimals: boolean;
+
+ closeToTray: boolean;
}
const defaultSettings: Settings = {
@@ -130,6 +133,7 @@ const defaultSettings: Settings = {
// In-app pill banner: default on for mobile (primary foreground alert), opt-in on desktop.
// System (OS) notifications: desktop-only; hidden and disabled on mobile.
usePushNotifications: mobileOrTablet(),
+ useUnifiedPush: false,
useInAppNotifications: mobileOrTablet(),
useSystemNotifications: !mobileOrTablet(),
isNotificationSounds: true,
@@ -172,6 +176,8 @@ const defaultSettings: Settings = {
// furry stuff
renderAnimals: true,
+
+ closeToTray: true,
};
export const getSettings = () => {
diff --git a/src/app/state/titlebarStatus.ts b/src/app/state/titlebarStatus.ts
new file mode 100644
index 000000000..c5b6bf548
--- /dev/null
+++ b/src/app/state/titlebarStatus.ts
@@ -0,0 +1,9 @@
+import { atom } from 'jotai';
+
+export type TitlebarStatusVariant = 'Success' | 'Warning' | 'Critical';
+export type TitlebarStatusView = {
+ text: string;
+ variant: TitlebarStatusVariant;
+};
+
+export const titlebarStatusAtom = atom(null);
diff --git a/src/app/state/unifiedPushEndpoint.ts b/src/app/state/unifiedPushEndpoint.ts
new file mode 100644
index 000000000..5defe38be
--- /dev/null
+++ b/src/app/state/unifiedPushEndpoint.ts
@@ -0,0 +1,28 @@
+import { atom } from 'jotai';
+import {
+ atomWithLocalStorage,
+ getLocalStorageItem,
+ setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const UP_ENDPOINT_KEY = 'unifiedPushEndpoint';
+
+type UnifiedPushState = {
+ endpoint: string;
+ instance: string;
+} | null;
+
+const baseAtom = atomWithLocalStorage(
+ UP_ENDPOINT_KEY,
+ (key) => getLocalStorageItem(key, null),
+ (key, value) => {
+ setLocalStorageItem(key, value);
+ }
+);
+
+export const unifiedPushEndpointAtom = atom(
+ (get) => get(baseAtom),
+ (_get, set, value: UnifiedPushState) => {
+ set(baseAtom, value);
+ }
+);
diff --git a/src/app/styles/overrides/TauriDesktop.css b/src/app/styles/overrides/TauriDesktop.css
new file mode 100644
index 000000000..f09f856e0
--- /dev/null
+++ b/src/app/styles/overrides/TauriDesktop.css
@@ -0,0 +1,145 @@
+:root {
+ --tauri-titlebar-height: 32px;
+ --tauri-titlebar-icon-size: 12px;
+}
+
+.tauri-titlebar {
+ height: var(--tauri-titlebar-height);
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: stretch;
+ position: relative;
+ background: var(--sable-bg-container);
+ border-bottom: 1px solid var(--sable-bg-container-line);
+ user-select: none;
+ flex-shrink: 0;
+}
+
+.tauri-titlebar [data-tauri-drag-region] {
+ app-region: drag;
+ -webkit-app-region: drag;
+}
+
+.tauri-titlebar__drag {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ padding-inline: 12px;
+}
+
+.tauri-titlebar__title {
+ color: var(--sable-bg-on-container);
+ font-size: 12px;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ text-transform: lowercase;
+}
+
+.tauri-titlebar__controls {
+ display: flex;
+ app-region: no-drag;
+ -webkit-app-region: no-drag;
+}
+
+.tauri-titlebar__status {
+ position: absolute;
+ left: 50%;
+ top: 0;
+ transform: translateX(-50%);
+ height: 100%;
+ min-width: 220px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-inline: 8px;
+}
+
+.tauri-titlebar-status__label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1;
+ padding: 4px 10px;
+ border-radius: 999px;
+ color: var(--sable-bg-on-container);
+ background: color-mix(in srgb, var(--sable-bg-on-container) 10%, transparent);
+ white-space: nowrap;
+}
+
+.tauri-titlebar-status__label--success {
+ background: rgb(34 197 94 / 28%);
+}
+
+.tauri-titlebar-status__label--warning {
+ background: rgb(245 158 11 / 28%);
+}
+
+.tauri-titlebar-status__label--critical {
+ background: rgb(239 68 68 / 28%);
+}
+
+.tauri-titlebar__control {
+ appearance: none;
+ -webkit-appearance: none;
+ border: 0;
+ margin: 0;
+ padding: 0;
+ border-radius: 0;
+ font: inherit;
+ line-height: 0;
+ outline: none;
+ width: 46px;
+ height: var(--tauri-titlebar-height);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--sable-bg-on-container);
+ background: transparent;
+ cursor: default;
+ transition:
+ background-color 80ms linear,
+ color 80ms linear;
+ transform: none;
+}
+
+.tauri-titlebar__control:hover {
+ background: color-mix(in srgb, var(--sable-bg-on-container) 14%, transparent);
+ transform: none;
+}
+
+.tauri-titlebar__control:active {
+ background: color-mix(in srgb, var(--sable-bg-on-container) 20%, transparent);
+ transform: none;
+}
+
+.tauri-titlebar__control:focus-visible {
+ transform: none;
+}
+
+.tauri-titlebar__control svg {
+ display: block;
+ width: var(--tauri-titlebar-icon-size);
+ height: var(--tauri-titlebar-icon-size);
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1;
+ shape-rendering: crispEdges;
+ pointer-events: none;
+}
+
+.tauri-titlebar__control svg * {
+ vector-effect: non-scaling-stroke;
+}
+
+.tauri-titlebar__control--close:hover {
+ background: rgb(232 17 35 / 90%);
+ color: #fff;
+}
+
+.tauri-titlebar__control--close:active {
+ background: rgb(232 17 35 / 80%);
+ color: #fff;
+}
diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts
index ea1a85147..c456c9dad 100644
--- a/src/app/utils/matrix.ts
+++ b/src/app/utils/matrix.ts
@@ -305,21 +305,29 @@ export const mxcUrlToHttp = (
useAuthentication
);
-export const downloadMedia = async (src: string): Promise => {
- // this request is authenticated by service worker
- const res = await fetch(src, { method: 'GET' });
- const blob = await res.blob();
- return blob;
+export const authenticatedMediaFetch = async (
+ src: string,
+ accessToken?: string | null
+): Promise => {
+ const headers: HeadersInit = {};
+ if (accessToken) {
+ headers.Authorization = `Bearer ${accessToken}`;
+ }
+ return fetch(src, { method: 'GET', headers });
+};
+
+export const downloadMedia = async (src: string, accessToken?: string | null): Promise => {
+ const res = await authenticatedMediaFetch(src, accessToken);
+ return res.blob();
};
export const downloadEncryptedMedia = async (
src: string,
- decryptContent: (buf: ArrayBuffer) => Promise
+ decryptContent: (buf: ArrayBuffer) => Promise,
+ accessToken?: string | null
): Promise => {
- const encryptedContent = await downloadMedia(src);
- const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer());
-
- return decryptedContent;
+ const encryptedContent = await downloadMedia(src, accessToken);
+ return decryptContent(await encryptedContent.arrayBuffer());
};
export const rateLimitedActions = async (
diff --git a/src/app/utils/mediaCache.ts b/src/app/utils/mediaCache.ts
new file mode 100644
index 000000000..47e3da1d2
--- /dev/null
+++ b/src/app/utils/mediaCache.ts
@@ -0,0 +1,51 @@
+const CACHE_NAME = 'sable-media-v1';
+const MAX_ENTRIES = 500;
+
+async function openCache(): Promise {
+ if (typeof caches === 'undefined') return undefined;
+ try {
+ return await caches.open(CACHE_NAME);
+ } catch {
+ return undefined;
+ }
+}
+
+export async function getFromMediaCache(url: string): Promise {
+ const cache = await openCache();
+ if (!cache) return undefined;
+ try {
+ const response = await cache.match(url);
+ if (!response) return undefined;
+ return await response.blob();
+ } catch {
+ return undefined;
+ }
+}
+
+async function evictIfNeeded(cache: Cache): Promise {
+ try {
+ const keys = await cache.keys();
+ const overflow = keys.length - MAX_ENTRIES;
+ if (overflow <= 0) return;
+ // Delete oldest entries (keys() returns insertion order).
+ await Promise.all(keys.slice(0, overflow).map((req) => cache.delete(req)));
+ } catch {
+ // Best-effort eviction.
+ }
+}
+
+export async function putInMediaCache(url: string, blob: Blob): Promise {
+ const cache = await openCache();
+ if (!cache) return;
+ try {
+ await cache.put(
+ url,
+ new Response(blob, {
+ headers: { 'Content-Type': blob.type || 'application/octet-stream' },
+ })
+ );
+ await evictIfNeeded(cache);
+ } catch {
+ // Storage full or unavailable — silently degrade to in-memory only.
+ }
+}
diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts
index a6d47b7a8..5ceed2bf6 100644
--- a/src/app/utils/notifications.ts
+++ b/src/app/utils/notifications.ts
@@ -1,4 +1,5 @@
import { MatrixClient, ReceiptType } from '$types/matrix-sdk';
+import { isTauri } from '@tauri-apps/api/core';
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
const room = mx.getRoom(roomId);
@@ -35,4 +36,17 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
latestEvent,
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read
);
+
+ // On Android (Tauri), dismiss the room's OS notification immediately so
+ // it stays in sync with the read state instead of lingering until the
+ // next push payload with unread: 0 arrives.
+ if (isTauri()) {
+ try {
+ const { clearRoomNotification } =
+ await import('$features/settings/notifications/UnifiedPushNotifications');
+ await clearRoomNotification(roomId);
+ } catch {
+ // Notification plugin not available (desktop, web) — ignore.
+ }
+ }
}
diff --git a/src/app/utils/platform.ts b/src/app/utils/platform.ts
new file mode 100644
index 000000000..80f663cee
--- /dev/null
+++ b/src/app/utils/platform.ts
@@ -0,0 +1,6 @@
+import { isTauri } from '@tauri-apps/api/core';
+
+export function hasServiceWorker(): boolean {
+ // Android WebViews (Tauri) do not support service workers.
+ return 'serviceWorker' in navigator && !isTauri();
+}
diff --git a/src/index.css b/src/index.css
index 453dc7f00..c1d1eacdf 100755
--- a/src/index.css
+++ b/src/index.css
@@ -76,11 +76,17 @@
html {
height: 100%;
overflow: hidden;
+
+ --sable-inset-top: env(safe-area-inset-top, 0px);
+ --sable-inset-bottom: env(safe-area-inset-bottom, 0px);
+ --sable-inset-left: env(safe-area-inset-left, 0px);
+ --sable-inset-right: env(safe-area-inset-right, 0px);
}
body {
margin: 0;
- padding: 0;
+ padding: var(--sable-inset-top) var(--sable-inset-right) var(--sable-inset-bottom)
+ var(--sable-inset-left);
height: 100%;
font-family: var(--font-secondary);
font-size: 16px;
diff --git a/src/index.tsx b/src/index.tsx
index 76ebaf91b..a282d5693 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,153 +1,17 @@
import { createRoot } from 'react-dom/client';
-import { enableMapSet } from 'immer';
-import '@fontsource-variable/nunito';
-import '@fontsource-variable/nunito/wght-italic.css';
-import '@fontsource/space-mono/400.css';
-import '@fontsource/space-mono/700.css';
-import '@fontsource/space-mono/400-italic.css';
-import '@fontsource/space-mono/700-italic.css';
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
-import { trimTrailingSlash } from './app/utils/common';
-import App from './app/pages/App';
-import './app/i18n';
-
import './index.css';
import './app/styles/themes.css';
-import './app/styles/overrides/General.css';
-import './app/styles/overrides/Privacy.css';
-import { pushSessionToSW } from './sw-session';
-import {
- getFallbackSession,
- MATRIX_SESSIONS_KEY,
- Sessions,
- ACTIVE_SESSION_KEY,
-} from './app/state/sessions';
-import { createLogger } from './app/utils/debug';
-import { getLocalStorageItem } from './app/state/utils/atomWithLocalStorage';
-
-enableMapSet();
-const log = createLogger('index');
document.body.classList.add(configClass, varsClass);
-if ('serviceWorker' in navigator) {
- const isProduction = import.meta.env.MODE === 'production';
- const swUrl = isProduction
- ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
- : `/dev-sw.js?dev-sw`;
-
- const swRegisterOptions: RegistrationOptions = {};
- if (!isProduction) {
- swRegisterOptions.type = 'module';
- }
-
- const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => {
- const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt';
- const userPreference = localStorage.getItem(DONT_SHOW_PROMPT_KEY);
-
- if (userPreference === 'true') {
- return;
- }
-
- if (window.confirm('A new version of the app is available. Refresh to update?')) {
- if (registration.waiting) {
- registration.waiting.postMessage({ type: 'SKIP_WAITING_AND_CLAIM' });
- } else {
- window.location.reload();
- }
- }
- };
-
- navigator.serviceWorker.register(swUrl, swRegisterOptions).then((registration) => {
- registration.addEventListener('updatefound', () => {
- const installingWorker = registration.installing;
- if (installingWorker) {
- installingWorker.onstatechange = () => {
- if (installingWorker.state === 'installed') {
- if (navigator.serviceWorker.controller) {
- showUpdateAvailablePrompt(registration);
- }
- }
- };
- }
- });
- });
-
- const sendSessionToSW = () => {
- // Use the active session from the new multi-session store, fall back to legacy
- const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []);
- const activeId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined);
- const active =
- sessions.find((s) => s.userId === activeId) ?? sessions[0] ?? getFallbackSession();
- pushSessionToSW(active?.baseUrl, active?.accessToken);
- };
-
- navigator.serviceWorker
- .register(swUrl)
- .then(sendSessionToSW)
- .catch((err) => {
- log.warn('SW registration failed:', err);
- });
- navigator.serviceWorker.ready.then(sendSessionToSW).catch((err) => {
- log.warn('SW ready failed:', err);
- });
+const rootContainer = document.getElementById('root');
+if (rootContainer === null) throw new Error('Root container element not found!');
+const root = createRoot(rootContainer);
- navigator.serviceWorker.addEventListener('message', (ev) => {
- const { data } = ev;
- if (!data || typeof data !== 'object') return;
- const { type } = data as { type?: unknown };
-
- if (type === 'requestSession') {
- sendSessionToSW();
- }
-
- if (data.type === 'token' && data.id) {
- const token = localStorage.getItem('cinny_access_token') ?? undefined;
- ev.source?.postMessage({
- replyTo: data.id,
- payload: token,
- });
- } else if (data.type === 'openRoom' && data.id) {
- /* Example:
- event.source.postMessage({
- replyTo: event.data.id,
- payload: success?,
- });
- */
- }
- });
+if (window.location.pathname.startsWith('/lp/')) {
+ import('./app/pages/LandingRouter').then(({ LandingRouter }) => root.render( ));
+} else {
+ import('./main').then(({ mountApp }) => mountApp(root));
}
-
-const injectIOSMetaTags = () => {
- const metaTags = [
- { name: 'theme-color', content: '#0C0B0F' },
- { name: 'apple-mobile-web-app-capable', content: 'yes' },
- { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
- ];
-
- metaTags.forEach((tag) => {
- let element = document.querySelector(`meta[name="${tag.name}"]`);
- if (!element) {
- element = document.createElement('meta');
- element.setAttribute('name', tag.name);
- document.head.appendChild(element);
- }
- element.setAttribute('content', tag.content);
- });
-};
-
-injectIOSMetaTags();
-
-const mountApp = () => {
- const rootContainer = document.getElementById('root');
-
- if (rootContainer === null) {
- throw new Error('Root container element not found!');
- }
-
- const root = createRoot(rootContainer);
- root.render( );
-};
-
-mountApp();
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 000000000..17bbe167f
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,123 @@
+import { type Root } from 'react-dom/client';
+import { enableMapSet } from 'immer';
+import { isTauri } from '@tauri-apps/api/core';
+import '@fontsource-variable/nunito';
+import '@fontsource-variable/nunito/wght-italic.css';
+import '@fontsource/space-mono/400.css';
+import '@fontsource/space-mono/700.css';
+import '@fontsource/space-mono/400-italic.css';
+import '@fontsource/space-mono/700-italic.css';
+import App from '$pages/App';
+import { trimTrailingSlash } from '$utils/common';
+import './app/i18n';
+
+import '$styles/overrides/General.css';
+import '$styles/overrides/Privacy.css';
+import '$styles/overrides/TauriDesktop.css';
+import {
+ getFallbackSession,
+ MATRIX_SESSIONS_KEY,
+ Sessions,
+ ACTIVE_SESSION_KEY,
+} from '$state/sessions';
+import { createLogger } from '$utils/debug';
+import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage';
+import { pushSessionToSW } from './sw-session';
+
+enableMapSet();
+const log = createLogger('main');
+
+const sendSessionToSW = () => {
+ const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []);
+ const activeId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined);
+ const active = sessions.find((s) => s.userId === activeId) ?? sessions[0] ?? getFallbackSession();
+ pushSessionToSW(active?.baseUrl, active?.accessToken);
+};
+
+const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => {
+ const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt';
+ if (localStorage.getItem(DONT_SHOW_PROMPT_KEY) === 'true') return;
+
+ // TODO: Replace with a custom in-app prompt to avoid the jarring native confirm dialog.
+ // eslint-disable-next-line no-alert
+ if (window.confirm('A new version of the app is available. Refresh to update?')) {
+ if (registration.waiting) {
+ registration.waiting.postMessage({ type: 'SKIP_WAITING_AND_CLAIM' });
+ } else {
+ window.location.reload();
+ }
+ }
+};
+
+const initServiceWorker = () => {
+ if (!('serviceWorker' in navigator)) return;
+
+ const isProduction = import.meta.env.MODE === 'production';
+ const swUrl = isProduction
+ ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
+ : `/dev-sw.js?dev-sw`;
+
+ const swRegisterOptions: RegistrationOptions = {};
+ if (!isProduction) swRegisterOptions.type = 'module';
+
+ navigator.serviceWorker
+ .register(swUrl, swRegisterOptions)
+ .then((registration) => {
+ registration.addEventListener('updatefound', () => {
+ const installingWorker = registration.installing;
+ if (installingWorker) {
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === 'installed') {
+ if (!isTauri() && navigator.serviceWorker.controller) {
+ showUpdateAvailablePrompt(registration);
+ }
+ }
+ };
+ }
+ });
+ sendSessionToSW();
+ })
+ .catch((err) => log.warn('SW registration failed:', err));
+
+ navigator.serviceWorker.ready.then(sendSessionToSW).catch((err) => {
+ log.warn('SW ready failed:', err);
+ });
+
+ navigator.serviceWorker.addEventListener('message', (ev) => {
+ const { data } = ev;
+ if (!data || typeof data !== 'object') return;
+ const { type } = data as { type?: unknown };
+
+ if (type === 'requestSession') {
+ sendSessionToSW();
+ }
+
+ if (data.type === 'token' && data.id) {
+ const token = localStorage.getItem('cinny_access_token') ?? undefined;
+ ev.source?.postMessage({ replyTo: data.id, payload: token });
+ }
+ });
+};
+
+const injectIOSMetaTags = () => {
+ const metaTags = [
+ { name: 'theme-color', content: '#0C0B0F' },
+ { name: 'apple-mobile-web-app-capable', content: 'yes' },
+ { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
+ ];
+ metaTags.forEach((tag) => {
+ let element = document.querySelector(`meta[name="${tag.name}"]`);
+ if (!element) {
+ element = document.createElement('meta');
+ element.setAttribute('name', tag.name);
+ document.head.appendChild(element);
+ }
+ element.setAttribute('content', tag.content);
+ });
+};
+
+export const mountApp = (root: Root) => {
+ initServiceWorker();
+ injectIOSMetaTags();
+ root.render( );
+};
diff --git a/src/sw.ts b/src/sw.ts
index 9f3314889..ab9729049 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -1,3 +1,4 @@
+/* eslint-disable no-console */
///
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts
index 1152d3d44..3f03b3ce3 100644
--- a/src/sw/pushNotification.ts
+++ b/src/sw/pushNotification.ts
@@ -1,3 +1,4 @@
+/* eslint-disable no-console */
import { EventType } from 'matrix-js-sdk/lib/@types/event';
import {
buildRoomMessageNotification,
diff --git a/tsconfig.json b/tsconfig.json
index 839ef9d31..5d5eb9ae8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -19,6 +19,7 @@
"$styles/*": ["src/app/styles/*"],
"$utils/*": ["src/app/utils/*"],
"$pages/*": ["src/app/pages/*"],
+ "$generated/*": ["src/app/generated/*"],
"$types/*": ["src/types/*"],
"$public/*": ["public/*"],
"$client/*": ["src/client/*"]
diff --git a/vite.config.ts b/vite.config.ts
index 4f38d92ac..883def3a7 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -48,6 +48,11 @@ const resolveBuildHash = (): string | undefined => {
const appVersion = packageJson.version;
const buildHash = resolveBuildHash();
+const tauriDevHost = process.env.TAURI_DEV_HOST;
+const isTauriBuild = Boolean(process.env.TAURI_ENV_PLATFORM);
+const isTauriDebug = process.env.TAURI_ENV_DEBUG === 'true';
+const tauriBuildTarget = process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13';
+const tauriBuildMinify = !isTauriDebug ? 'esbuild' : false;
const isReleaseTag = (() => {
const envVal = process.env.VITE_IS_RELEASE_TAG;
@@ -115,9 +120,11 @@ function serverMatrixSdkCryptoWasm() {
}
export default defineConfig({
+ clearScreen: false,
appType: 'spa',
publicDir: false,
base: buildConfig.base,
+ envPrefix: ['VITE_', 'TAURI_ENV_*'],
define: {
APP_VERSION: JSON.stringify(appVersion),
BUILD_HASH: JSON.stringify(buildHash ?? ''),
@@ -133,6 +140,7 @@ export default defineConfig({
$styles: path.resolve(__dirname, 'src/app/styles'),
$utils: path.resolve(__dirname, 'src/app/utils'),
$pages: path.resolve(__dirname, 'src/app/pages'),
+ $generated: path.resolve(__dirname, 'src/app/generated'),
$types: path.resolve(__dirname, 'src/types'),
$public: path.resolve(__dirname, 'public'),
$client: path.resolve(__dirname, 'src/client'),
@@ -140,7 +148,18 @@ export default defineConfig({
},
server: {
port: 8080,
- host: true,
+ strictPort: true,
+ host: tauriDevHost || true,
+ hmr: tauriDevHost
+ ? {
+ protocol: 'ws',
+ host: tauriDevHost,
+ port: 1421,
+ }
+ : undefined,
+ watch: {
+ ignored: ['**/src-tauri/**'],
+ },
fs: {
// Allow serving files from one level up to the project root
allow: ['..'],
@@ -172,6 +191,16 @@ export default defineConfig({
enabled: true,
type: 'module',
},
+ workbox: {
+ maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10 MB
+ globIgnores: [
+ '**/matrix_sdk_crypto_wasm_bg-*.wasm',
+ '**/vision_wasm_internal-*.wasm',
+ '**/qcms_bg.wasm',
+ '**/openjpeg.wasm',
+ '**/jbig2.wasm',
+ ],
+ },
}),
cloudflare({
config: {
@@ -191,6 +220,8 @@ export default defineConfig({
}),
],
optimizeDeps: {
+ // Include service worker entry so worker-only imports are discovered during startup.
+ entries: ['index.html', 'src/sw.ts'],
// Rebuild dep optimizer cache on each dev start to avoid stale API shapes.
force: true,
// Keep matrix-widget-api prebundled so matrix-js-sdk can import its named exports in dev.
@@ -215,11 +246,22 @@ export default defineConfig({
},
},
build: {
+ target: isTauriBuild ? tauriBuildTarget : undefined,
+ minify: isTauriBuild ? tauriBuildMinify : undefined,
+ sourcemap: isTauriBuild ? isTauriDebug : true,
outDir: 'dist',
- sourcemap: true,
copyPublicDir: false,
rollupOptions: {
plugins: [inject({ Buffer: ['buffer', 'Buffer'] }) as PluginOption],
+ output: {
+ manualChunks: (id) => {
+ if (id.includes('pdfjs-dist')) return 'pdf';
+ if (id.includes('@element-hq/element-call-embedded')) return 'element-call';
+ if (id.includes('@matrix-org') || id.includes('matrix-js-sdk')) return 'matrix';
+ if (id.includes('react-prism') || id.includes('prism')) return 'prism';
+ return undefined;
+ },
+ },
},
},
});