diff --git a/.gitignore b/.gitignore index 76af755426..bf21e1a2cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ devAssets .DS_Store .idea - +*.code-workspace .env .wrangler @@ -24,6 +24,31 @@ build.sh # the following line was added with the "git ignore" tool by itsrye.dev, version 0.1.0 .lh +# 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/ +# Tauri Android release keystore +*.jks + +# Tauri-typegen cache +.typecache + +# Worktrees +.worktrees + # the following line was added with nvim by Shea because its annoying to clear every so often .vscode/bookmarks.json diff --git a/.vscode/settings.json b/.vscode/settings.json index d8071aa979..78a3d23b9a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,9 @@ "[json]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + }, "nixEnvSelector.nixFile": "${workspaceFolder}/flake.nix", "nixEnvSelector.useFlakes": true, "explorer.fileNesting.enabled": true, diff --git a/config.json b/config.json index 0002880c9b..9abd8fd830 100644 --- a/config.json +++ b/config.json @@ -10,7 +10,9 @@ "pushNotificationDetails": { "pushNotifyUrl": "https://sygnal.sable.moe/_matrix/push/v1/notify", "vapidPublicKey": "BCnS4SbHjeOaqVFW4wjt5xDt_pYIL62qMzKePfYF9fl9PQU14RieIaObh7nLR_9dQf4sykZa-CTrcjkgMIE1mcg", - "webPushAppID": "moe.sable.app.sygnal" + "webPushAppID": "moe.sable.app.sygnal", + "nativePushAppID": "moe.sable.client.android", + "unifiedPushAppID": "moe.sable.up" }, "themeCatalogBaseUrl": "https://raw.githubusercontent.com/SableClient/themes/main/", diff --git a/flake.lock b/flake.lock index 536adabd72..b8fb186ec0 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,26 @@ { "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1784276287, + "narHash": "sha256-xXONk0mfpVtFSaZZp21bjw1eRud0dFJ2jpVQ/TQX7ZY=", + "owner": "nix-community", + "repo": "fenix", + "rev": "3181005f932fbae4626c6a6dca5b728742656d9e", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -110,12 +131,30 @@ }, "root": { "inputs": { + "fenix": "fenix", "flake-parts": "flake-parts", "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" } }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1784247776, + "narHash": "sha256-GiZgiGRmDl773Y8wEnS1Vs/KJICXwYYspYSuKS0dhLc=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "7d5fff4b1a86097dda2b8bb899eaf5cf6b6827d5", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, "treefmt-nix": { "inputs": { "nixpkgs": [ diff --git a/flake.nix b/flake.nix index 4b719d4b77..e95a163082 100644 --- a/flake.nix +++ b/flake.nix @@ -7,11 +7,14 @@ git-hooks.inputs.nixpkgs.follows = "nixpkgs"; treefmt-nix.url = "github:numtide/treefmt-nix"; treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = - inputs@{ flake-parts, ... }: - flake-parts.lib.mkFlake { inherit inputs; } { + outputs = inputs @ {flake-parts, ...}: + flake-parts.lib.mkFlake {inherit inputs;} { imports = [ inputs.git-hooks.flakeModule inputs.treefmt-nix.flakeModule @@ -24,108 +27,102 @@ "aarch64-darwin" ]; - perSystem = - { - config, - pkgs, - lib, - ... - }: - let - self = inputs.self; + perSystem = { + inputs', + config, + system, + lib, + ... + }: let + pkgs = import inputs.nixpkgs { + inherit system; - packageJson = builtins.fromJSON (builtins.readFile ./package.json); + config = { + allowUnfree = true; + android_sdk.accept_license = true; + }; + }; - nodejs = pkgs.nodejs_24; - pnpm = pkgs.pnpm_10; - pnpmConfigHook = pkgs.pnpmConfigHook.override { inherit pnpm; }; + rust = ( + with inputs'.fenix.packages; + combine [ + stable.toolchain + targets.aarch64-linux-android.stable.rust-std + targets.x86_64-linux-android.stable.rust-std + targets.armv7-linux-androideabi.stable.rust-std + targets.i686-linux-android.stable.rust-std + ] + ); - pnpmNativeBuildInputs = [ - nodejs - pnpm - pnpmConfigHook - ]; + platformVersion = "36"; + systemImageType = "default"; + currentPath = builtins.getEnv "PWD"; + androidEnv = pkgs.androidenv.override {licenseAccepted = true;}; + androidComp = ( + androidEnv.composeAndroidPackages { + cmdLineToolsVersion = "8.0"; + includeNDK = true; + buildToolsVersions = ["35.0.0"]; - mkPnpmDeps = - { - src, - version, - pnpmInstallFlags, - }: - pkgs.fetchPnpmDeps { - inherit - pnpm - src - version - pnpmInstallFlags - ; - pname = "sable"; - fetcherVersion = 3; - hash = "sha256-iBvtMeYUHWhsz1DnKjTzydAt3cGPaisUhaagoaZRg1M="; - }; - - mkPnpmCheck = - name: script: - pkgs.stdenv.mkDerivation (finalAttrs: { - pname = "sable-${name}"; - inherit (packageJson) version; - src = lib.cleanSource ./.; - - pnpmInstallFlags = [ "--ignore-scripts" ]; - - pnpmDeps = mkPnpmDeps { - inherit (finalAttrs) src version pnpmInstallFlags; - }; - - nativeBuildInputs = pnpmNativeBuildInputs; - - buildPhase = '' - runHook preBuild - pnpm run ${script} - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - touch $out - runHook postInstall - ''; - - doCheck = false; - }); - in - { - treefmt = { - projectRootFile = "flake.nix"; - programs = { - nixfmt.enable = true; - oxfmt.enable = true; - }; - settings.global.excludes = [ - "dist" - "node_modules" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" - "package.json" - "LICENSE" - "CHANGELOG.md" - "./changeset" + platformVersions = [ + "30" + platformVersion ]; - }; - pre-commit.settings.hooks = { - treefmt = { - enable = true; - package = config.treefmt.build.wrapper; - }; - }; + includeEmulator = true; + includeSystemImages = true; + systemImageTypes = [ + systemImageType + ]; + abiVersions = [ + "x86" + "x86_64" + "armeabi-v7a" + "arm64-v8a" + ]; + cmakeVersions = ["3.10.2"]; + } + ); + android-sdk = pkgs.android-studio.withSdk androidComp.androidsdk; + + self = inputs.self; + + packageJson = builtins.fromJSON (builtins.readFile ./package.json); + + nodejs = pkgs.nodejs_24; + pnpm = pkgs.pnpm_10; + pnpmConfigHook = pkgs.pnpmConfigHook.override {inherit pnpm;}; - packages.sable = pkgs.stdenv.mkDerivation (finalAttrs: { + pnpmNativeBuildInputs = [ + pkgs.pkg-config + nodejs + pnpm + pnpmConfigHook + ]; + + mkPnpmDeps = { + src, + version, + pnpmInstallFlags, + }: + pkgs.fetchPnpmDeps { + inherit + pnpm + src + version + pnpmInstallFlags + ; pname = "sable"; + fetcherVersion = 3; + hash = "sha256-9lHy7/+hVET5c9Mt3F9DvrkW97DMxTISJsrklK/Ls2c="; + }; + + mkPnpmCheck = name: script: + pkgs.stdenv.mkDerivation (finalAttrs: { + pname = "sable-${name}"; inherit (packageJson) version; src = lib.cleanSource ./.; - # ignoring knope for building - pnpmInstallFlags = [ "--ignore-scripts" ]; + pnpmInstallFlags = ["--ignore-scripts"]; pnpmDeps = mkPnpmDeps { inherit (finalAttrs) src version pnpmInstallFlags; @@ -133,49 +130,178 @@ nativeBuildInputs = pnpmNativeBuildInputs; - env.VITE_BUILD_HASH = self.shortRev or self.dirtyShortRev or ""; - env.VITE_IS_RELEASE_TAG = "false"; - buildPhase = '' runHook preBuild - pnpm run build + pnpm run ${script} runHook postBuild ''; installPhase = '' runHook preInstall - cp -r dist $out + touch $out runHook postInstall ''; + + doCheck = false; }); - packages.default = config.packages.sable; + nativeBuildInputs = with pkgs; [ + pkg-config + gobject-introspection + cargo + cargo-tauri + nodejs + xdg-utils + desktop-file-utils + wrapGAppsHook3 + ]; - checks = { - build = config.packages.sable; - lint = mkPnpmCheck "lint" "lint"; - fmt = mkPnpmCheck "fmt" "fmt:check"; - test = mkPnpmCheck "test" "test:run"; - typecheck = mkPnpmCheck "typecheck" "typecheck"; - knip = mkPnpmCheck "knip" "knip"; + buildInputs = with pkgs; [ + at-spi2-atk + atkmm + cairo + gdk-pixbuf + glib + glib-networking + gtk3 + gsettings-desktop-schemas + harfbuzz + librsvg + libsoup_3 + pango + webkitgtk_4_1 + openssl + dbus + dbus.dev + libayatana-appindicator + gst_all_1.gstreamer + gst_all_1.gst-plugins-base + gst_all_1.gst-plugins-good + gst_all_1.gst-plugins-bad + gst_all_1.gst-plugins-ugly + gst_all_1.gst-libav + android-sdk + ]; + + defaultPackages = [ + nodejs + pnpm + rust + pkgs.cargo + pkgs.cargo-tauri + pkgs.corepack + pkgs.vitejs + pkgs.oxlint + pkgs.oxfmt + pkgs.knope + pkgs.typescript + pkgs.typescript-language-server + pkgs.nil + pkgs.nixd + pkgs.jdk + ]; + in { + treefmt = { + projectRootFile = "flake.nix"; + programs = { + nixfmt.enable = true; + oxfmt.enable = true; }; + settings.global.excludes = [ + "dist" + "node_modules" + "pnpm-lock.yaml" + "pnpm-workspace.yaml" + "package.json" + "LICENSE" + "CHANGELOG.md" + "./changeset" + ]; + }; + pre-commit.settings.hooks = { + treefmt = { + enable = true; + package = config.treefmt.build.wrapper; + }; + }; - devShells.default = pkgs.mkShell { - packages = [ - nodejs - pnpm - pkgs.corepack - pkgs.vitejs - pkgs.oxlint - pkgs.oxfmt - pkgs.knope - pkgs.typescript - pkgs.typescript-language-server - pkgs.nil - pkgs.nixd + packages.sable = pkgs.stdenv.mkDerivation (finalAttrs: { + pname = "sable"; + inherit (packageJson) version; + src = lib.cleanSource ./.; + + # ignoring knope for building + pnpmInstallFlags = ["--ignore-scripts"]; + + pnpmDeps = mkPnpmDeps { + inherit (finalAttrs) src version pnpmInstallFlags; + }; + + nativeBuildInputs = pnpmNativeBuildInputs; + + env.VITE_BUILD_HASH = self.shortRev or self.dirtyShortRev or ""; + env.VITE_IS_RELEASE_TAG = "false"; + + buildPhase = '' + runHook preBuild + pnpm run build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + cp -r dist $out + runHook postInstall + ''; + }); + + packages.android-emulator = androidEnv.emulateApp { + name = "emulate-sable"; + platformVersion = platformVersion; + abiVersion = "x86_64"; # arm64-v8a + systemImageType = systemImageType; + }; + + packages.default = config.packages.sable; + + checks = { + build = config.packages.sable; + lint = mkPnpmCheck "lint" "lint"; + fmt = mkPnpmCheck "fmt" "fmt:check"; + test = mkPnpmCheck "test" "test:run"; + typecheck = mkPnpmCheck "typecheck" "typecheck"; + knip = mkPnpmCheck "knip" "knip"; + }; + + devShells = { + default = pkgs.mkShell { + inherit buildInputs nativeBuildInputs; + + packages = defaultPackages; + + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (nativeBuildInputs ++ buildInputs); + + GIO_EXTRA_MODULES = "${pkgs.glib-networking}/lib/gio/modules"; + GST_PLUGIN_SYSTEM_PATH = "${pkgs.gst_all_1.gst-plugins-base}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-good}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-bad}/lib/gstreamer-1.0"; + }; + + android = pkgs.mkShell { + inherit buildInputs nativeBuildInputs; + + packages = defaultPackages ++ [ + android-sdk ]; - shellHook = config.pre-commit.installationScript; + + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (nativeBuildInputs ++ buildInputs); + + ANDROID_HOME = "${androidComp.androidsdk}/libexec/android-sdk"; + ANDROID_SDK_ROOT = "${androidComp.androidsdk}/libexec/android-sdk"; + ANDROID_NDK_ROOT = "${androidComp.androidsdk}/libexec/android-sdk/ndk-bundle"; + + GIO_EXTRA_MODULES = "${pkgs.glib-networking}/lib/gio/modules"; + GST_PLUGIN_SYSTEM_PATH = "${pkgs.gst_all_1.gst-plugins-base}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-good}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-bad}/lib/gstreamer-1.0"; }; }; + }; }; } diff --git a/index.html b/index.html index 4e73dec7cd..3a9c594781 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,10 @@ - + Sable Client @@ -141,7 +144,6 @@ window.global ||= window;
-
diff --git a/knip.json b/knip.json index 1912dfad95..6e320647de 100644 --- a/knip.json +++ b/knip.json @@ -1,11 +1,12 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/sw.ts", "scripts/normalize-imports.js"], + "entry": ["src/sw.ts", "scripts/**/*.js"], "ignore": ["oxlint.config.ts", "oxfmt.config.ts"], "ignoreExportsUsedInFile": { "interface": true, "type": true }, + "ignoreFiles": ["src/app/generated/**/*"], "ignoreDependencies": [ "buffer", "@sableclient/sable-call-embedded", diff --git a/knope.toml b/knope.toml index fc533824c8..9b5cafbbd0 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 = [ diff --git a/package.json b/package.json index a2d8d8abba..688723916d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "fmt": "oxfmt .", "fmt:check": "oxfmt --check .", "typecheck": "tsc", + "tauri": "tauri", + "tauri:cef": "node scripts/tauri.js cef", + "tauri:wry": "node scripts/tauri.js wry", "test": "vitest", "test:ui": "vitest --ui", "test:run": "vitest run", @@ -23,7 +26,10 @@ "tunnel": "cloudflared tunnel --url http://localhost:8080", "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" }, "dependencies": { "@arborium/arborium": "^2.18.1", @@ -33,11 +39,19 @@ "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", "@phosphor-icons/react": "^2.1.10", + "@sableclient/tauri-plugin-notifications-api": "git+https://github.com/SableClient/tauri-plugin-notifications.git#9d15b2b2c8798dc29ce723d743f0f3b4921d6a54", "@sableclient/twemoji-font": "^1.0.4", "@sentry/react": "^10.63.0", "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-virtual": "^3.14.5", + "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-deep-link": "2.4.9", + "@tauri-apps/plugin-http": "2.5.9", + "@tauri-apps/plugin-opener": "2.5.4", + "@tauri-apps/plugin-os": "2.3.2", + "@tauri-apps/plugin-store": "^2.4.3", "@use-gesture/react": "10.3.1", "@vanilla-extract/css": "^1.21.1", "@vanilla-extract/recipes": "^0.5.7", @@ -88,6 +102,7 @@ "slate-dom": "^0.124.1", "slate-history": "^0.113.1", "slate-react": "^0.125.1", + "tauri-plugin-android-fs-api": "28.4.0", "ua-parser-js": "^2.0.10", "virtua": "^0.49.2", "workbox-precaching": "^7.4.1" @@ -99,6 +114,7 @@ "@rollup/plugin-wasm": "^6.2.2", "@sableclient/sable-call-embedded": "1.1.7", "@sentry/vite-plugin": "^5.3.0", + "@tauri-apps/cli": "2.11.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -140,4 +156,4 @@ "jsdom>undici": "^7.28.0" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 082e727ca0..bcdb2c7273 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sableclient/tauri-plugin-notifications-api': + specifier: git+https://github.com/SableClient/tauri-plugin-notifications.git#9d15b2b2c8798dc29ce723d743f0f3b4921d6a54 + version: https://codeload.github.com/SableClient/tauri-plugin-notifications/tar.gz/9d15b2b2c8798dc29ce723d743f0f3b4921d6a54 '@sableclient/twemoji-font': specifier: ^1.0.4 version: 1.0.4 @@ -47,6 +50,27 @@ importers: '@tanstack/react-virtual': specifier: ^3.14.5 version: 3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tauri-apps/api': + specifier: 2.11.0 + version: 2.11.0 + '@tauri-apps/plugin-clipboard-manager': + specifier: 2.3.2 + version: 2.3.2 + '@tauri-apps/plugin-deep-link': + specifier: 2.4.9 + version: 2.4.9 + '@tauri-apps/plugin-http': + specifier: 2.5.9 + version: 2.5.9 + '@tauri-apps/plugin-opener': + specifier: 2.5.4 + version: 2.5.4 + '@tauri-apps/plugin-os': + specifier: 2.3.2 + version: 2.3.2 + '@tauri-apps/plugin-store': + specifier: ^2.4.3 + version: 2.4.3 '@use-gesture/react': specifier: 10.3.1 version: 10.3.1(react@18.3.1) @@ -197,6 +221,9 @@ importers: slate-react: specifier: ^0.125.1 version: 0.125.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate-dom@0.124.1(slate@0.124.1))(slate@0.124.1) + tauri-plugin-android-fs-api: + specifier: 28.4.0 + version: 28.4.0 ua-parser-js: specifier: ^2.0.10 version: 2.0.10 @@ -225,6 +252,9 @@ importers: '@sentry/vite-plugin': specifier: ^5.3.0 version: 5.3.0(rollup@2.80.0) + '@tauri-apps/cli': + specifier: 2.11.0 + version: 2.11.0 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -2472,6 +2502,10 @@ packages: '@sableclient/sable-call-embedded@1.1.7': resolution: {integrity: sha512-I2GSgGSUjY7ytsurTQ7JfBAsO8ZiytniknbNVzJfbN6MQjr69YDmj/UHtB2QUqwPqUmxHVxhtqVTiUgFx0k8mA==} + '@sableclient/tauri-plugin-notifications-api@https://codeload.github.com/SableClient/tauri-plugin-notifications/tar.gz/9d15b2b2c8798dc29ce723d743f0f3b4921d6a54': + resolution: {tarball: https://codeload.github.com/SableClient/tauri-plugin-notifications/tar.gz/9d15b2b2c8798dc29ce723d743f0f3b4921d6a54} + version: 0.4.3 + '@sableclient/twemoji-font@1.0.4': resolution: {integrity: sha512-LzvQB/VZtv5KEq1VYq2azr7v7cYT8oklOVRGkAN2RNwa3sF0XcnqM3lBHSYRFXQleGLSUgs3gA0slbwkD2+sJw==} @@ -2784,6 +2818,103 @@ packages: '@tanstack/virtual-core@3.17.3': resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + + '@tauri-apps/cli-darwin-arm64@2.11.0': + resolution: {integrity: sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.0': + resolution: {integrity: sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.0': + resolution: {integrity: sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.0': + resolution: {integrity: sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.0': + resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.0': + resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.0': + resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.0': + resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.0': + resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.0': + resolution: {integrity: sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.0': + resolution: {integrity: sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.0': + resolution: {integrity: sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-clipboard-manager@2.3.2': + resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + + '@tauri-apps/plugin-deep-link@2.4.9': + resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} + + '@tauri-apps/plugin-http@2.5.9': + resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==} + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@tauri-apps/plugin-os@2.3.2': + resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} + + '@tauri-apps/plugin-store@2.4.3': + resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -3210,6 +3341,10 @@ packages: typescript: optional: true + create-web-stream@1.1.3: + resolution: {integrity: sha512-ocdWcsi6F8/8f0L/Fsbt3UmPSB+7H5hXR0aRr7Wiu7TXC1Z8vwlwxpeO4+gMgJ8g4TqwM5oTnSid43Jxm2ArwA==} + engines: {node: '>=18.9.0'} + cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} @@ -4747,6 +4882,9 @@ packages: tar-mini@0.2.0: resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==} + tauri-plugin-android-fs-api@28.4.0: + resolution: {integrity: sha512-YOo1P+cRjuaoPbTNZprMhr56ruKO4hIDq7kJOum91NqkN+VWY9F+UPAdjZokhzJSsbkeY0nsb1VDLXb2/h3f3w==} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -6983,6 +7121,10 @@ snapshots: '@sableclient/sable-call-embedded@1.1.7': {} + '@sableclient/tauri-plugin-notifications-api@https://codeload.github.com/SableClient/tauri-plugin-notifications/tar.gz/9d15b2b2c8798dc29ce723d743f0f3b4921d6a54': + dependencies: + '@tauri-apps/api': 2.11.0 + '@sableclient/twemoji-font@1.0.4': {} '@sentry/babel-plugin-component-annotate@5.3.0': {} @@ -7270,6 +7412,79 @@ snapshots: '@tanstack/virtual-core@3.17.3': {} + '@tauri-apps/api@2.11.0': {} + + '@tauri-apps/cli-darwin-arm64@2.11.0': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.0': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.0': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.0': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.0': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.0': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.0': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.0': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.0': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.0': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.0': + optional: true + + '@tauri-apps/cli@2.11.0': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.0 + '@tauri-apps/cli-darwin-x64': 2.11.0 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.0 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.0 + '@tauri-apps/cli-linux-arm64-musl': 2.11.0 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.0 + '@tauri-apps/cli-linux-x64-gnu': 2.11.0 + '@tauri-apps/cli-linux-x64-musl': 2.11.0 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.0 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.0 + '@tauri-apps/cli-win32-x64-msvc': 2.11.0 + + '@tauri-apps/plugin-clipboard-manager@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-deep-link@2.4.9': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-http@2.5.9': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-os@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-store@2.4.3': + dependencies: + '@tauri-apps/api': 2.11.0 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -7784,6 +7999,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + create-web-stream@1.1.3: {} + cross-fetch@4.0.0: dependencies: node-fetch: 2.7.0 @@ -9548,6 +9765,11 @@ snapshots: tar-mini@0.2.0: {} + tauri-plugin-android-fs-api@28.4.0: + dependencies: + '@tauri-apps/api': 2.11.0 + create-web-stream: 1.1.3 + temp-dir@2.0.0: {} tempy@0.6.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ddada001b..b616adb35d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ allowBuilds: + '@sableclient/tauri-plugin-notifications-api': true '@sentry/cli': true '@swc/core': true cloudflared: true diff --git a/public/full_res_sable_square.png b/public/full_res_sable_square.png new file mode 100644 index 0000000000..1c3980e228 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 0000000000..25674f335c --- /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/scripts/tauri.js b/scripts/tauri.js new file mode 100644 index 0000000000..b4ac819b8b --- /dev/null +++ b/scripts/tauri.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +/** + * Script to run tauri commands for a given runtime (wry or cef) with prepended CLI args. + * + * Usage: + * script/tauri [prepended-args...] + * + * Examples: + * script/tauri cef dev --verbose + * script/tauri cef dev -- --verbose + * Both will run: cargo tauri dev --features cef -- --verbose --no-default-features + */ + +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import process from 'node:process'; +import { PrefixedLogger, createTextHelpers } from './utils/console-style.js'; + +const logger = new PrefixedLogger('[tauri]'); +const { dim } = createTextHelpers({ useColor: logger.useColor }); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const cmdlineArgs = process.argv.slice(2); + +if (cmdlineArgs.length < 2) { + logger.error('Usage: node scripts/tauri [prepended-args...]'); + logger.error(` ${dim('command:')} dev or build`); + logger.error(` ${dim('runtime:')} wry or cef`); + logger.error( + ` ${dim('prepended-args:')} Arguments to prepend to the cargo args (-- separator is optional)` + ); + process.exit(1); +} + +const [runtime, cmd, ...tauriArgs] = cmdlineArgs; + +if (!['wry', 'cef'].includes(runtime)) { + logger.error(`Invalid runtime: ${runtime}. Must be 'wry' or 'cef'`); + process.exit(1); +} +if (!['dev', 'build'].includes(cmd)) { + logger.error(`Invalid command: ${cmd}. Must be 'dev' or 'build'`); + process.exit(1); +} + +tauriArgs.unshift('--features', runtime); +if (!tauriArgs.includes('--')) { + tauriArgs.push('--'); +} +tauriArgs.push('--no-default-features'); + +const args = ['tauri', cmd, ...tauriArgs]; + +const command = 'cargo'; + +logger.info(`${dim('Running:')} ${command} ${args.join(' ')}`); + +// Spawn the tauri process +const proc = spawn(command, args, { + stdio: 'inherit', + shell: false, + cwd: join(__dirname, '..'), +}); + +proc.on('error', (error) => { + logger.error(`Failed to start process: ${error.message}`); + process.exit(1); +}); + +proc.on('exit', (code) => { + process.exit(code ?? 0); +}); diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000000..489be3277b --- /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 0000000000..a760042520 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,7239 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.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 = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +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 = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[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 = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[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.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.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.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +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.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.3", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.1", + "core-foundation 0.10.1", + "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.1", + "core-foundation 0.10.1", + "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-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +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.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[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 = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[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.1", + "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 = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+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 = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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 0.10.1", + "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 = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[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 = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.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 = "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 = "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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[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.1", + "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.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.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 = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.11.1", + "ignore", + "walkdir", +] + +[[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 = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[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 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.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 = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry 0.6.1", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +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 = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "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.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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 = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[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.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.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 = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[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.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "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.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "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.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +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 = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "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 = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[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 = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + +[[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" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[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_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[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.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.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_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.39.3", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "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.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[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 = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[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.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[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.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[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.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[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.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.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.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +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 = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "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.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[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.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "sable" +version = "0.1.0" +dependencies = [ + "enigo", + "gtk", + "log", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-android-fs", + "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", + "tauri-plugin-edge-to-edge", + "tauri-plugin-http", + "tauri-plugin-log", + "tauri-plugin-notifications", + "tauri-plugin-opener", + "tauri-plugin-os", + "tauri-plugin-single-instance", + "tauri-plugin-store", + "tauri-plugin-window-state", + "tauri-typegen", + "tokio", + "ts-rs", + "webkit2gtk", + "windows", +] + +[[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.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-rename-rule" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a059d895f1a31dd928f40abbea4e7177e3d8ff3aa4152fdb7a396ae1ef63a3" + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-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_async" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b815ef8e053247ad785b136d233ee9c9872eeda5319f2719d57cac88270c3dd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "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.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.3", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "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.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-android-fs" +version = "28.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "999e39313646a1f48f65270e75df20664a692bf28fa21f44a16bf8706c154f31" +dependencies = [ + "base64 0.22.1", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "sync_async", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + +[[package]] +name = "tauri-plugin-edge-to-edge" +version = "0.3.3" +source = "git+https://github.com/SableClient/tauri-plugin-edge-to-edge.git?rev=33c6116c27be28c06df5a9d02231ecc5fdeb93c5#33c6116c27be28c06df5a9d02231ecc5fdeb93c5" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-http" +version = "2.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067" +dependencies = [ + "bytes", + "cookie_store", + "data-url", + "http", + "regex", + "reqwest 0.12.28", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "tokio", + "url", + "urlpattern", +] + +[[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=9d15b2b2c8798dc29ce723d743f0f3b4921d6a54#9d15b2b2c8798dc29ce723d743f0f3b4921d6a54" +dependencies = [ + "log", + "notify-rust", + "rand 0.10.1", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[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.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95" +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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" +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-typegen" +version = "0.5.0" +source = "git+https://github.com/SableClient/tauri-typegen?branch=fix%2Fnondeterministic-generation-cache#645ac948bbd940704cda4419a203e307e7f0d429" +dependencies = [ + "chrono", + "clap", + "indicatif", + "proc-macro2", + "quote", + "regex", + "serde", + "serde-rename-rule", + "serde_json", + "syn 2.0.117", + "tera", + "thiserror 2.0.18", + "walkdir", +] + +[[package]] +name = "tauri-utils" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf 0.11.3", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+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.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.6", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[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 = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.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.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[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.1", + "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.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.18", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "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 = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +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.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +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.14.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.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.3", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.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 = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[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-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +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" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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.14.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.1", + "indexmap 2.14.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.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.2", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.2", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000000..1c0a9fa1af --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,102 @@ +[package] +name = "sable" +version = "0.1.0" +description = "Yet another matrix client but better" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.88.0" + +# 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 = [] } +tauri-typegen = "0.5" + +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +log = "0.4" +tokio = { version = "1", features = ["time", "sync"] } +ts-rs = "12.0.0" +sha2 = "0.10" +percent-encoding = "2" + +tauri = { version = "2.11.0", default-features = false, features = ["tray-icon", "dynamic-acl", "compression", "x11", "common-controls-v6", "dbus", "image-png"] } + +tauri-plugin-log = "2.8.0" +tauri-plugin-opener = "2.5.4" +tauri-plugin-os = "2" +tauri-plugin-deep-link = "2.4.9" +tauri-plugin-http = "2.5.9" +tauri-plugin-store = "2.4.3" +tauri-plugin-clipboard-manager = "2.3.2" + +[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.2", features = ["deep-link"] } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-window-state = "2.4.1" + +[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] +webkit2gtk = "2.0" +gtk = "0.18" + +[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "9d15b2b2c8798dc29ce723d743f0f3b4921d6a54", features = ["push-notifications", "unified-push"] } +tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } + +[target.'cfg(target_os = "android")'.dependencies] +tauri-plugin-android-fs = "=28.4.0" + +[features] +default = ["wry"] +custom-protocol = ["tauri/custom-protocol"] +wry = ["tauri/wry"] +cef = [] +[patch.crates-io] +tauri-typegen = { git = "https://github.com/SableClient/tauri-typegen", branch = "fix/nondeterministic-generation-cache" } + +# To test unreleased tauri features +# tauri = { git = "https://github.com/tauri-apps/tauri", branch = "dev" } +# tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "dev" } +# tauri-plugin = { git = "https://github.com/tauri-apps/tauri", branch = "dev" } +# tauri-utils = { git = "https://github.com/tauri-apps/tauri", branch = "dev" } +# tauri-macros = { git = "https://github.com/tauri-apps/tauri", branch = "dev" } +# tauri-runtime = { git = "https://github.com/tauri-apps/tauri", branch = "dev" } +# tauri-runtime-wry = { git = "https://github.com/tauri-apps/tauri", branch = "dev" } + +# To test the CEF runtime path, switch this back to `cef = ["tauri/cef"]` +# and uncomment the patch scaffold below. +# cef = ["tauri/cef"] +# +# [patch.crates-io] +# tauri = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" } +# tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" } +# tauri-plugin = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" } +# tauri-utils = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" } +# tauri-macros = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" } +# tauri-runtime = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" } +# tauri-runtime-wry = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef" } +# tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/cef" } +# tauri-plugin-opener = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/cef" } +# tauri-plugin-os = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/cef" } +# tauri-plugin-deep-link = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/cef" } +# tauri-plugin-http = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/cef" } +# tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/cef" } +# tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/cef" } +# tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "feat/cef" } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000000..2d0c193d40 --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,10 @@ + + + + + NSCameraUsageDescription + Sable needs camera access for video calls. + NSMicrophoneUsageDescription + Sable needs microphone access for voice and video calls. + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000000..c72d822f61 --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,8 @@ +fn main() { + println!("cargo:rustc-env=TS_RS_EXPORT_DIR=../src/app/generated/tauri"); + + tauri_typegen::BuildSystem::generate_at_build_time() + .expect("Failed to generate TypeScript bindings"); + + tauri_build::build() +} diff --git a/src-tauri/capabilities/android.json b/src-tauri/capabilities/android.json new file mode 100644 index 0000000000..c557d637e9 --- /dev/null +++ b/src-tauri/capabilities/android.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "android-capability", + "platforms": ["android"], + "windows": ["main"], + "permissions": ["android-fs:default"] +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000000..e014d123d6 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,45 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": ["*"], + "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", + "os:allow-platform", + "os:allow-version", + "os:allow-os-type", + "os:allow-family", + "os:allow-arch", + "os:allow-exe-extension", + "os:allow-locale", + "os:allow-hostname", + "opener:default", + "opener:allow-default-urls", + "clipboard-manager:allow-write-text", + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-image", + { + "identifier": "http:default", + "allow": [{ "url": "http://*" }, { "url": "https://*" }] + }, + { + "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 0000000000..0e665f7fdf --- /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", "store:default"] +} diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json new file mode 100644 index 0000000000..9a46cf55d3 --- /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", + "edge-to-edge:default", + "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 0000000000..ebe51d3bfa --- /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 0000000000..026c5af237 --- /dev/null +++ b/src-tauri/gen/android/.gitignore @@ -0,0 +1,21 @@ +*.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 +keystore.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/src-tauri/gen/android/app/.gitignore b/src-tauri/gen/android/app/.gitignore new file mode 100644 index 0000000000..6c4d56b4a2 --- /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 0000000000..10397a7805 --- /dev/null +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,92 @@ +import java.util.Properties +import java.io.FileInputStream + +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.client" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "moe.sable.client" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + signingConfigs { + create("release") { + val keystorePropertiesFile = rootProject.file("keystore.properties") + if (keystorePropertiesFile.exists()) { + val keystoreProperties = Properties() + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["password"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["password"] as String + } + } + } + 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") { + signingConfig = signingConfigs.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") + +// Native FCM push (Sygnal): applies only once google-services.json is added to this +// directory, so builds without Firebase configured still succeed. +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} 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 0000000000..b63990750b --- /dev/null +++ b/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,56 @@ +# 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 + +# Keep Android bridge symbols used by Wry/Tauri JNI on release builds. +# Without these, R8 can rename/remove methods like WryActivity.getId(), +# which tao resolves by method name via JNI. +-keep class moe.sable.client.* { + native ; +} + +-keep class moe.sable.client.WryActivity { + public (...); + + void setWebView(moe.sable.client.RustWebView); + java.lang.Class getAppClass(...); + java.lang.String getVersion(); + int startActivity(...); + int getId(); +} + +-keep class moe.sable.client.Ipc { + public (...); + + @android.webkit.JavascriptInterface public ; +} + +-keep class moe.sable.client.RustWebView { + public (...); + + void loadUrlMainThread(...); + void loadHTMLMainThread(...); + void evalScript(...); +} + +-keep class moe.sable.client.RustWebChromeClient,moe.sable.client.RustWebViewClient { + public (...); +} \ 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 0000000000..2b6fff7df0 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt new file mode 100644 index 0000000000..016cf63168 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt @@ -0,0 +1,11 @@ +package moe.sable.client + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } +} 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 0000000000..cc14f035a6 --- /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 0000000000..a4f78de59d --- /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 0000000000..94a4ffc173 --- /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 0000000000..f363fcecdc --- /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 0000000000..4fc244418b --- /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 0000000000..eb916ab9de --- /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 0000000000..e4011dc876 --- /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_adaptive_fore.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000000..3b15c6d1f2 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 120000 index 0000000000..2bb464f07c --- /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 0000000000..0e1d1415ef --- /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 100644 index 0000000000..9e7178aa75 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_notification.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 120000 index 0000000000..c5c8af7d59 --- /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_adaptive_fore.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000000..786b8e5d38 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 120000 index 0000000000..1dc8bd6601 --- /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 0000000000..de6c001a7e --- /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 100644 index 0000000000..805fe1c8e2 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_notification.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 120000 index 0000000000..c244d43ac8 --- /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_adaptive_fore.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000000..aa5ae418bd Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 120000 index 0000000000..a9ba70dfaa --- /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 0000000000..9756cb1f2e --- /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 100644 index 0000000000..f3d89b5bf9 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_notification.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 120000 index 0000000000..1832021dc3 --- /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_adaptive_fore.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000000..f75f45659a Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 120000 index 0000000000..20afdb7e59 --- /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 0000000000..f209887ca9 --- /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 100644 index 0000000000..854e9e43a9 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_notification.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 120000 index 0000000000..2c20016b0d --- /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_adaptive_fore.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000000..7645636dda Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 120000 index 0000000000..18a184d9f9 --- /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 0000000000..c7eff28348 --- /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 100644 index 0000000000..e6d61da79f Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_notification.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..fbc12a6cbb --- /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 0000000000..f8c6127d32 --- /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 0000000000..3aa4ae6f10 --- /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 0000000000..72d1fa7704 --- /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 0000000000..fbc12a6cbb --- /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 0000000000..782d63b993 --- /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 0000000000..a91af1aefb --- /dev/null +++ b/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,23 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.0") + classpath("com.google.gms:google-services:4.5.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 0000000000..5c55bba71c --- /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/client/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/client/kotlin/BuildTask.kt new file mode 100644 index 0000000000..f764e2ad9b --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/client/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/client/kotlin/RustPlugin.kt b/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/client/kotlin/RustPlugin.kt new file mode 100644 index 0000000000..4aa7fcaf67 --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/moe/sable/client/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 0000000000..2a7ec6959d --- /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 0000000000..e708b1c023 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 0000000000..c5f9a53c27 --- /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 0000000000..4f906e0c81 --- /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 0000000000..107acd32c4 --- /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 0000000000..39391166fb --- /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 0000000000..3dcc853099 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 0000000000..54c0186caa 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 0000000000..4955e9386b 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 0000000000..abc1e5ba66 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 0000000000..19a3a8b550 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 0000000000..9441421e54 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 0000000000..80eb62d4b9 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 0000000000..398bc40ec0 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 0000000000..9354e0048e 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 0000000000..61db66fcd8 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 0000000000..8c45719e92 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 0000000000..0e58ace924 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 0000000000..c3604882e3 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 0000000000..cf085a9072 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 0000000000..696a13d0cb --- /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 0000000000..afa6af3542 --- /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 0000000000..c4d06bb57a --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ 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 0000000000..06d7abf55e 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 0000000000..aa3bdd1a59 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 0000000000..a866e4f1c6 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 0000000000..e2d71f7d2a 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 0000000000..c080497e08 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 0000000000..cb6e67baeb 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 0000000000..417de2fa8c 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 0000000000..2550840251 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 0000000000..b33e134fb9 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 0000000000..1e2b2c45ad 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 0000000000..09f18a61e4 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 0000000000..bea0f2d250 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 0000000000..a865d7cb9e 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 0000000000..1c3ccf97dd 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 0000000000..7013b3e534 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 0000000000..8a3e2a860c 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 0000000000..6a119ae908 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 0000000000..f99ea54a08 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 0000000000..dab373e053 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 0000000000..334958c5d9 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 0000000000..652c0c8d54 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1A1C28 + \ 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 0000000000..7861362c82 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 0000000000..f4a17c308b 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 0000000000..9aa0b987db 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 0000000000..d0dc8dbb64 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 0000000000..fd053f3918 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 0000000000..fd053f3918 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 0000000000..ec9197293a 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 0000000000..49d8569209 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 0000000000..d7bbc3f232 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 0000000000..d7bbc3f232 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 0000000000..d97d1f1aa9 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 0000000000..fd053f3918 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 0000000000..639d4898fb 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 0000000000..639d4898fb 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 0000000000..0030a8adab 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 0000000000..4bb49bd7b9 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 0000000000..0030a8adab 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 0000000000..fc6144024e 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 0000000000..ab85d4e6f3 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 0000000000..83d677b848 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 0000000000..01b78d3ef1 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/desktop/mod.rs b/src-tauri/src/desktop/mod.rs new file mode 100644 index 0000000000..657ecd98e0 --- /dev/null +++ b/src-tauri/src/desktop/mod.rs @@ -0,0 +1,6 @@ +pub mod runtime_state; +pub mod settings; +pub mod tray; + +#[cfg(windows)] +pub mod windows; diff --git a/src-tauri/src/desktop/runtime_state.rs b/src-tauri/src/desktop/runtime_state.rs new file mode 100644 index 0000000000..f81982b826 --- /dev/null +++ b/src-tauri/src/desktop/runtime_state.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "desktop/", rename_all = "camelCase")] +pub struct DesktopRuntimeState { + pub tray_available: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use ts_rs::{Config, TS}; + + #[test] + fn desktop_runtime_state_serializes_with_camel_case_keys() { + let state = DesktopRuntimeState { + tray_available: true, + }; + + assert_eq!( + serde_json::to_value(state).unwrap(), + json!({ + "trayAvailable": true, + }) + ); + } + + #[test] + fn desktop_runtime_state_exports_to_typescript() { + let output = DesktopRuntimeState::export_to_string(&Config::new()).unwrap(); + + assert!(output.contains("type DesktopRuntimeState")); + assert!(output.contains("trayAvailable: boolean")); + } +} diff --git a/src-tauri/src/desktop/settings.rs b/src-tauri/src/desktop/settings.rs new file mode 100644 index 0000000000..6f9c58e129 --- /dev/null +++ b/src-tauri/src/desktop/settings.rs @@ -0,0 +1,101 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "desktop/", rename_all = "camelCase")] +pub struct DesktopSettings { + pub close_to_background_on_close: bool, + pub show_system_tray_icon: bool, +} + +pub(crate) const DESKTOP_SETTINGS_PATH: &str = "desktop-preferences.json"; +pub(crate) const CLOSE_TO_BACKGROUND_ON_CLOSE_KEY: &str = "closeToBackgroundOnClose"; +pub(crate) const SHOW_SYSTEM_TRAY_ICON_KEY: &str = "showSystemTrayIcon"; +pub(crate) const LEGACY_KEEP_BACKGROUND_RUNNING_KEY: &str = "keepBackgroundRunning"; + +pub(crate) fn tray_available_for_session(settings: DesktopSettings, tray_created: bool) -> bool { + settings.show_system_tray_icon && tray_created +} + +pub(crate) fn desktop_settings_from_values( + close_to_background_on_close: Option, + show_system_tray_icon: Option, + keep_background_running: Option, +) -> DesktopSettings { + DesktopSettings { + close_to_background_on_close: close_to_background_on_close.unwrap_or(true) + || keep_background_running.unwrap_or(false), + show_system_tray_icon: show_system_tray_icon.unwrap_or(true), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use ts_rs::{Config, TS}; + + #[test] + fn desktop_settings_serialize_with_camel_case_keys() { + let settings = DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: false, + }; + + assert_eq!( + serde_json::to_value(settings).unwrap(), + json!({ + "closeToBackgroundOnClose": true, + "showSystemTrayIcon": false, + }) + ); + } + + #[test] + fn desktop_settings_deserialize_from_camel_case_keys() { + assert_eq!( + serde_json::from_value::(json!({ + "closeToBackgroundOnClose": true, + "showSystemTrayIcon": false, + })) + .unwrap(), + DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: false, + } + ); + } + + #[test] + fn desktop_settings_export_to_typescript_with_camel_case_keys() { + let output = DesktopSettings::export_to_string(&Config::new()).unwrap(); + + assert!(output.contains("type DesktopSettings")); + assert!(output.contains("closeToBackgroundOnClose: boolean")); + assert!(output.contains("showSystemTrayIcon: boolean")); + assert!(!output.contains("keepBackgroundRunning: boolean")); + } + + #[test] + fn legacy_background_setting_keeps_close_behavior_enabled() { + assert_eq!( + desktop_settings_from_values(Some(false), Some(false), Some(true)), + DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: false, + } + ); + } + + #[test] + fn explicit_close_setting_stays_disabled_when_legacy_background_is_off() { + assert_eq!( + desktop_settings_from_values(Some(false), Some(true), Some(false)), + DesktopSettings { + close_to_background_on_close: false, + show_system_tray_icon: true, + } + ); + } +} diff --git a/src-tauri/src/desktop/tray.rs b/src-tauri/src/desktop/tray.rs new file mode 100644 index 0000000000..c756f03ca4 --- /dev/null +++ b/src-tauri/src/desktop/tray.rs @@ -0,0 +1,378 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::desktop::runtime_state::DesktopRuntimeState; +use crate::desktop::settings::{ + desktop_settings_from_values, tray_available_for_session, DesktopSettings, + CLOSE_TO_BACKGROUND_ON_CLOSE_KEY, DESKTOP_SETTINGS_PATH, LEGACY_KEEP_BACKGROUND_RUNNING_KEY, + SHOW_SYSTEM_TRAY_ICON_KEY, +}; +use serde_json::json; +use tauri::{ + menu::{Menu, MenuEvent, MenuItem}, + tray::TrayIconBuilder, + AppHandle, Manager, RunEvent, +}; +use tauri_plugin_store::StoreExt; + +#[cfg(not(target_os = "linux"))] +use tauri::tray::{MouseButton, TrayIconEvent}; + +const MAIN_TRAY_ID: &str = "main"; +const TRAY_MENU_SHOW_ID: &str = "tray_show"; +const TRAY_MENU_QUIT_ID: &str = "tray_quit"; + +pub struct DesktopSettingsState { + close_to_background_on_close: AtomicBool, + show_system_tray_icon: AtomicBool, + tray_available: AtomicBool, +} + +impl Default for DesktopSettingsState { + fn default() -> Self { + Self { + close_to_background_on_close: AtomicBool::new(true), + show_system_tray_icon: AtomicBool::new(true), + tray_available: AtomicBool::new(false), + } + } +} + +#[cfg(not(target_os = "linux"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TrayDoubleClickAction { + Ignore, + ShowOrCreateMainWindow, + CloseMainWindow, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExitRequestAction { + AllowExit, + CloseWindowsToBackground, +} + +#[cfg(not(target_os = "linux"))] +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(settings: DesktopSettings, code: Option) -> ExitRequestAction { + match (settings.close_to_background_on_close, code) { + (_, Some(_)) => ExitRequestAction::AllowExit, + (true, None) => ExitRequestAction::CloseWindowsToBackground, + (false, None) => ExitRequestAction::AllowExit, + } +} + +fn load_desktop_settings(app: &AppHandle) -> tauri::Result { + let store = app + .store_builder(DESKTOP_SETTINGS_PATH) + .defaults(std::collections::HashMap::from([ + (CLOSE_TO_BACKGROUND_ON_CLOSE_KEY.into(), json!(true)), + (SHOW_SYSTEM_TRAY_ICON_KEY.into(), json!(true)), + ])) + .build() + .map_err(|error| tauri::Error::PluginInitialization("store".into(), error.to_string()))?; + + Ok(desktop_settings_from_values( + store + .get(CLOSE_TO_BACKGROUND_ON_CLOSE_KEY) + .and_then(|value| value.as_bool()), + store + .get(SHOW_SYSTEM_TRAY_ICON_KEY) + .and_then(|value| value.as_bool()), + store + .get(LEGACY_KEEP_BACKGROUND_RUNNING_KEY) + .and_then(|value| value.as_bool()), + )) +} + +fn current_desktop_settings(app: &AppHandle) -> DesktopSettings { + let state = app.state::(); + DesktopSettings { + close_to_background_on_close: state.close_to_background_on_close.load(Ordering::Relaxed), + show_system_tray_icon: state.show_system_tray_icon.load(Ordering::Relaxed), + } +} + +fn desktop_runtime_state(app: &AppHandle) -> DesktopRuntimeState { + DesktopRuntimeState { + tray_available: app + .state::() + .tray_available + .load(Ordering::Relaxed), + } +} + +#[tauri::command] +pub fn get_desktop_runtime_state(app: AppHandle) -> DesktopRuntimeState { + desktop_runtime_state(&app) +} + +#[tauri::command] +pub fn sync_desktop_settings( + app: AppHandle, + settings: DesktopSettings, +) -> Result { + apply_desktop_settings(&app, settings).map_err(|error| error.to_string()) +} + +pub(crate) fn sync_desktop_settings_inner( + app: &AppHandle, +) -> tauri::Result { + let settings = load_desktop_settings(app)?; + apply_desktop_settings(app, settings) +} + +fn apply_desktop_settings( + app: &AppHandle, + settings: DesktopSettings, +) -> tauri::Result { + let state = app.state::(); + + state + .close_to_background_on_close + .store(settings.close_to_background_on_close, Ordering::Relaxed); + state + .show_system_tray_icon + .store(settings.show_system_tray_icon, Ordering::Relaxed); + + if settings.show_system_tray_icon { + if app.tray_by_id(MAIN_TRAY_ID).is_none() { + match create_system_tray(app) { + Ok(()) => state.tray_available.store( + tray_available_for_session(settings, true), + Ordering::Relaxed, + ), + Err(error) => { + log::warn!("Failed to initialize system tray: {error}"); + state.tray_available.store( + tray_available_for_session(settings, false), + Ordering::Relaxed, + ); + } + } + } else { + state.tray_available.store( + tray_available_for_session(settings, true), + Ordering::Relaxed, + ); + } + } else { + let _ = app.remove_tray_by_id(MAIN_TRAY_ID); + state.tray_available.store(false, Ordering::Relaxed); + } + + Ok(desktop_runtime_state(app)) +} + +#[cfg(not(target_os = "linux"))] +fn main_window_exists(app: &AppHandle) -> bool { + app.get_webview_window(crate::MAIN_WINDOW_LABEL).is_some() +} + +#[cfg(not(target_os = "linux"))] +fn close_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window(crate::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, +) { + let settings = current_desktop_settings(app); + if exit_request_action(settings, code) == ExitRequestAction::CloseWindowsToBackground { + api.prevent_exit(); + close_all_windows(app); + } +} + +#[cfg(not(target_os = "linux"))] +fn handle_tray_double_click(app: &AppHandle, event: &TrayIconEvent) { + match tray_double_click_action(main_window_exists(app), event) { + TrayDoubleClickAction::ShowOrCreateMainWindow => { + let _ = crate::show_or_create_main_window(app); + } + TrayDoubleClickAction::CloseMainWindow => { + close_main_window(app); + } + TrayDoubleClickAction::Ignore => {} + } +} + +pub fn handle_run_event(app: &AppHandle, event: RunEvent) { + if let RunEvent::ExitRequested { code, api, .. } = event { + handle_exit_request(app, code, &api); + } +} + +#[cfg(not(target_os = "linux"))] +fn configure_tray_icon_interactions( + builder: TrayIconBuilder, +) -> TrayIconBuilder { + builder + .show_menu_on_left_click(false) + .on_tray_icon_event(|tray, event| { + let app = tray.app_handle(); + handle_tray_double_click(app, &event); + }) +} + +#[cfg(target_os = "linux")] +fn configure_tray_icon_interactions( + builder: TrayIconBuilder, +) -> TrayIconBuilder { + builder +} + +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 = configure_tray_icon_interactions( + TrayIconBuilder::with_id(MAIN_TRAY_ID) + .menu(&tray_menu) + .on_menu_event(|app, event: MenuEvent| match event.id().as_ref() { + TRAY_MENU_SHOW_ID => { + let _ = crate::show_or_create_main_window(app); + } + TRAY_MENU_QUIT_ID => { + app.exit(0); + } + _ => {} + }), + ); + + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } + + tray_builder.build(app)?; + Ok(()) +} + +#[cfg(test)] +fn tray_icon_events_supported() -> bool { + !cfg!(target_os = "linux") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::desktop::settings::{ + desktop_settings_from_values, tray_available_for_session, DesktopSettings, + }; + + #[test] + fn close_behavior_keeps_sable_running() { + let settings = DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: false, + }; + + assert_eq!( + exit_request_action(settings, None), + ExitRequestAction::CloseWindowsToBackground + ); + } + + #[test] + fn tray_setting_does_not_keep_sable_running_on_its_own() { + let settings = DesktopSettings { + show_system_tray_icon: true, + close_to_background_on_close: false, + }; + + assert_eq!( + exit_request_action(settings, None), + ExitRequestAction::AllowExit + ); + } + + #[test] + fn tray_failure_still_closes_to_background_when_requested() { + let settings = DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: true, + }; + + assert_eq!( + exit_request_action(settings, None), + ExitRequestAction::CloseWindowsToBackground + ); + assert!(!tray_available_for_session(settings, false)); + } + + #[test] + fn explicit_quit_bypasses_background_mode() { + let settings = DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: true, + }; + + assert_eq!( + exit_request_action(settings, Some(0)), + ExitRequestAction::AllowExit + ); + } + + #[test] + fn missing_store_values_default_to_enabled() { + assert_eq!( + desktop_settings_from_values(None, None, None), + DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: true, + } + ); + } + + #[test] + fn legacy_background_store_value_migrates_to_close_behavior() { + assert_eq!( + desktop_settings_from_values(Some(false), Some(false), Some(true)), + DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: false, + } + ); + } + + #[test] + fn explicit_store_values_are_preserved_when_legacy_background_is_off() { + assert_eq!( + desktop_settings_from_values(Some(false), Some(false), Some(false)), + DesktopSettings { + show_system_tray_icon: false, + close_to_background_on_close: false, + } + ); + } + + #[test] + fn tray_icon_event_support_matches_platform() { + assert_eq!(tray_icon_events_supported(), !cfg!(target_os = "linux")); + } +} diff --git a/src-tauri/src/desktop/windows/mod.rs b/src-tauri/src/desktop/windows/mod.rs new file mode 100644 index 0000000000..f5ac1c7f6e --- /dev/null +++ b/src-tauri/src/desktop/windows/mod.rs @@ -0,0 +1,5 @@ +#[cfg(windows)] +pub mod snap_overlay; + +#[cfg(windows)] +pub mod window_tracking; diff --git a/src-tauri/src/desktop/windows/snap_overlay.rs b/src-tauri/src/desktop/windows/snap_overlay.rs new file mode 100644 index 0000000000..0dc27aa1fe --- /dev/null +++ b/src-tauri/src/desktop/windows/snap_overlay.rs @@ -0,0 +1,23 @@ +use enigo::{ + Direction::{Click, Press, Release}, + Enigo, Key, Keyboard, Settings, +}; + +#[tauri::command] +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] +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/desktop/windows/window_tracking.rs b/src-tauri/src/desktop/windows/window_tracking.rs new file mode 100644 index 0000000000..fa00afc7cb --- /dev/null +++ b/src-tauri/src/desktop/windows/window_tracking.rs @@ -0,0 +1,294 @@ +use std::{ + ffi::OsString, + os::windows::ffi::OsStringExt, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use serde::{Deserialize, Serialize}; +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)] +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)] +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)] +pub enum EventType { + Started, + TargetLost, + Timeout, + Stopped, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +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] +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] +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] +pub async fn is_window_tracking_active( + tracking_state: State<'_, Arc>, +) -> Result { + Ok(tracking_state.is_active()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000000..edfa467b03 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,294 @@ +#[cfg(desktop)] +mod desktop; +mod network; + +use tauri::{AppHandle, Manager}; +#[cfg(desktop)] +use tauri_plugin_window_state::StateFlags; + +// Runtime selection: CEF or Wry +#[cfg(feature = "cef")] +type BrowserEngine = tauri::Wry; +#[cfg(all(not(feature = "cef"), feature = "wry"))] +use tauri::Wry as BrowserEngine; + +pub const MAIN_WINDOW_LABEL: &str = "main"; + +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" +))] +fn prompt_webview_permission(message: &str) -> bool { + use gtk::prelude::*; + use gtk::{ButtonsType, DialogFlags, MessageDialog, MessageType, ResponseType}; + + let dialog = MessageDialog::new( + None::<>k::Window>, + DialogFlags::MODAL, + MessageType::Question, + ButtonsType::YesNo, + message, + ); + dialog.set_title("Permission request"); + let response = dialog.run(); + dialog.close(); + matches!(response, ResponseType::Yes) +} + +// Return the remembered decision for a webview permission, or prompt the user +// once and persist their choice for next time. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" +))] +fn resolve_webview_permission( + app: &AppHandle, + key: &str, + message: &str, +) -> bool { + use tauri_plugin_store::StoreExt; + + let store = app.store("permissions.json").ok(); + if let Some(allowed) = store + .as_ref() + .and_then(|store| store.get(key)) + .and_then(|value| value.as_bool()) + { + return allowed; + } + + let allowed = prompt_webview_permission(message); + + if let Some(store) = &store { + store.set(key, allowed); + let _ = store.save(); + } + + allowed +} + +pub fn show_or_create_main_window(app: &AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + #[cfg(desktop)] + { + window.unminimize()?; + window.show()?; + window.set_focus()?; + } + + return Ok(()); + } + + log::info!("Main window not found, creating a new one."); + + let builder = + tauri::WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, tauri::WebviewUrl::default()) + .disable_drag_drop_handler(); + + #[cfg(desktop)] + let builder = builder + .title(app.package_info().name.clone()) + .resizable(true) + .fullscreen(false) + .inner_size(1280.0, 720.0) + .visible(false); + + #[cfg(target_os = "windows")] + let builder = builder.decorations(false); + + let _webview_window = builder.build()?; + + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" + ))] + { + use tauri::Manager; + use webkit2gtk::glib::prelude::Cast; + use webkit2gtk::{ + GeolocationPermissionRequest, NotificationPermissionRequest, PermissionRequestExt, + SettingsExt, UserMediaPermissionRequest, WebViewExt, + }; + let app_handle = app.clone(); + if let Some(wv) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let _ = wv.with_webview(move |webview| { + let gtk_webview = webview.inner(); + if let Some(settings) = gtk_webview.settings() { + settings.set_enable_webrtc(true); + settings.set_enable_media_stream(true); + settings.set_enable_mediasource(true); + settings.set_enable_media(true); + } + gtk_webview.connect_permission_request(move |_wv, request| { + // Ask the user (once per permission type, then remember the choice) + // for the permissions Sable actually uses; deny anything else. + let decision = if request + .downcast_ref::() + .is_some() + { + Some(("media", "Allow Sable to use your camera and microphone?")) + } else if request + .downcast_ref::() + .is_some() + { + Some(("notifications", "Allow Sable to show desktop notifications?")) + } else if request + .downcast_ref::() + .is_some() + { + Some(("geolocation", "Allow Sable to access your location?")) + } else { + None + }; + + match decision { + Some((key, message)) + if resolve_webview_permission(&app_handle, key, message) => + { + request.allow(); + } + _ => request.deny(), + } + true + }); + }); + } + } + + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let builder = tauri::Builder::::new(); + + // macOS needs a standard menu (with the Edit submenu) for keyboard + // copy/paste/select-all to work in the webview. It lives in the system menu + // bar, so it is macOS-only to avoid an in-window menu bar elsewhere. + #[cfg(target_os = "macos")] + let builder = builder.menu(|handle| tauri::menu::Menu::default(handle)); + + #[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 + .plugin(tauri_plugin_store::Builder::default().build()) + .manage(desktop::tray::DesktopSettingsState::default()); + + #[cfg(windows)] + let builder = builder.manage(std::sync::Arc::new( + desktop::windows::window_tracking::TrackingState::new(), + )); + + #[cfg(desktop)] + let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + let _ = show_or_create_main_window(app); + })); + + #[cfg(mobile)] + let builder = builder.plugin(tauri_plugin_notifications::init()); + + #[cfg(mobile)] + let builder = builder.plugin(tauri_plugin_edge_to_edge::init()); + + #[cfg(target_os = "android")] + let builder = builder.plugin(tauri_plugin_android_fs::init()); + + let app = builder + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_os::init()) + .manage(network::media_protocol::MediaSessionState::default()) + .register_asynchronous_uri_scheme_protocol( + network::media_protocol::MEDIA_URI_SCHEME, + network::media_protocol::respond, + ) + .setup(|app| { + #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] + { + use tauri_plugin_deep_link::DeepLinkExt; + app.deep_link().register_all()?; + } + + show_or_create_main_window(app.handle())?; + + #[cfg(desktop)] + desktop::tray::sync_desktop_settings_inner(app.handle())?; + + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + network::loopback_http::abort_loopback_fetch, + network::loopback_http::loopback_fetch, + network::media_protocol::set_media_session, + network::media_protocol::clear_media_session, + #[cfg(desktop)] + desktop::tray::get_desktop_runtime_state, + #[cfg(desktop)] + desktop::tray::sync_desktop_settings, + #[cfg(windows)] + desktop::windows::snap_overlay::show_snap_overlay, + #[cfg(windows)] + desktop::windows::snap_overlay::hide_snap_overlay, + #[cfg(windows)] + desktop::windows::window_tracking::start_window_tracking_with_target, + #[cfg(windows)] + desktop::windows::window_tracking::stop_window_tracking, + #[cfg(windows)] + desktop::windows::window_tracking::is_window_tracking_active, + ]) + .build(tauri::generate_context!()); + + let Ok(app) = app else { + // On Android this function is called through JNI. Panicking here causes + // "panic_cannot_unwind" and aborts the whole process without a useful + // message in logcat. + eprintln!("failed to build tauri application: {app:?}"); + return; + }; + + app.run(|app, event| { + #[cfg(desktop)] + desktop::tray::handle_run_event(app, event); + + #[cfg(not(desktop))] + let _ = (app, event); + }); +} + +#[cfg(test)] +mod tests { + #[test] + fn desktop_modules_are_grouped_under_desktop() { + let _ = crate::desktop::settings::DesktopSettings { + close_to_background_on_close: true, + show_system_tray_icon: true, + }; + let _ = crate::desktop::runtime_state::DesktopRuntimeState { + tray_available: true, + }; + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000000..af842c6052 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,80 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + #[cfg(target_os = "linux")] + unsafe { + use std::path::{Path, PathBuf}; + + // Tao/Tauri Wayland decorations are don't respect server side decorations, forcing GTK onto X11/XWayland for now. + // https://github.com/tauri-apps/tao/issues/1046 + // https://github.com/tauri-apps/tauri/issues/11856 + // https://github.com/tauri-apps/tauri/issues/14251 + std::env::set_var("GDK_BACKEND", "x11"); + + // NVIDIA explicit sync is another upstream WebKitGTK/Wayland failure mode. Prefer this lower-cost workaround over WEBKIT_DISABLE_DMABUF_RENDERER=1, but don't stomp an explicit user override. + // https://github.com/tauri-apps/tauri/issues/10702 + // https://github.com/tauri-apps/tauri/issues/9394 + if std::env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() { + std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1"); + } + + // WebKit2GTK can hit compositor/DMABUF bugs + // https://github.com/tauri-apps/tauri/issues/14424 + // https://github.com/tauri-apps/tauri/issues/9394 + if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + + // AppImage can fail to discover host GStreamer plugins/scanner. Probe + // common distro layouts, but don't override explicit user config. + // Not finding these plugings prevents Sable from launching correctly. + // Maybe there's a better way to do this? + let plugin_dirs = [ + "/usr/lib/gstreamer-1.0", + "/usr/lib64/gstreamer-1.0", + "/usr/local/lib/gstreamer-1.0", + "/usr/local/lib64/gstreamer-1.0", + "/usr/lib/x86_64-linux-gnu/gstreamer-1.0", + "/usr/lib/aarch64-linux-gnu/gstreamer-1.0", + "/run/host/usr/lib/gstreamer-1.0", + "/run/host/usr/lib64/gstreamer-1.0", + ]; + let resolved_plugin_dir = plugin_dirs.iter().find(|dir| Path::new(dir).exists()); + + if std::env::var_os("GST_PLUGIN_SYSTEM_PATH_1_0").is_none() { + if let Some(dir) = resolved_plugin_dir { + std::env::set_var("GST_PLUGIN_SYSTEM_PATH_1_0", dir); + } + } + if std::env::var_os("GST_PLUGIN_PATH_1_0").is_none() { + if let Some(dir) = resolved_plugin_dir { + std::env::set_var("GST_PLUGIN_PATH_1_0", dir); + } + } + if std::env::var_os("GST_PLUGIN_SCANNER").is_none() { + let mut scanner_candidates: Vec = vec![ + PathBuf::from("/usr/lib/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/usr/lib64/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/usr/libexec/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/usr/lib/x86_64-linux-gnu/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/usr/lib/aarch64-linux-gnu/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/run/host/usr/lib/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/run/host/usr/lib64/gstreamer-1.0/gst-plugin-scanner"), + ]; + + if let Some(path_env) = std::env::var_os("PATH") { + scanner_candidates.extend(std::env::split_paths(&path_env).map(|p| p.join("gst-plugin-scanner"))); + } + + if let Some(scanner) = scanner_candidates.iter().find(|path| path.exists()) { + std::env::set_var("GST_PLUGIN_SCANNER", scanner.as_os_str()); + } + } + } + + app_lib::run(); +} diff --git a/src-tauri/src/network/loopback_http.rs b/src-tauri/src/network/loopback_http.rs new file mode 100644 index 0000000000..a599b2196d --- /dev/null +++ b/src-tauri/src/network/loopback_http.rs @@ -0,0 +1,196 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use serde::{Deserialize, Serialize}; +use tauri_plugin_http::reqwest::{ + header::{HeaderMap, HeaderName, HeaderValue}, + ClientBuilder, Method, Url, +}; +use tokio::sync::watch; + +static LOOPBACK_ABORT_SENDERS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoopbackFetchRequest { + request_id: String, + method: String, + url: String, + headers: Vec<(String, String)>, + body: Option>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoopbackFetchResponse { + status: u16, + status_text: String, + url: String, + headers: Vec<(String, String)>, + body: Vec, +} + +fn validate_loopback_url(url: &str) -> Result { + let parsed = Url::parse(url.trim()).map_err(|err| err.to_string())?; + + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err("loopback_fetch only allows loopback http:// or https:// URLs".into()); + } + + match parsed.host_str() { + Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]") => Ok(parsed), + _ => Err("loopback_fetch only allows loopback http:// or https:// URLs".into()), + } +} + +fn build_headers(headers: Vec<(String, String)>) -> Result { + let mut header_map = HeaderMap::new(); + + for (name, value) in headers { + let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| err.to_string())?; + let header_value = HeaderValue::from_str(&value).map_err(|err| err.to_string())?; + header_map.append(header_name, header_value); + } + + Ok(header_map) +} + +fn register_abort_sender(request_id: &str) -> watch::Receiver { + let (sender, receiver) = watch::channel(false); + LOOPBACK_ABORT_SENDERS + .lock() + .expect("loopback abort senders poisoned") + .insert(request_id.to_owned(), sender); + receiver +} + +fn remove_abort_sender(request_id: &str) { + LOOPBACK_ABORT_SENDERS + .lock() + .expect("loopback abort senders poisoned") + .remove(request_id); +} + +async fn wait_for_abort_signal(receiver: &mut watch::Receiver) { + if *receiver.borrow() { + return; + } + + while receiver.changed().await.is_ok() { + if *receiver.borrow() { + return; + } + } + + std::future::pending::<()>().await; +} + +#[tauri::command] +pub fn abort_loopback_fetch(request_id: String) { + if let Some(sender) = LOOPBACK_ABORT_SENDERS + .lock() + .expect("loopback abort senders poisoned") + .remove(&request_id) + { + let _ = sender.send(true); + } +} + +#[tauri::command] +pub async fn loopback_fetch( + request: LoopbackFetchRequest, +) -> Result { + let request_id = request.request_id.clone(); + let mut abort_receiver = register_abort_sender(&request_id); + let url = validate_loopback_url(&request.url)?; + + let result = async { + let method = Method::from_bytes(request.method.as_bytes()).map_err(|err| err.to_string())?; + let headers = build_headers(request.headers)?; + let mut req = ClientBuilder::new() + .no_proxy() + .build() + .map_err(|err| err.to_string())? + .request(method, url) + .headers(headers); + + if let Some(body) = request.body { + req = req.body(body); + } + + let response = tokio::select! { + response = req.send() => response.map_err(|err| err.to_string())?, + _ = wait_for_abort_signal(&mut abort_receiver) => return Err("Loopback request aborted".into()), + }; + let status = response.status(); + let status_text = status + .canonical_reason() + .map(str::to_owned) + .unwrap_or_else(|| status.as_str().to_owned()); + let url = response.url().to_string(); + let headers = response + .headers() + .iter() + .filter_map(|(name, value)| value.to_str().ok().map(|text| (name.to_string(), text.to_owned()))) + .collect(); + let body = tokio::select! { + body = response.bytes() => body.map_err(|err| err.to_string())?.to_vec(), + _ = wait_for_abort_signal(&mut abort_receiver) => return Err("Loopback request aborted".into()), + }; + + Ok(LoopbackFetchResponse { + status: status.as_u16(), + status_text, + url, + headers, + body, + }) + } + .await; + + remove_abort_sender(&request_id); + result +} + +#[cfg(test)] +mod tests { + use super::{abort_loopback_fetch, register_abort_sender, validate_loopback_url}; + use tokio::time::{timeout, Duration}; + + #[test] + fn allows_loopback_http_hosts() { + assert!(validate_loopback_url("http://localhost:8008").is_ok()); + assert!(validate_loopback_url("https://localhost:8448").is_ok()); + assert!(validate_loopback_url("http://127.0.0.1:8008").is_ok()); + assert!(validate_loopback_url("https://127.0.0.1:8448").is_ok()); + assert!(validate_loopback_url("http://[::1]:8008").is_ok()); + assert!(validate_loopback_url("https://[::1]:8448").is_ok()); + assert!(validate_loopback_url("http://localhost/_matrix/client/versions").is_ok()); + } + + #[test] + fn rejects_non_loopback_or_non_http_urls() { + assert!(validate_loopback_url("http://192.168.1.5:8008").is_err()); + assert!(validate_loopback_url("https://matrix.example.org").is_err()); + assert!(validate_loopback_url("http://localhost.evil.example.org").is_err()); + assert!(validate_loopback_url("http://localhost:8008@evil.example.org").is_err()); + } + + #[test] + fn abort_command_signals_registered_requests() { + tauri::async_runtime::block_on(async { + let mut receiver = register_abort_sender("request-1"); + abort_loopback_fetch("request-1".into()); + + timeout(Duration::from_secs(1), receiver.changed()) + .await + .expect("abort signal timed out") + .expect("abort receiver unexpectedly closed"); + + assert!(*receiver.borrow()); + }); + } +} diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs new file mode 100644 index 0000000000..3fe43fef93 --- /dev/null +++ b/src-tauri/src/network/media_protocol.rs @@ -0,0 +1,206 @@ +use std::{ + fs, + path::PathBuf, + sync::{OnceLock, RwLock}, +}; + +use sha2::{Digest, Sha256}; +use tauri::{ + http::{header, Request, Response, StatusCode, Uri}, + AppHandle, Manager, Runtime, UriSchemeContext, UriSchemeResponder, +}; +use tauri_plugin_http::reqwest::{ + header::{AUTHORIZATION, CONTENT_TYPE}, + Client, Url, +}; + +pub const MEDIA_URI_SCHEME: &str = "sable-media"; + +const MEDIA_PATH_PREFIXES: [&str; 2] = ["/_matrix/media/", "/_matrix/client/v1/media/"]; +const CACHE_SUBDIR: &str = "sable-media"; + +#[derive(Default)] +pub struct MediaSessionState { + inner: RwLock>, + client: OnceLock, +} + +impl MediaSessionState { + // Shared across requests so the connection pool and TLS sessions stay warm. + fn client(&self) -> Client { + self.client.get_or_init(Client::new).clone() + } +} + +#[derive(Clone)] +struct MediaSession { + origin: String, + token: String, +} + +#[tauri::command] +pub fn set_media_session( + state: tauri::State<'_, MediaSessionState>, + base_url: String, + token: String, +) -> Result<(), String> { + let origin = Url::parse(&base_url) + .map_err(|err| err.to_string())? + .origin() + .ascii_serialization(); + + let mut guard = state + .inner + .write() + .map_err(|_| "media session lock poisoned".to_string())?; + *guard = Some(MediaSession { origin, token }); + Ok(()) +} + +#[tauri::command] +pub fn clear_media_session( + app: AppHandle, + state: tauri::State<'_, MediaSessionState>, +) { + if let Ok(mut guard) = state.inner.write() { + *guard = None; + } + if let Ok(dir) = cache_dir(&app) { + let _ = fs::remove_dir_all(dir); + } +} + +pub fn respond( + ctx: UriSchemeContext<'_, R>, + request: Request>, + responder: UriSchemeResponder, +) { + let app = ctx.app_handle().clone(); + let uri = request.uri().clone(); + tauri::async_runtime::spawn(async move { + let response = handle_request(&app, uri).await.unwrap_or_else(error_response); + responder.respond(response); + }); +} + +async fn handle_request( + app: &AppHandle, + uri: Uri, +) -> Result>, StatusCode> { + let target = percent_encoding::percent_decode_str(uri.path().trim_start_matches('/')) + .decode_utf8() + .map_err(|_| StatusCode::BAD_REQUEST)? + .into_owned(); + + let session = { + let state = app.state::(); + let guard = state + .inner + .read() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + guard.clone().ok_or(StatusCode::UNAUTHORIZED)? + }; + + let media_url = Url::parse(&target).map_err(|_| StatusCode::BAD_REQUEST)?; + if media_url.scheme() != "http" && media_url.scheme() != "https" { + return Err(StatusCode::FORBIDDEN); + } + if media_url.origin().ascii_serialization() != session.origin { + return Err(StatusCode::FORBIDDEN); + } + if !MEDIA_PATH_PREFIXES + .iter() + .any(|prefix| media_url.path().starts_with(prefix)) + { + return Err(StatusCode::FORBIDDEN); + } + + let key = cache_key(&target); + let dir = cache_dir(app).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let body_path = dir.join(&key); + let content_type_path = dir.join(format!("{key}.ct")); + + if let (Ok(body), Ok(content_type)) = + (fs::read(&body_path), fs::read_to_string(&content_type_path)) + { + return Ok(ok_response(body, &content_type)); + } + + let client = app.state::().client(); + let upstream = client + .get(media_url) + .header(AUTHORIZATION, format!("Bearer {}", session.token)) + .send() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + if !upstream.status().is_success() { + return Err(StatusCode::from_u16(upstream.status().as_u16()) + .unwrap_or(StatusCode::BAD_GATEWAY)); + } + + let content_type = upstream + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_owned(); + let body = upstream + .bytes() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)? + .to_vec(); + + if fs::create_dir_all(&dir).is_ok() { + let _ = fs::write(&body_path, &body); + let _ = fs::write(&content_type_path, &content_type); + } + + Ok(ok_response(body, &content_type)) +} + +fn ok_response(body: Vec, content_type: &str) -> Response> { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .body(body) + .expect("failed to build media response") +} + +fn error_response(status: StatusCode) -> Response> { + Response::builder() + .status(status) + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .body(Vec::new()) + .expect("failed to build media error response") +} + +fn cache_dir(app: &AppHandle) -> Result { + app.path() + .app_cache_dir() + .map(|dir| dir.join(CACHE_SUBDIR)) + .map_err(|err| err.to_string()) +} + +fn cache_key(url: &str) -> String { + let digest = Sha256::digest(url.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::cache_key; + + #[test] + fn cache_key_is_stable_and_hex() { + let key = cache_key("https://matrix.example.org/_matrix/client/v1/media/download/x/y"); + assert_eq!(key.len(), 64); + assert!(key.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!( + key, + cache_key("https://matrix.example.org/_matrix/client/v1/media/download/x/y") + ); + } +} diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs new file mode 100644 index 0000000000..1f6c79b863 --- /dev/null +++ b/src-tauri/src/network/mod.rs @@ -0,0 +1,2 @@ +pub mod loopback_http; +pub mod media_protocol; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000000..580f540adf --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,59 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "sable", + "version": "0.1.0", + "identifier": "moe.sable.client", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:8080", + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build" + }, + "app": { + "windows": [], + "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:; style-src 'self' 'unsafe-inline' blob: data: filesystem: http: https: tauri:; img-src 'self' data: blob: filesystem: http: https: sable-media:; media-src 'self' blob: data: http: https: sable-media:; connect-src 'self' ipc: ws: wss: http: https: http://ipc.localhost sable-media: http://sable-media.localhost" + } + }, + "bundle": { + "active": true, + "targets": "all", + "macOS": { + "infoPlist": "Info.plist" + }, + "linux": { + "deb": { + "depends": ["xwayland", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav", "gstreamer1.0-nice", "geoclue-2.0"] + }, + "rpm": { + "depends": ["xorg-x11-server-Xwayland", "gstreamer1-plugins-good", "gstreamer1-plugins-bad-free", "gstreamer1-libav", "gstreamer1-plugins-nice", "geoclue2"] + } + }, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + }, + "plugins": { + "typegen": { + "projectPath": ".", + "outputPath": "../src/app/generated/tauri", + "validationLibrary": "none", + "verbose": false + }, + "deep-link": { + "desktop": { + "schemes": ["sable", "moe.sable.app"] + }, + "mobile": [ + { + "scheme": ["sable", "moe.sable.app"], + "appLink": false + } + ] + } + } +} diff --git a/src/app/components/AuthFlowsLoader.tsx b/src/app/components/AuthFlowsLoader.tsx index b25ff14244..9c0e96d33f 100644 --- a/src/app/components/AuthFlowsLoader.tsx +++ b/src/app/components/AuthFlowsLoader.tsx @@ -7,6 +7,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { promiseFulfilledResult, promiseRejectedResult } from '$utils/common'; import type { AuthFlows, RegisterFlowsResponse } from '$hooks/useAuthFlows'; import { RegisterFlowStatus, parseRegisterErrResp } from '$hooks/useAuthFlows'; +import { fetch } from '$utils/fetch'; type AuthFlowsLoaderProps = { fallback?: () => ReactNode; @@ -17,7 +18,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr const autoDiscoveryInfo = useAutoDiscoveryInfo(); const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url; - const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); + const mx = useMemo(() => createClient({ baseUrl, fetchFn: fetch }), [baseUrl]); const [state, load] = useAsyncCallback( useCallback(async () => { diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx index 6097be06ef..b601773701 100644 --- a/src/app/components/ClientConfigLoader.tsx +++ b/src/app/components/ClientConfigLoader.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Button, Dialog, Text, color, config } from 'folds'; import type { ClientConfig } from '$hooks/useClientConfig'; import { trimTrailingSlash } from '$utils/common'; +import { fetch } from '$utils/fetch'; import { SplashScreen } from '$components/splash-screen'; export const FALLBACK_CLIENT_CONFIG: ClientConfig = { diff --git a/src/app/components/SpecVersionsLoader.tsx b/src/app/components/SpecVersionsLoader.tsx index 8dbfe1305c..c053b0bdfb 100644 --- a/src/app/components/SpecVersionsLoader.tsx +++ b/src/app/components/SpecVersionsLoader.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { fetch } from '$utils/fetch'; import type { SpecVersions } from '../cs-api'; import { specVersions } from '../cs-api'; diff --git a/src/app/components/SyncConnectionStatus.tsx b/src/app/components/SyncConnectionStatus.tsx new file mode 100644 index 0000000000..ec6ef2505d --- /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 'framer-motion'; +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/app-shell/AppShell.tsx b/src/app/components/app-shell/AppShell.tsx new file mode 100644 index 0000000000..b9aa3642f7 --- /dev/null +++ b/src/app/components/app-shell/AppShell.tsx @@ -0,0 +1,80 @@ +import { type ReactNode, Suspense, lazy, useState } from 'react'; +import { Provider as JotaiProvider } from 'jotai'; +import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; + +import { TauriFrontendReady } from '$components/tauri/TauriFrontendReady'; +import { WindowsTitleBar } from '$components/tauri/WindowsTitleBar'; +import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import { isReactQueryDevtoolsEnabled } from '$pages/reactQueryDevtoolsGate'; +import { SystemBarShell } from './SystemBarShell'; + +const ReactQueryDevtools = lazy(async () => { + const { ReactQueryDevtools: Devtools } = await import('@tanstack/react-query-devtools'); + + return { default: Devtools }; +}); + +type AppShellProps = { + children: ReactNode; + queryClient: Parameters[0]['client']; + screenSize: ScreenSize; +}; + +export function AppShell({ children, queryClient, screenSize }: AppShellProps) { + const tauriOs = isTauri() ? osType() : undefined; + const useCustomWindowsTitleBar = tauriOs === 'windows'; + const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); + const contentHeight = useCustomWindowsTitleBar + ? 'calc(100% - var(--tauri-titlebar-height))' + : '100%'; + const [portalContainer, setPortalContainer] = useState(null); + + return ( + + + + + + + +
+ {useCustomWindowsTitleBar && } +
+ + {children} + +
+
+
+ {reactQueryDevtoolsEnabled && ( + + + + )} +
+
+
+
+
+ ); +} diff --git a/src/app/components/app-shell/SystemBarShell.tsx b/src/app/components/app-shell/SystemBarShell.tsx new file mode 100644 index 0000000000..4410ec74db --- /dev/null +++ b/src/app/components/app-shell/SystemBarShell.tsx @@ -0,0 +1,81 @@ +import { type ReactNode } from 'react'; +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; + +const safeAreaTop = 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))'; +const safeAreaBottom = 'var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))'; +const safeAreaLeft = 'var(--safe-area-inset-left, env(safe-area-inset-left, 0px))'; +const safeAreaRight = 'var(--safe-area-inset-right, env(safe-area-inset-right, 0px))'; + +type SystemBarStripProps = { + position: 'top' | 'bottom'; + size: string; +}; + +function SystemBarStrip({ position, size }: SystemBarStripProps) { + return ( +
+
+
+ ); +} + +type SystemBarShellProps = { + children: ReactNode; + onPortalContainerChange: (node: HTMLDivElement | null) => void; +}; + +export function SystemBarShell({ children, onPortalContainerChange }: SystemBarShellProps) { + const tauriOs = isTauri() ? osType() : undefined; + const enabled = tauriOs === 'android' || tauriOs === 'ios'; + + return ( + <> + {enabled && } + +
+
+ {children} +
+ +
+
+ + {enabled && } + + ); +} diff --git a/src/app/components/app-shell/index.ts b/src/app/components/app-shell/index.ts new file mode 100644 index 0000000000..d9728686de --- /dev/null +++ b/src/app/components/app-shell/index.ts @@ -0,0 +1,2 @@ +export { AppShell } from './AppShell'; +export { SystemBarShell } from './SystemBarShell'; diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index 6d8cbb2975..d2475b123d 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -93,7 +93,7 @@ function RenderEmoticonElement({ {element.key.startsWith('mxc://') ? ( {element.shortcode} ) : ( diff --git a/src/app/components/emoji-board/components/Preview.tsx b/src/app/components/emoji-board/components/Preview.tsx index 178ac6074e..623557517b 100644 --- a/src/app/components/emoji-board/components/Preview.tsx +++ b/src/app/components/emoji-board/components/Preview.tsx @@ -37,7 +37,7 @@ export function Preview({ previewAtom }: PreviewProps) { {key.startsWith('mxc://') ? ( {shortcode} ) : ( diff --git a/src/app/components/image-pack-view/ImageTile.tsx b/src/app/components/image-pack-view/ImageTile.tsx index 0038ca7342..0f84a3907b 100644 --- a/src/app/components/image-pack-view/ImageTile.tsx +++ b/src/app/components/image-pack-view/ImageTile.tsx @@ -42,7 +42,7 @@ export function ImageTile({ before={ {image.shortcode} @@ -166,7 +166,7 @@ export function ImageTileEdit({ before={ {image.shortcode} diff --git a/src/app/components/image-pack-view/PackMeta.tsx b/src/app/components/image-pack-view/PackMeta.tsx index 2b6e6ce213..2024827cc5 100644 --- a/src/app/components/image-pack-view/PackMeta.tsx +++ b/src/app/components/image-pack-view/PackMeta.tsx @@ -24,6 +24,7 @@ import { useObjectURL } from '$hooks/useObjectURL'; import type { UploadSuccess } from '$state/upload'; import { createUploadAtom } from '$state/upload'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { PackMetaReader } from '$plugins/custom-emoji'; import { CompactUploadCardRenderer } from '$components/upload-card'; @@ -32,10 +33,11 @@ type ImagePackAvatarProps = { name?: string; }; function ImagePackAvatar({ url, name }: ImagePackAvatarProps) { + const resolvedUrl = useRenderableMediaUrl(url); return ( {url ? ( - + ) : ( {nameInitials(name ?? 'Unknown')} diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx new file mode 100644 index 0000000000..f8e5c6e882 --- /dev/null +++ b/src/app/components/image-viewer/ImageViewer.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import FileSaver from 'file-saver'; +import { ImageViewer } from './ImageViewer'; + +const downloadMedia = vi.fn(); + +vi.mock('$hooks/useImageGestures', () => ({ + useImageGestures: () => ({ + transforms: { zoom: 1, pan: { x: 0, y: 0 } }, + cursor: 'grab', + handleWheel: vi.fn(), + onPointerDown: vi.fn(), + resetTransforms: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }), +})); + +vi.mock('$utils/matrix', () => ({ + downloadMedia: (...args: unknown[]) => downloadMedia(...args), +})); + +vi.mock('file-saver', () => ({ + default: { + saveAs: vi.fn(), + }, +})); + +describe('ImageViewer', () => { + it('downloads media without passing a media token argument', async () => { + downloadMedia.mockResolvedValue(new Blob(['image'])); + + render( + + ); + + fireEvent.click(screen.getByText('Download')); + + await waitFor(() => { + expect(downloadMedia).toHaveBeenCalledWith('https://example.org/kitten.png'); + }); + expect(FileSaver.saveAs).toHaveBeenCalledWith(expect.any(Blob), 'kitten.png'); + }); +}); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 55df2294bd..ab3fde5624 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,6 +1,5 @@ import type { MouseEventHandler } from 'react'; import { useEffect, useRef, useState } from 'react'; -import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, @@ -37,7 +36,7 @@ import { CheckerboardIcon, CopyIcon, DownloadIcon } from '@phosphor-icons/react' import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '$utils/keyboard'; import { copyImageToClipboard } from '$utils/dom'; -import { getDownloadFilename } from '$utils/download'; +import { getDownloadFilename, saveFileToDevice } from '$utils/download'; export type ImageViewerProps = { alt: string; @@ -100,7 +99,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const handleDownload = async () => { const fileContent = await downloadMedia(src); - FileSaver.saveAs(fileContent, getDownloadFilename(filename, alt, 'image')); + await saveFileToDevice(fileContent, getDownloadFilename(filename, alt, 'image')); }; const [menuAnchor, setMenuAnchor] = useState(); diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index b7cc7fc777..02fe9afd22 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -3,13 +3,12 @@ import { Download, sizedIcon } from '$components/icons/phosphor'; import type { ReactNode } from 'react'; import { useCallback } from 'react'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import FileSaver from 'file-saver'; import { mimeTypeToExt } from '$utils/mimeTypes'; 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 { getDownloadFilename } from '$utils/download'; +import { getDownloadFilename, saveFileToDevice } from '$utils/download'; const badgeStyles = { maxWidth: toRem(100) }; @@ -32,7 +31,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow : await downloadMedia(mediaUrl); const fileURL = URL.createObjectURL(fileContent); - FileSaver.saveAs(fileURL, getDownloadFilename(filename)); + await saveFileToDevice(fileContent, getDownloadFilename(filename), mimeType); return fileURL; }, [mx, url, useAuthentication, mimeType, encInfo, filename]) ); diff --git a/src/app/components/message/MsgTypeRenderers.test.tsx b/src/app/components/message/MsgTypeRenderers.test.tsx new file mode 100644 index 0000000000..0f30649340 --- /dev/null +++ b/src/app/components/message/MsgTypeRenderers.test.tsx @@ -0,0 +1,115 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { MAudio, MFile, MImage, MSticker, MVideo } from './MsgTypeRenderers'; + +vi.mock('$utils/platform', () => ({ + hasServiceWorker: () => false, + hasControllingServiceWorker: () => false, +})); + +vi.mock('$utils/fetch', () => ({ + fetch: globalThis.fetch, +})); + +describe('incoming media renderers', () => { + it('rejects arbitrary image URLs', () => { + const renderImageContent = vi.fn(() => rendered); + + render( + + ); + + expect(renderImageContent).not.toHaveBeenCalled(); + expect(document.body).toHaveTextContent('Broken message: remote image'); + }); + + it('renders mxc image URLs', () => { + const renderImageContent = vi.fn(() => rendered); + + render( + + ); + + expect(renderImageContent).toHaveBeenCalledWith( + expect.objectContaining({ url: 'mxc://example.org/image' }) + ); + }); + + it('does not pass arbitrary video URLs to the video renderer', () => { + const renderAsFile = vi.fn(() => file fallback); + const renderVideoContent = vi.fn(() =>
); } -); +); \ No newline at end of file diff --git a/src/app/features/room/location-modal/LocationDialog.tsx b/src/app/features/room/location-modal/LocationDialog.tsx index 8df359f2cf..9241066b1a 100644 --- a/src/app/features/room/location-modal/LocationDialog.tsx +++ b/src/app/features/room/location-modal/LocationDialog.tsx @@ -15,6 +15,7 @@ import { import { ClipboardIcon, MapPinAreaIcon, MapPinLineIcon } from '@phosphor-icons/react'; import { chipIcon, composerIcon, Warning, X } from '$components/icons/phosphor'; import { stopPropagation } from '$utils/keyboard'; +import { readClipboardText } from '$utils/dom'; import type { IContent, MatrixClient, Room } from 'matrix-js-sdk'; import * as css from './LocationDialog.css'; import type { IReplyDraft } from '$state/room/roomInputDrafts'; @@ -195,8 +196,7 @@ export function LocationDialog({ } function getClipboard() { - navigator.clipboard - .readText() + readClipboardText() .then((result: string) => { const coords = filterLocationString(result); storeLocation(coords); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 81293f9a19..53f7062b5e 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -48,7 +48,6 @@ import { SwipeableMessageWrapper } from '$components/SwipeableMessageWrapper'; import { mobileOrTablet } from '$utils/user-agent'; import { useUserProfile } from '$hooks/useUserProfile'; import { useSetting } from '$state/hooks/settings'; -import { useBlobCache } from '$hooks/useBlobCache'; import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns'; import type { PronounSet } from '$utils/pronouns'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; @@ -58,6 +57,7 @@ import { MessageEditor } from './MessageEditor'; import * as css from './styles.css'; import { modalAtom, ModalType } from '$state/modal'; import { OptionQuickMenu } from '$components/message/modals/Options'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -465,7 +465,7 @@ function MessageInternal( return mxc ? mxcUrlToHttp(mx, mxc, useAuthentication, 48, 48, 'crop') : undefined; }, [pmp, memberAvatarMxc, profile.avatarUrl, mx, useAuthentication]); - const cachedAvatar = useBlobCache(avatarUrl ?? undefined); + const cachedAvatar = useRenderableMediaUrl(avatarUrl ?? undefined); // UI State const [isDesktopHover, setIsDesktopHover] = useState(false); diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 90654dc3e2..a66ce3fe64 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import type { ComponentType } from 'react'; -import type { IconProps } from '@phosphor-icons/react'; +import { DesktopIcon, type IconProps } from '@phosphor-icons/react'; import { Avatar, Box, @@ -61,6 +61,7 @@ import { settingsHeader } from './styles.css'; import { useSettingsFocus } from './useSettingsFocus'; import { SettingsLinkProvider } from './SettingsLinkContext'; import { useSettingsLinkBaseUrl } from './useSettingsLinkBaseUrl'; +import { Desktop } from './desktop'; export enum SettingsPages { GeneralPage, @@ -68,6 +69,7 @@ export enum SettingsPages { PerMessageProfilesPage, NotificationPage, DevicesPage, + DesktopPage, EmojisStickersPage, CosmeticsPage, DeveloperToolsPage, @@ -95,6 +97,7 @@ export const settingsMenuIcons: Record< appearance: { icon: Palette }, notifications: { icon: Bell }, devices: { icon: DevicesIcon }, + desktop: { icon: DesktopIcon }, emojis: { icon: Smiley }, 'developer-tools': { icon: Terminal }, experimental: { icon: Flask }, @@ -108,6 +111,7 @@ const settingsPageToSectionId: Record = { [SettingsPages.PerMessageProfilesPage]: 'persona', [SettingsPages.NotificationPage]: 'notifications', [SettingsPages.DevicesPage]: 'devices', + [SettingsPages.DesktopPage]: 'desktop', [SettingsPages.EmojisStickersPage]: 'emojis', [SettingsPages.CosmeticsPage]: 'appearance', [SettingsPages.DeveloperToolsPage]: 'developer-tools', @@ -123,6 +127,7 @@ const settingsSectionIdToPage: Record = { appearance: SettingsPages.CosmeticsPage, notifications: SettingsPages.NotificationPage, devices: SettingsPages.DevicesPage, + desktop: SettingsPages.DesktopPage, emojis: SettingsPages.EmojisStickersPage, 'developer-tools': SettingsPages.DeveloperToolsPage, experimental: SettingsPages.ExperimentalPage, @@ -132,7 +137,7 @@ const settingsSectionIdToPage: Record = { const settingsSectionComponents: Record< SettingsSectionId, - (props: { requestBack?: () => void; requestClose: () => void }) => JSX.Element + (props: { requestBack?: () => void; requestClose: () => void }) => JSX.Element | null > = { general: General, account: Account, @@ -140,6 +145,7 @@ const settingsSectionComponents: Record< appearance: Cosmetics, notifications: Notifications, devices: Devices, + desktop: Desktop, emojis: EmojisStickers, 'developer-tools': DeveloperTools, experimental: Experimental, diff --git a/src/app/features/settings/desktop/Desktop.test.tsx b/src/app/features/settings/desktop/Desktop.test.tsx new file mode 100644 index 0000000000..f2405404dc --- /dev/null +++ b/src/app/features/settings/desktop/Desktop.test.tsx @@ -0,0 +1,128 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import { Desktop } from './Desktop'; + +const { + mockUseDesktopSetting, + mockUseDesktopSettingsReady, + mockUseDesktopRuntimeState, + mockUseDesktopSettingsSyncing, +} = vi.hoisted(() => ({ + mockUseDesktopSetting: vi.fn((key: 'closeToBackgroundOnClose' | 'showSystemTrayIcon') => { + if (key === 'closeToBackgroundOnClose') return [true, vi.fn()] as const; + return [true, vi.fn()] as const; + }), + mockUseDesktopSettingsReady: vi.fn(() => true), + mockUseDesktopSettingsSyncing: vi.fn(() => false), + mockUseDesktopRuntimeState: vi.fn(() => ({ + trayAvailable: false, + })), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => true, +})); + +vi.mock('$state/hooks/desktopSettings', () => ({ + useDesktopSetting: mockUseDesktopSetting, + useDesktopSettingsReady: mockUseDesktopSettingsReady, + useDesktopSettingsSyncing: mockUseDesktopSettingsSyncing, + useDesktopRuntimeState: mockUseDesktopRuntimeState, +})); + +vi.mock('folds', async () => { + const actual = await vi.importActual('folds'); + return { + ...actual, + Switch: ({ + value, + onChange, + disabled, + 'aria-label': ariaLabel, + }: { + value: boolean; + onChange: (nextValue: boolean) => void; + disabled?: boolean; + 'aria-label'?: string; + }) => ( + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + {options.map((option) => ( + handleSelect(option.value)} + > + + {option.label} + + + ))} + + + + } + /> + + ); +} + +function NotificationTransportOverrideInput({ + focusId, + title, + description, + name, + value, + placeholder, + onSave, +}: { + focusId: string; + title: string; + description: string; + name: string; + value: string; + placeholder: string; + onSave: (value: string) => void; +}) { + const [draftValue, setDraftValue] = useState(value); + + useEffect(() => { + setDraftValue(value); + }, [value]); + + const hasChanges = draftValue !== value; + + const handleReset = () => { + setDraftValue(value); + }; + + const handleSubmit = (evt: FormEvent) => { + evt.preventDefault(); + onSave(draftValue); + }; + + return ( + + + + + setDraftValue(evt.currentTarget.value)} + style={{ paddingRight: config.space.S200 }} + after={ + hasChanges && ( + + + + ) + } + /> + + + + + + ); +} + +function labelUnifiedPushDistributorOption(distributor: string): string { + const lastSegment = distributor + .split(/[./]/) + .map((segment) => segment.trim()) + .filter(Boolean) + .at(-1); + + return lastSegment ?? distributor; +} + +function BackgroundPushNotificationSetting() { const mx = useMatrixClient(); const clientConfig = useClientConfig(); - const [isLoading, setIsLoading] = useState(true); - const [usePushNotifications, setPushNotifications] = useSetting( + const pushTransportDefaults = { + unifiedPushGatewayUrl: + clientConfig.pushTransport?.unifiedPushGatewayUrl ?? + clientConfig.pushNotificationDetails?.unifiedPushGatewayUrl, + unifiedPushAppID: + clientConfig.pushTransport?.unifiedPushAppID ?? + clientConfig.pushNotificationDetails?.unifiedPushAppID, + unifiedPushDistributor: clientConfig.pushTransport?.unifiedPushDistributor, + }; + const [backgroundPushEnabled, setBackgroundPushEnabled] = useSetting( + settingsAtom, + 'backgroundPushEnabled' + ); + const [backgroundPushProvider, setBackgroundPushProvider] = useSetting( + settingsAtom, + 'backgroundPushProvider' + ); + const [pushTransportMode, setPushTransportMode] = useSetting(settingsAtom, 'pushTransportMode'); + const [pushTransportOverride, setPushTransportOverride] = useSetting( + settingsAtom, + 'pushTransportOverride' + ); + const [legacyPushNotifications, setLegacyPushNotifications] = useSetting( settingsAtom, 'usePushNotifications' ); + const [legacyUnifiedPush, setLegacyUnifiedPush] = useSetting(settingsAtom, 'useUnifiedPush'); const pushSubAtom = useAtom(pushSubscriptionAtom); - + const [upEndpoint, setUpEndpoint] = useAtom(unifiedPushEndpointAtom); + const unifiedPushStateRef = useRef(upEndpoint); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedDistributor, setSelectedDistributor] = useState( + pushTransportOverride.unifiedPushDistributor ?? '' + ); + const [availableDistributors, setAvailableDistributors] = useState([]); const browserPermission = usePermissionState('notifications', getNotificationState()); + const isTauriRuntime = isTauri(); + const runtimePlatform = getBackgroundPushPlatform(isTauriRuntime); + const supportedModes = getSupportedNotificationTransportModes(runtimePlatform); + const selectedTransportMode = normalizeNotificationTransportMode( + pushTransportMode, + runtimePlatform + ); + const preferredKind = resolvePreferredNotificationTransportProvider( + selectedTransportMode, + runtimePlatform + ); + const effectiveKind = backgroundPushEnabled + ? (backgroundPushProvider ?? preferredKind) + : preferredKind; + const effectivePushTransport = mergePushConfig(pushTransportDefaults, pushTransportOverride); + const backgroundPushSupported = supportedModes.length > 0; + const showUnifiedPushSettings = + runtimePlatform === 'android' && + (selectedTransportMode === 'auto' || selectedTransportMode === 'unifiedpush'); + const nativePushConfigError = + effectiveKind === 'native' ? getNativePushConfigError(clientConfig) : null; + const modeOptions = supportedModes.map((mode) => ({ + value: mode, + label: labelTransportMode(mode), + })); + const distributorOptions = Array.from( + new Set( + [selectedDistributor, ...availableDistributors].filter( + (distributor): distributor is string => distributor.trim().length > 0 + ) + ) + ).map((distributor) => ({ + value: distributor, + label: labelUnifiedPushDistributorOption(distributor), + })); + useEffect(() => { - setIsLoading(false); - }, []); - const handleRequestPermissionAndEnable = async () => { + unifiedPushStateRef.current = upEndpoint; + }, [upEndpoint]); + + useEffect(() => { + const sync = deriveLegacyPushSync({ + enabled: backgroundPushEnabled, + provider: backgroundPushEnabled ? (backgroundPushProvider ?? preferredKind) : null, + }); + + if (legacyPushNotifications !== sync.usePushNotifications) { + setLegacyPushNotifications(sync.usePushNotifications); + } + if (legacyUnifiedPush !== sync.useUnifiedPush) { + setLegacyUnifiedPush(sync.useUnifiedPush); + } + }, [ + backgroundPushEnabled, + backgroundPushProvider, + preferredKind, + legacyPushNotifications, + legacyUnifiedPush, + setLegacyPushNotifications, + setLegacyUnifiedPush, + ]); + + useEffect(() => { + if (runtimePlatform !== 'android') { + setAvailableDistributors([]); + setIsLoading(false); + return undefined; + } + + let active = true; + loadUnifiedPushDistributorState() + .then((state) => { + if (!active) return; + setAvailableDistributors(state.distributors); + const overrideDistributor = pushTransportOverride.unifiedPushDistributor; + const nextDistributor = overrideDistributor || state.selectedDistributor; + if (nextDistributor) { + setSelectedDistributor(nextDistributor); + if (!overrideDistributor) { + setPushTransportOverride((current) => + current.unifiedPushDistributor === nextDistributor + ? current + : { + ...current, + unifiedPushDistributor: nextDistributor, + } + ); + } + } + }) + .catch((caughtError) => { + if (!active) return; + setError(caughtError instanceof Error ? caughtError.message : String(caughtError)); + }) + .finally(() => { + if (active) setIsLoading(false); + }); + + return () => { + active = false; + }; + }, [runtimePlatform, pushTransportOverride.unifiedPushDistributor, setPushTransportOverride]); + + const updatePushTransportOverride = (patch: Partial) => { + setPushTransportOverride((current) => + cleanPushTransportOverrides({ + ...current, + ...patch, + }) + ); + }; + + const buildUnifiedPushTransportConfig = (): UnifiedPushTransportConfigInput => ({ + unifiedPushGatewayUrl: effectivePushTransport.unifiedPushGatewayUrl, + unifiedPushAppID: effectivePushTransport.unifiedPushAppID, + }); + + const buildRegisteredUnifiedPushState = ( + registration: { + endpoint: string; + instance: string; + distributor?: string; + pubKeySet?: { + pubKey: string; + auth: string; + }; + }, + distributorOverride?: string + ): UnifiedPushState => ({ + endpoint: registration.endpoint, + instance: registration.instance, + appId: effectivePushTransport.unifiedPushAppID?.trim() ?? DEFAULT_UNIFIED_PUSH_APP_ID, + gatewayUrl: effectivePushTransport.unifiedPushGatewayUrl?.trim() ?? undefined, + status: 'registered', + distributor: distributorOverride ?? registration.distributor, + permissionState: 'granted', + pubKeySet: registration.pubKeySet, + }); + + const setUnifiedPushEndpointState = (endpoint: UnifiedPushState) => { + unifiedPushStateRef.current = endpoint; + setUpEndpoint(endpoint); + }; + + const ensureConfiguredUnifiedPushDistributor = async (): Promise => { + const distributor = await ensureUnifiedPushDistributorSelection( + availableDistributors, + selectedDistributor || effectivePushTransport.unifiedPushDistributor || '' + ); + + if (!distributor) { + return ''; + } + + setSelectedDistributor(distributor); + updatePushTransportOverride({ unifiedPushDistributor: distributor }); + return distributor; + }; + + const activateTransport = async (kind: BackgroundPushKind | null) => { + if (!kind) { + throw new Error('Background push is not available on this platform.'); + } + + if (kind === 'web') { + if (browserPermission === 'prompt') { + const permissionResult = await requestBrowserNotificationPermission(); + if (permissionResult !== 'granted') { + throw new Error('Browser notification permission was not granted.'); + } + } + await enablePushNotifications(mx, clientConfig, pushSubAtom); + return; + } + + if (kind === 'unifiedpush') { + const distributor = await ensureConfiguredUnifiedPushDistributor(); + if (!distributor) { + throw new Error('No UnifiedPush distributor selected.'); + } + const result = await enableUnifiedPush(mx, buildUnifiedPushTransportConfig()); + setUnifiedPushEndpointState( + buildRegisteredUnifiedPushState( + { + ...result, + }, + distributor + ) + ); + return; + } + + if (nativePushConfigError) { + throw new Error(nativePushConfigError); + } + + await enableNativePush(mx, clientConfig); + }; + + const deactivateTransport = async (kind: BackgroundPushKind | null) => { + if (!kind) return; + + if (kind === 'web') { + await disablePushNotifications(mx, clientConfig, pushSubAtom); + return; + } + + if (kind === 'unifiedpush') { + const currentUnifiedPushState = unifiedPushStateRef.current; + await disableUnifiedPush(mx, { + pushkey: currentUnifiedPushState?.endpoint, + config: { + unifiedPushAppID: + currentUnifiedPushState?.appId ?? effectivePushTransport.unifiedPushAppID, + }, + }); + setUnifiedPushEndpointState(null); + return; + } + + await disableNativePush(mx, clientConfig); + }; + + const activateAndroidAutoTransport = async ( + currentKind: BackgroundPushKind | null + ): Promise => { + const nativeFallback = async (failureReason: string): Promise => { + const configError = getNativePushConfigError(clientConfig); + if (configError) { + throw new Error(`${failureReason} Native push fallback is unavailable: ${configError}`); + } + + if (currentKind === 'native') { + return 'native'; + } + + await activateTransport('native'); + return 'native'; + }; + + const distributor = await ensureConfiguredUnifiedPushDistributor(); + if (!distributor) { + return nativeFallback('UnifiedPush is not configured.'); + } + + const result = await tryEnableUnifiedPush(mx, buildUnifiedPushTransportConfig()); + if (result.status === 'registered') { + setUnifiedPushEndpointState(buildRegisteredUnifiedPushState(result)); + return 'unifiedpush'; + } + + if (result.status === 'temp-unavailable') { + throw new Error(result.error); + } + + return nativeFallback(result.error); + }; + + const activateMode = async ( + mode: NotificationTransportMode, + currentKind: BackgroundPushKind | null + ): Promise => { + const normalizedMode = normalizeNotificationTransportMode(mode, runtimePlatform); + const nextPreferredKind = resolvePreferredNotificationTransportProvider( + normalizedMode, + runtimePlatform + ); + + if (!nextPreferredKind) { + throw new Error('Selected transport is not available on this platform.'); + } + + if (normalizedMode === 'auto' && runtimePlatform === 'android') { + if (currentKind === 'unifiedpush') { + return 'unifiedpush'; + } + return activateAndroidAutoTransport(currentKind); + } + + if (currentKind === nextPreferredKind) { + return currentKind; + } + + await activateTransport(nextPreferredKind); + return nextPreferredKind; + }; + + const handleToggleBackgroundPush = async (wantsPush: boolean) => { setIsLoading(true); + setError(null); try { - const permissionResult = await requestBrowserNotificationPermission(); - if (permissionResult === 'granted') { - await enablePushNotifications(mx, clientConfig, pushSubAtom); - setPushNotifications(true); + if (!backgroundPushSupported) { + throw new Error('Background push is not available in the desktop Tauri build yet.'); + } + if (wantsPush) { + const nextKind = await activateMode(selectedTransportMode, null); + setBackgroundPushProvider(nextKind); + } else { + await deactivateTransport(backgroundPushProvider ?? preferredKind); + setBackgroundPushProvider(null); } + setBackgroundPushEnabled(wantsPush); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : String(caughtError)); } finally { setIsLoading(false); } }; - const handlePushSwitchChange = async (wantsPush: boolean) => { + const handleModeChange = async (nextMode: NotificationTransportMode) => { + if (nextMode === selectedTransportMode) return; setIsLoading(true); + setError(null); + const previousKind = backgroundPushEnabled ? (backgroundPushProvider ?? preferredKind) : null; try { - if (wantsPush) { - await enablePushNotifications(mx, clientConfig, pushSubAtom); + if (backgroundPushEnabled) { + const nextKind = await switchBackgroundPushTransport({ + previousKind, + activate: () => activateMode(nextMode, previousKind), + deactivate: deactivateTransport, + }); + setBackgroundPushProvider(nextKind); } else { - await disablePushNotifications(mx, clientConfig, pushSubAtom); + setBackgroundPushProvider(null); } - setPushNotifications(wantsPush); + setPushTransportMode(nextMode); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : String(caughtError)); } finally { setIsLoading(false); } }; - return ( - - Permission blocked. Please allow notifications in your browser settings. - - ) : ( - 'Receive notifications when the app is closed or in the background.' - ) - } - after={ - isLoading ? ( - - ) : browserPermission === 'prompt' ? ( - - ) : browserPermission === 'granted' ? ( - - ) : null + const handleDistributorChange = async (distributor: string) => { + if (distributor === selectedDistributor) return; + setIsLoading(true); + setError(null); + try { + const activeKind = backgroundPushEnabled ? (backgroundPushProvider ?? preferredKind) : null; + if (backgroundPushEnabled && activeKind === 'unifiedpush') { + const result = await switchUnifiedPushDistributorSelection( + distributor, + selectedDistributor, + () => enableUnifiedPush(mx, buildUnifiedPushTransportConfig()) + ); + setUnifiedPushEndpointState( + buildRegisteredUnifiedPushState( + { + ...result, + }, + distributor + ) + ); + } else { + await setUnifiedPushDistributorSelection(distributor); } - /> + setSelectedDistributor(distributor); + updatePushTransportOverride({ unifiedPushDistributor: distributor }); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : String(caughtError)); + } finally { + setIsLoading(false); + } + }; + + const transportDescription = (() => { + if (error) { + return ( + + {error} + + ); + } + + if (!backgroundPushSupported) { + return ( + + Background push is not available in the desktop Tauri build yet. + + ); + } + + if (!backgroundPushEnabled) { + return 'Receive notifications when the app is closed or in the background.'; + } + + if (nativePushConfigError) { + return ( + + {nativePushConfigError} + + ); + } + + if (browserPermission === 'denied' && effectiveKind === 'web') { + return ( + + Permission blocked. Please allow notifications in your browser settings. + + ); + } + + if (!effectiveKind) { + return 'Receive notifications when the app is closed or in the background.'; + } + + return `Background push is using ${labelTransportKind(effectiveKind)}.`; + })(); + + const renderTransportToggle = () => { + if (isLoading) { + return ; + } + + if (!backgroundPushSupported) { + return ; + } + + if (!backgroundPushEnabled && nativePushConfigError) { + return ; + } + + if (!backgroundPushEnabled && effectiveKind === 'web' && browserPermission === 'prompt') { + return ( + + ); + } + + return ; + }; + + return ( + <> + + {supportedModes.length > 2 && ( + + } + /> + )} + {showUnifiedPushSettings && ( + <> + 0 ? ( + + ) : undefined + } + > + {distributorOptions.length === 0 && ( + + No UnifiedPush distributors were detected yet. + + )} + + + updatePushTransportOverride({ unifiedPushGatewayUrl: nextValue }) + } + /> + updatePushTransportOverride({ unifiedPushAppID: nextValue })} + /> + + )} + ); } @@ -175,10 +989,6 @@ export function SystemNotification() { settingsAtom, 'isNotificationSounds' ); - const [backgroundNotificationSounds, setBackgroundNotificationSounds] = useSetting( - settingsAtom, - 'backgroundNotificationSounds' - ); const [showMessageContent, setShowMessageContent] = useSetting( settingsAtom, 'showMessageContentInNotifications' @@ -247,16 +1057,6 @@ export function SystemNotification() { after={} /> - {mobileOrTablet() && ( - - - - )} {!mobileOrTablet() && ( - } - /> + - } + title="In-App Notification Sound" + focusId="in-app-notification-sound" + description="Play a sound inside the app when a new message arrives." + after={} /> ); -} +} \ No newline at end of file diff --git a/src/app/features/settings/notifications/TauriNotificationsApiClient.ts b/src/app/features/settings/notifications/TauriNotificationsApiClient.ts new file mode 100644 index 0000000000..8753b09d2d --- /dev/null +++ b/src/app/features/settings/notifications/TauriNotificationsApiClient.ts @@ -0,0 +1,35 @@ +export type NotificationPluginListener = { + unregister: () => Promise | void; +}; + +export type TauriNotificationsApi = { + Importance: { + readonly Default: string; + }; + createChannel: (channel: { + id: string; + name: string; + description?: string; + importance?: string; + vibration?: boolean; + }) => Promise; + sendNotification: (payload: Record) => Promise; + removeActive: (payload: Array<{ id: number; tag?: string }>) => Promise; + onUnifiedPushMessage: ( + listener: (payload: Record) => void + ) => Promise | NotificationPluginListener; + onUnifiedPushEndpoint: ( + listener: (payload: { endpoint: string; instance: string }) => void + ) => Promise | NotificationPluginListener; +}; + +let notificationsApiPromise: Promise | null = null; + +export async function getTauriNotificationsApi(): Promise { + if (!notificationsApiPromise) { + notificationsApiPromise = + import('@sableclient/tauri-plugin-notifications-api') as Promise; + } + + return notificationsApiPromise; +} diff --git a/src/app/features/settings/notifications/UnifiedPushMessageListener.test.ts b/src/app/features/settings/notifications/UnifiedPushMessageListener.test.ts new file mode 100644 index 0000000000..8bf1a67f16 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushMessageListener.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createUnifiedPushMessageListener } from './UnifiedPushMessageListener'; + +describe('createUnifiedPushMessageListener', () => { + it('catches rejected payload handlers instead of leaking unhandled rejections', async () => { + const onError = vi.fn(); + const listener = createUnifiedPushMessageListener(async () => { + throw new Error('boom'); + }, onError); + + expect(listener({})).toBeUndefined(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + }); +}); diff --git a/src/app/features/settings/notifications/UnifiedPushMessageListener.ts b/src/app/features/settings/notifications/UnifiedPushMessageListener.ts new file mode 100644 index 0000000000..b7e6d26404 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushMessageListener.ts @@ -0,0 +1,11 @@ +export type UnifiedPushMessageHandler = (data: Record) => Promise; +export type UnifiedPushMessageErrorHandler = (error: unknown) => void; + +export function createUnifiedPushMessageListener( + handler: UnifiedPushMessageHandler, + onError: UnifiedPushMessageErrorHandler +) { + return (data: Record) => { + handler(data).catch(onError); + }; +} diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts new file mode 100644 index 0000000000..e1a4caab23 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_UNIFIED_PUSH_APP_ID, + disableUnifiedPush, + tryEnableUnifiedPush, +} from './UnifiedPushNotifications'; + +const notificationsApi = vi.hoisted(() => ({ + onUnifiedPushMessage: vi.fn(), + onUnifiedPushEndpoint: vi.fn(), + sendNotification: vi.fn(), + removeActive: vi.fn(), + createChannel: vi.fn(), + Importance: { + Default: 'default', + }, +})); + +const unifiedPushTransport = vi.hoisted(() => ({ + getUnifiedPushDistributor: vi.fn(), + getUnifiedPushDistributors: vi.fn(), + registerUnifiedPushTransport: vi.fn(), + saveUnifiedPushDistributor: vi.fn(), + unregisterUnifiedPushTransport: vi.fn(), +})); + +const getTauriNotificationsApi = vi.hoisted(() => vi.fn().mockResolvedValue(notificationsApi)); + +const matrixClient = vi.hoisted(() => ({ + setPusher: vi.fn().mockResolvedValue(undefined), + getDeviceId: vi.fn(() => 'DEVICE'), + getDevice: vi.fn().mockResolvedValue({ display_name: 'Pixel' }), + getPushers: vi.fn().mockResolvedValue({ pushers: [] }), +})); + +vi.mock('./UnifiedPushTransport', () => unifiedPushTransport); + +vi.mock('./TauriNotificationsApiClient', () => ({ + getTauriNotificationsApi, +})); + +vi.mock('$utils/fetch', () => ({ + fetch: (...args: Parameters) => globalThis.fetch(...args), +})); + +describe('UnifiedPushNotifications', () => { + beforeEach(() => { + notificationsApi.createChannel.mockResolvedValue(undefined); + getTauriNotificationsApi.mockResolvedValue(notificationsApi); + unifiedPushTransport.registerUnifiedPushTransport.mockResolvedValue({ + status: 'registered', + permissionState: 'granted', + endpoint: 'https://up.example/device', + instance: 'instance-1', + distributor: 'org.unifiedpush.distributor.ntfy', + }); + unifiedPushTransport.unregisterUnifiedPushTransport.mockResolvedValue(undefined); + matrixClient.setPusher.mockClear(); + matrixClient.getPushers.mockResolvedValue({ pushers: [] }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('gateway probe failed'))); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('registers the Matrix pusher with the resolved UnifiedPush overrides', async () => { + await expect( + tryEnableUnifiedPush(matrixClient as never, { + unifiedPushAppID: 'com.example.up', + unifiedPushGatewayUrl: ' https://gateway.example/_matrix/push/v1/notify ', + }) + ).resolves.toMatchObject({ + status: 'registered', + endpoint: 'https://up.example/device', + instance: 'instance-1', + }); + + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'http', + app_id: 'com.example.up', + pushkey: 'https://up.example/device', + data: expect.objectContaining({ + url: 'https://gateway.example/_matrix/push/v1/notify', + }), + }) + ); + }, 15_000); + + it('clears the UnifiedPush registration timeout after successful registration', async () => { + vi.useFakeTimers(); + + try { + await expect(tryEnableUnifiedPush(matrixClient as never)).resolves.toMatchObject({ + status: 'registered', + }); + + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('falls back to the default UnifiedPush app id when no override is provided', async () => { + await tryEnableUnifiedPush(matrixClient as never); + + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + app_id: DEFAULT_UNIFIED_PUSH_APP_ID, + }) + ); + }); + + it('removes current-device UnifiedPush pushers when the cached endpoint is unavailable', async () => { + matrixClient.getPushers.mockResolvedValue({ + pushers: [ + { + app_id: 'com.example.up', + pushkey: 'stale-endpoint-1', + device_display_name: 'Pixel', + kind: 'http', + }, + { + app_id: 'com.example.up', + pushkey: 'stale-endpoint-2', + device_display_name: 'Pixel', + kind: 'http', + }, + { + app_id: 'com.example.up', + pushkey: 'other-device-endpoint', + device_display_name: 'Other Phone', + kind: 'http', + }, + ], + }); + + await disableUnifiedPush(matrixClient as never, { + config: { + unifiedPushAppID: 'com.example.up', + }, + }); + + expect(matrixClient.setPusher).toHaveBeenCalledTimes(2); + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + kind: null, + app_id: 'com.example.up', + pushkey: 'stale-endpoint-1', + }) + ); + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + kind: null, + app_id: 'com.example.up', + pushkey: 'stale-endpoint-2', + }) + ); + expect(unifiedPushTransport.unregisterUnifiedPushTransport).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts new file mode 100644 index 0000000000..b450cb4a4a --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -0,0 +1,702 @@ +import { IPusherRequest, MatrixClient } from '$types/matrix-sdk'; +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 { createDebugLogger } from '$utils/debugLogger'; +import { fetch } from '$utils/fetch'; +import { + getUnifiedPushDistributor, + getUnifiedPushDistributors, + registerUnifiedPushTransport, + saveUnifiedPushDistributor, + type UnifiedPushRegistrationResult, + unregisterUnifiedPushTransport, +} from './UnifiedPushTransport'; +import { createUnifiedPushMessageListener } from './UnifiedPushMessageListener'; +import type { PushTransportConfig } from './NotificationTransport'; +import { getTauriNotificationsApi } from './TauriNotificationsApiClient'; + +export { getUnifiedPushDistributors, getUnifiedPushDistributor, saveUnifiedPushDistributor }; + +const UP_PUBLIC_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify'; +export const DEFAULT_UNIFIED_PUSH_APP_ID = 'moe.sable.up'; +const unifiedPushLog = createDebugLogger('unifiedpush'); + +/** + * 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, + upInstance?: string +): Promise { + const probeCandidates = [upInstance, upEndpoint].filter( + (candidate): candidate is string => !!candidate?.trim() + ); + + const probeCandidate = async (candidate: string): Promise => { + try { + const probeUrl = new URL(candidate); + probeUrl.pathname = '/_matrix/push/v1/notify'; + probeUrl.search = ''; + const res = await fetch(probeUrl.toString()); + if (!res.ok) return undefined; + + const body = await res.json(); + if ( + body?.gateway === 'matrix' || + (body?.unifiedpush && body.unifiedpush.gateway === 'matrix') + ) { + return probeUrl.toString(); + } + } catch { + // Probe failed (network error, invalid URL, etc) + } + return undefined; + }; + + const probeAtIndex = async (index: number): Promise => { + if (index >= probeCandidates.length) return undefined; + + const candidate = probeCandidates[index]; + if (candidate === undefined) return undefined; + const result = await probeCandidate(candidate); + if (result) return result; + + return probeAtIndex(index + 1); + }; + + const discoveredGateway = await probeAtIndex(0); + return discoveredGateway ?? unifiedPushGateway ?? UP_PUBLIC_GATEWAY; +} + +const UP_REGISTER_TIMEOUT_MS = 30_000; + +export type UnifiedPushTransportConfigInput = Pick< + PushTransportConfig, + 'unifiedPushGatewayUrl' | 'unifiedPushAppID' +>; + +type UnifiedPushPusherConfig = { + appId: string; + gatewayUrl?: string; +}; + +function trimConfigValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function resolveUnifiedPushPusherConfig( + config?: UnifiedPushTransportConfigInput +): UnifiedPushPusherConfig { + return { + appId: trimConfigValue(config?.unifiedPushAppID) ?? DEFAULT_UNIFIED_PUSH_APP_ID, + gatewayUrl: trimConfigValue(config?.unifiedPushGatewayUrl), + }; +} + +export type EnableUnifiedPushResult = + | { + status: 'registered'; + endpoint: string; + instance: string; + gatewayUrl: string; + distributor: string; + pubKeySet?: { + pubKey: string; + auth: string; + }; + } + | Exclude; + +async function registerUnifiedPushWithTimeout(): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error('UnifiedPush registration timed out')); + }, UP_REGISTER_TIMEOUT_MS); + }); + + try { + return await Promise.race([registerUnifiedPushTransport(), timeout]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} + +export async function tryEnableUnifiedPush( + mx: MatrixClient, + config?: UnifiedPushTransportConfigInput +): Promise { + const notificationsApi = await getTauriNotificationsApi(); + + await notificationsApi.createChannel({ + id: 'messages', + name: 'Messages', + description: 'Matrix message and invite notifications', + importance: notificationsApi.Importance.Default, + vibration: true, + }); + + const registration = await registerUnifiedPushWithTimeout(); + + if (registration.status !== 'registered') { + return registration; + } + + const { endpoint, instance, pubKeySet } = registration; + const resolvedConfig = resolveUnifiedPushPusherConfig(config); + const gatewayUrl = await discoverGateway(endpoint, resolvedConfig.gatewayUrl, instance); + + 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: resolvedConfig.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 { + status: 'registered', + endpoint, + instance, + gatewayUrl, + distributor: registration.distributor, + pubKeySet, + }; +} + +export async function enableUnifiedPush( + mx: MatrixClient, + config?: UnifiedPushTransportConfigInput +): Promise<{ endpoint: string; instance: string; gatewayUrl: string }> { + const result = await tryEnableUnifiedPush(mx, config); + if (result.status !== 'registered') { + throw new Error(result.error ?? 'UnifiedPush registration failed'); + } + + return { + endpoint: result.endpoint, + instance: result.instance, + gatewayUrl: result.gatewayUrl, + }; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +async function getCurrentDeviceUnifiedPushPushkeys( + mx: MatrixClient, + appId: string +): Promise { + const deviceId = mx.getDeviceId() ?? ''; + if (!deviceId) { + return []; + } + + const currentDevice = await mx.getDevice(deviceId); + const deviceDisplayName = currentDevice?.display_name; + if (!deviceDisplayName) { + return []; + } + + const response = await mx.getPushers(); + const pushers = response.pushers ?? []; + return pushers + .filter( + (pusher) => + pusher.app_id === appId && + pusher.device_display_name === deviceDisplayName && + pusher.kind === 'http' && + isNonEmptyString(pusher.pushkey) + ) + .map((pusher) => pusher.pushkey); +} + +async function getUnifiedPushCleanupPushkeys( + mx: MatrixClient, + appId: string, + pushkey?: string +): Promise { + const pushkeys = new Set(); + + if (isNonEmptyString(pushkey)) { + pushkeys.add(pushkey); + } + + const currentDevicePushkeys = await getCurrentDeviceUnifiedPushPushkeys(mx, appId); + currentDevicePushkeys.forEach((candidate) => pushkeys.add(candidate)); + + return Array.from(pushkeys); +} + +export type DisableUnifiedPushOptions = { + config?: UnifiedPushTransportConfigInput; + pushkey?: string; +}; + +export async function disableUnifiedPush( + mx: MatrixClient, + options: DisableUnifiedPushOptions = {} +): Promise { + const { appId } = resolveUnifiedPushPusherConfig(options.config); + const pushkeys = await getUnifiedPushCleanupPushkeys(mx, appId, options.pushkey); + + await Promise.allSettled( + pushkeys.map((pushkey) => + mx.setPusher({ + kind: null, + app_id: appId, + pushkey, + } as unknown as IPusherRequest) + ) + ); + + await unregisterUnifiedPushTransport(); +} + +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 { + const notificationsApi = await getTauriNotificationsApi(); + await notificationsApi.removeActive([{ id: roomNotifId(roomId) }]); + } catch { + // already dismissed + } + if (roomNotifCaches.size <= 1) { + try { + const notificationsApi = await getTauriNotificationsApi(); + await notificationsApi.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 notificationsApi = await getTauriNotificationsApi(); + const { messages, roomName, isGroupConversation } = cache; + const latestMsg = messages[messages.length - 1]; + const latestBody = latestMsg ? `${latestMsg.sender?.name ?? 'You'}: ${latestMsg.text}` : ''; + + await notificationsApi.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 notificationsApi.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) { + const notificationsApi = await getTauriNotificationsApi(); + await notificationsApi.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}`; + + const notificationsApi = await getTauriNotificationsApi(); + await notificationsApi.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, EventType.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 getTauriNotificationsApi().then((notificationsApi) => + notificationsApi.onUnifiedPushMessage( + createUnifiedPushMessageListener( + (data) => handleUnifiedPushPayload(data, getSettings), + (error) => { + unifiedPushLog.error( + 'notification', + 'UnifiedPush payload handling failed', + error instanceof Error ? error : new Error(String(error)) + ); + } + ) + ) + ); +} + +export function listenForUnifiedPushEndpointChanges( + onEndpointChanged: (endpoint: string, instance: string) => void +) { + return getTauriNotificationsApi().then((notificationsApi) => + notificationsApi.onUnifiedPushEndpoint(({ endpoint, instance }) => { + onEndpointChanged(endpoint, instance); + }) + ); +} diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.test.ts b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts new file mode 100644 index 0000000000..76a8b3bee3 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts @@ -0,0 +1,240 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + classifyUnifiedPushFailure, + ensureUnifiedPushDistributorSelection, + loadUnifiedPushDistributorState, + registerUnifiedPushTransport, + switchUnifiedPushDistributorSelection, + setUnifiedPushDistributorSelection, +} from './UnifiedPushTransport'; + +const unifiedPushApi = vi.hoisted(() => ({ + isPermissionGranted: vi.fn(), + requestPermission: vi.fn(), + registerForUnifiedPush: vi.fn(), + unregisterFromUnifiedPush: vi.fn(), + getUnifiedPushDistributors: vi.fn(), + getUnifiedPushDistributor: vi.fn(), + saveUnifiedPushDistributor: vi.fn(), +})); + +vi.mock('./UnifiedPushTransportApiClient', () => ({ + getUnifiedPushTransportApi: vi.fn().mockResolvedValue(unifiedPushApi), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('classifyUnifiedPushFailure', () => { + it('treats temporary unavailability as a distinct failure', () => { + expect( + classifyUnifiedPushFailure(new Error('UnifiedPush registration temporarily unavailable')) + ).toBe('temp-unavailable'); + }); + + it('treats missing distributors as a distinct failure', () => { + expect(classifyUnifiedPushFailure(new Error('No UnifiedPush distributor installed'))).toBe( + 'missing-distributor' + ); + }); +}); + +describe('registerUnifiedPushTransport', () => { + it('requests permission before registering and saves the only available distributor', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(false); + unifiedPushApi.requestPermission.mockResolvedValue('granted'); + unifiedPushApi.getUnifiedPushDistributor.mockResolvedValue({ distributor: '' }); + unifiedPushApi.getUnifiedPushDistributors.mockResolvedValue({ + distributors: ['org.unifiedpush.distributor.ntfy'], + }); + unifiedPushApi.registerForUnifiedPush.mockResolvedValue({ + endpoint: 'https://up.example/endpoint', + instance: 'instance-1', + }); + + await expect(registerUnifiedPushTransport()).resolves.toEqual({ + status: 'registered', + permissionState: 'granted', + endpoint: 'https://up.example/endpoint', + instance: 'instance-1', + distributor: 'org.unifiedpush.distributor.ntfy', + }); + expect(unifiedPushApi.requestPermission).toHaveBeenCalledOnce(); + expect(unifiedPushApi.saveUnifiedPushDistributor).toHaveBeenCalledOnce(); + expect(unifiedPushApi.registerForUnifiedPush).toHaveBeenCalledOnce(); + }); + + it('returns denied without registering when permission is denied', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(false); + unifiedPushApi.requestPermission.mockResolvedValue('denied'); + + await expect(registerUnifiedPushTransport()).resolves.toEqual({ + status: 'denied', + permissionState: 'denied', + error: 'UnifiedPush permission denied', + }); + expect(unifiedPushApi.registerForUnifiedPush).not.toHaveBeenCalled(); + }); + + it('returns missing-distributor without registering when none are available', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + unifiedPushApi.getUnifiedPushDistributor.mockResolvedValue({ distributor: '' }); + unifiedPushApi.getUnifiedPushDistributors.mockResolvedValue({ distributors: [] }); + + await expect(registerUnifiedPushTransport()).resolves.toEqual({ + status: 'missing-distributor', + permissionState: 'granted', + distributors: [], + error: 'No UnifiedPush distributor installed', + }); + expect(unifiedPushApi.registerForUnifiedPush).not.toHaveBeenCalled(); + }); + + it('classifies temporary-unavailable registration failures distinctly', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + unifiedPushApi.getUnifiedPushDistributor.mockResolvedValue({ distributor: 'org.example.up' }); + unifiedPushApi.getUnifiedPushDistributors.mockResolvedValue({ + distributors: ['org.example.up'], + }); + unifiedPushApi.registerForUnifiedPush.mockRejectedValue( + new Error('UnifiedPush registration temporarily unavailable') + ); + + await expect(registerUnifiedPushTransport()).resolves.toEqual({ + status: 'temp-unavailable', + permissionState: 'granted', + distributor: 'org.example.up', + error: 'UnifiedPush registration temporarily unavailable', + }); + }); + + it('treats missing endpoint data as a hard failure', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + unifiedPushApi.getUnifiedPushDistributor.mockResolvedValue({ distributor: 'org.example.up' }); + unifiedPushApi.getUnifiedPushDistributors.mockResolvedValue({ + distributors: ['org.example.up'], + }); + unifiedPushApi.registerForUnifiedPush.mockResolvedValue({ + endpoint: '', + instance: 'instance-1', + }); + + await expect(registerUnifiedPushTransport()).resolves.toMatchObject({ + status: 'hard-failure', + error: 'UnifiedPush registration returned an invalid endpoint', + distributor: 'org.example.up', + }); + }); + + it('treats missing instance data as a hard failure', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + unifiedPushApi.getUnifiedPushDistributor.mockResolvedValue({ distributor: 'org.example.up' }); + unifiedPushApi.getUnifiedPushDistributors.mockResolvedValue({ + distributors: ['org.example.up'], + }); + unifiedPushApi.registerForUnifiedPush.mockResolvedValue({ + endpoint: 'https://up.example/endpoint', + instance: '', + }); + + await expect(registerUnifiedPushTransport()).resolves.toMatchObject({ + status: 'hard-failure', + error: 'UnifiedPush registration returned an invalid instance', + distributor: 'org.example.up', + }); + }); +}); + +describe('UnifiedPush distributor state helpers', () => { + it('loads distributor state and auto-saves the sole available distributor', async () => { + unifiedPushApi.getUnifiedPushDistributor.mockResolvedValue({ distributor: '' }); + unifiedPushApi.getUnifiedPushDistributors.mockResolvedValue({ + distributors: ['org.unifiedpush.distributor.ntfy'], + }); + + await expect(loadUnifiedPushDistributorState()).resolves.toEqual({ + distributors: ['org.unifiedpush.distributor.ntfy'], + selectedDistributor: 'org.unifiedpush.distributor.ntfy', + }); + expect(unifiedPushApi.saveUnifiedPushDistributor).toHaveBeenCalledOnce(); + }); + + it('drops a stale saved distributor that is no longer installed', async () => { + unifiedPushApi.getUnifiedPushDistributor.mockResolvedValue({ + distributor: 'org.unifiedpush.distributor.removed', + }); + unifiedPushApi.getUnifiedPushDistributors.mockResolvedValue({ + distributors: ['org.unifiedpush.distributor.ntfy'], + }); + + await expect(loadUnifiedPushDistributorState()).resolves.toEqual({ + distributors: ['org.unifiedpush.distributor.ntfy'], + selectedDistributor: 'org.unifiedpush.distributor.ntfy', + }); + expect(unifiedPushApi.saveUnifiedPushDistributor).toHaveBeenCalledWith( + 'org.unifiedpush.distributor.ntfy' + ); + }); + + it('ensures a distributor selection by auto-saving the first available distributor', async () => { + await expect( + ensureUnifiedPushDistributorSelection( + ['org.unifiedpush.distributor.ntfy', 'org.unifiedpush.distributor.nextpush'], + '' + ) + ).resolves.toBe('org.unifiedpush.distributor.ntfy'); + expect(unifiedPushApi.saveUnifiedPushDistributor).toHaveBeenCalledOnce(); + }); + + it('replaces a stale selected distributor with the first available one', async () => { + await expect( + ensureUnifiedPushDistributorSelection( + ['org.unifiedpush.distributor.ntfy', 'org.unifiedpush.distributor.nextpush'], + 'org.unifiedpush.distributor.removed' + ) + ).resolves.toBe('org.unifiedpush.distributor.ntfy'); + expect(unifiedPushApi.saveUnifiedPushDistributor).toHaveBeenCalledWith( + 'org.unifiedpush.distributor.ntfy' + ); + }); + + it('persists a selected distributor through the transport helper', async () => { + await expect( + setUnifiedPushDistributorSelection('org.unifiedpush.distributor.nextpush') + ).resolves.toBeUndefined(); + expect(unifiedPushApi.saveUnifiedPushDistributor).toHaveBeenCalledWith( + 'org.unifiedpush.distributor.nextpush' + ); + }); + + it('surfaces backend errors while loading distributor state', async () => { + unifiedPushApi.getUnifiedPushDistributor.mockRejectedValue(new Error('backend unavailable')); + unifiedPushApi.getUnifiedPushDistributors.mockRejectedValue(new Error('backend unavailable')); + + await expect(loadUnifiedPushDistributorState()).rejects.toThrow('backend unavailable'); + }); + + it('restores the previous distributor when a switch registration fails', async () => { + unifiedPushApi.saveUnifiedPushDistributor.mockResolvedValue(undefined); + const register = vi.fn().mockRejectedValue(new Error('registration failed')); + + await expect( + switchUnifiedPushDistributorSelection( + 'org.unifiedpush.distributor.ntfy', + 'org.unifiedpush.distributor.nextpush', + register + ) + ).rejects.toThrow('registration failed'); + + expect(unifiedPushApi.saveUnifiedPushDistributor).toHaveBeenNthCalledWith( + 1, + 'org.unifiedpush.distributor.ntfy' + ); + expect(unifiedPushApi.saveUnifiedPushDistributor).toHaveBeenNthCalledWith( + 2, + 'org.unifiedpush.distributor.nextpush' + ); + expect(register).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.ts b/src/app/features/settings/notifications/UnifiedPushTransport.ts new file mode 100644 index 0000000000..4fb5ff34e7 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushTransport.ts @@ -0,0 +1,312 @@ +import { getUnifiedPushTransportApi } from './UnifiedPushTransportApiClient'; + +export type UnifiedPushPermissionState = 'granted' | 'denied' | 'default'; + +export type UnifiedPushRegistrationStatus = + | 'registered' + | 'temp-unavailable' + | 'hard-failure' + | 'denied' + | 'missing-distributor'; + +export type UnifiedPushDistributorState = { + distributors: string[]; + selectedDistributor: string; +}; + +export type UnifiedPushRegistrationResult = + | { + status: 'registered'; + permissionState: 'granted'; + endpoint: string; + instance: string; + distributor: string; + pubKeySet?: { + pubKey: string; + auth: string; + }; + } + | { + status: 'temp-unavailable'; + permissionState: UnifiedPushPermissionState; + distributor?: string; + error: string; + instance?: string; + } + | { + status: 'hard-failure'; + permissionState: UnifiedPushPermissionState; + distributor?: string; + error: string; + instance?: string; + } + | { + status: 'missing-distributor'; + permissionState: UnifiedPushPermissionState; + distributors: string[]; + error: string; + distributor?: string; + } + | { + status: 'denied'; + permissionState: Exclude; + error: string; + }; + +type UnifiedPushEndpointResponse = { + endpoint: string; + instance: string; + pubKeySet?: { + pubKey: string; + auth: string; + }; +}; + +type UnifiedPushDistributorsResponse = { + distributors: string[]; +}; + +type UnifiedPushDistributorResponse = { + distributor: string; +}; + +type UnifiedPushSwitchRegistration = { + endpoint: string; + instance: string; + pubKeySet?: { + pubKey: string; + auth: string; + }; +}; + +function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object') { + const message = 'message' in error ? (error as { message?: unknown }).message : undefined; + if (typeof message === 'string') return message; + const code = 'code' in error ? (error as { code?: unknown }).code : undefined; + if (typeof code === 'string') return code; + } + return String(error); +} + +function normalizeErrorCode(error: unknown): string { + if (!error || typeof error !== 'object') return ''; + if ('code' in error && typeof (error as { code?: unknown }).code === 'string') { + return String((error as { code?: string }).code).toLowerCase(); + } + if ('name' in error && typeof (error as { name?: unknown }).name === 'string') { + return String((error as { name?: string }).name).toLowerCase(); + } + return ''; +} + +export function classifyUnifiedPushFailure( + error: unknown +): Exclude { + const message = normalizeErrorMessage(error).toLowerCase(); + const code = normalizeErrorCode(error); + + if ( + code.includes('temp_unavailable') || + code.includes('temporary_unavailable') || + message.includes('temp-unavailable') || + message.includes('temp unavailable') || + message.includes('temporarily unavailable') + ) { + return 'temp-unavailable'; + } + + if ( + code.includes('missing_distributor') || + message.includes('missing distributor') || + message.includes('no unifiedpush distributor') || + message.includes('no distributor') || + message.includes('distributor parameter is required') + ) { + return 'missing-distributor'; + } + + return 'hard-failure'; +} + +async function getUnifiedPushPermissionState(): Promise { + const api = await getUnifiedPushTransportApi(); + if (await api.isPermissionGranted()) return 'granted'; + + const permission = await api.requestPermission(); + return permission === 'granted' ? 'granted' : permission; +} + +export async function getUnifiedPushDistributors(): Promise { + const api = await getUnifiedPushTransportApi(); + return api.getUnifiedPushDistributors(); +} + +export async function getUnifiedPushDistributor(): Promise { + const api = await getUnifiedPushTransportApi(); + return api.getUnifiedPushDistributor(); +} + +export async function saveUnifiedPushDistributor(distributor: string): Promise { + const api = await getUnifiedPushTransportApi(); + await api.saveUnifiedPushDistributor(distributor); +} + +export async function loadUnifiedPushDistributorState(): Promise { + const [{ distributor: savedDistributor }, { distributors }] = await Promise.all([ + getUnifiedPushDistributor(), + getUnifiedPushDistributors(), + ]); + + if (savedDistributor && distributors.includes(savedDistributor)) { + return { distributors, selectedDistributor: savedDistributor }; + } + + if (distributors.length === 1) { + const [onlyDistributor] = distributors; + if (onlyDistributor) { + await saveUnifiedPushDistributor(onlyDistributor); + return { distributors, selectedDistributor: onlyDistributor }; + } + } + + return { distributors, selectedDistributor: '' }; +} + +export async function ensureUnifiedPushDistributorSelection( + distributors: string[], + selectedDistributor: string +): Promise { + const distributor = + selectedDistributor && distributors.includes(selectedDistributor) + ? selectedDistributor + : distributors[0]; + + if (!distributor) return ''; + + await saveUnifiedPushDistributor(distributor); + return distributor; +} + +export async function setUnifiedPushDistributorSelection(distributor: string): Promise { + await saveUnifiedPushDistributor(distributor); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function validateUnifiedPushRegistrationResponse( + response: Partial +): { ok: true; value: UnifiedPushSwitchRegistration } | { ok: false; error: string } { + if (!isNonEmptyString(response.endpoint)) { + return { ok: false, error: 'UnifiedPush registration returned an invalid endpoint' }; + } + + if (!isNonEmptyString(response.instance)) { + return { ok: false, error: 'UnifiedPush registration returned an invalid instance' }; + } + + return { + ok: true, + value: { + endpoint: response.endpoint, + instance: response.instance, + pubKeySet: response.pubKeySet, + }, + }; +} + +export async function switchUnifiedPushDistributorSelection( + nextDistributor: string, + previousDistributor: string, + register: () => Promise +): Promise { + if (nextDistributor === previousDistributor) { + return register(); + } + + await saveUnifiedPushDistributor(nextDistributor); + + try { + return await register(); + } catch (error) { + await saveUnifiedPushDistributor(previousDistributor); + throw error; + } +} + +export async function registerUnifiedPushTransport(): Promise { + let permissionState: UnifiedPushPermissionState = 'default'; + let selectedDistributor: string | undefined; + + try { + permissionState = await getUnifiedPushPermissionState(); + if (permissionState !== 'granted') { + return { + status: 'denied', + permissionState, + error: + permissionState === 'denied' + ? 'UnifiedPush permission denied' + : 'UnifiedPush permission dismissed', + }; + } + + const { distributors, selectedDistributor: distributor } = + await loadUnifiedPushDistributorState(); + selectedDistributor = distributor || undefined; + if (!distributor) { + return { + status: 'missing-distributor', + permissionState: 'granted', + distributors, + error: + distributors.length === 0 + ? 'No UnifiedPush distributor installed' + : 'No UnifiedPush distributor selected', + }; + } + + const api = await getUnifiedPushTransportApi(); + const response = (await api.registerForUnifiedPush()) as Partial; + const validated = validateUnifiedPushRegistrationResponse(response); + if (!validated.ok) { + return { + status: 'hard-failure', + permissionState: 'granted', + error: validated.error, + ...(selectedDistributor ? { distributor: selectedDistributor } : {}), + }; + } + + return { + status: 'registered', + permissionState: 'granted', + endpoint: validated.value.endpoint, + instance: validated.value.instance, + distributor, + pubKeySet: validated.value.pubKeySet, + }; + } catch (error) { + const failureStatus = classifyUnifiedPushFailure(error); + return { + status: failureStatus, + permissionState, + error: normalizeErrorMessage(error), + ...(selectedDistributor ? { distributor: selectedDistributor } : {}), + ...(error && typeof error === 'object' && 'instance' in error + ? { + instance: String((error as { instance?: unknown }).instance ?? ''), + } + : {}), + } as UnifiedPushRegistrationResult; + } +} + +export async function unregisterUnifiedPushTransport(): Promise { + const api = await getUnifiedPushTransportApi(); + await api.unregisterFromUnifiedPush(); +} diff --git a/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts b/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts new file mode 100644 index 0000000000..192279e31a --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts @@ -0,0 +1,29 @@ +export type UnifiedPushTransportApi = { + isPermissionGranted: () => Promise; + requestPermission: () => Promise; + registerForUnifiedPush: () => Promise<{ + endpoint: string; + instance: string; + pubKeySet?: { + pubKey: string; + auth: string; + }; + }>; + unregisterFromUnifiedPush: () => Promise; + getUnifiedPushDistributors: () => Promise<{ distributors: string[] }>; + getUnifiedPushDistributor: () => Promise<{ distributor: string }>; + saveUnifiedPushDistributor: (distributor: string) => Promise; +}; + +export async function getUnifiedPushTransportApi(): Promise { + const api = await import('@sableclient/tauri-plugin-notifications-api'); + return { + isPermissionGranted: api.isPermissionGranted, + requestPermission: api.requestPermission, + registerForUnifiedPush: api.registerForUnifiedPush, + unregisterFromUnifiedPush: api.unregisterFromUnifiedPush, + getUnifiedPushDistributors: api.getUnifiedPushDistributors, + getUnifiedPushDistributor: api.getUnifiedPushDistributor, + saveUnifiedPushDistributor: api.saveUnifiedPushDistributor, + }; +} diff --git a/src/app/features/settings/routes.ts b/src/app/features/settings/routes.ts index 823112b177..685005dc27 100644 --- a/src/app/features/settings/routes.ts +++ b/src/app/features/settings/routes.ts @@ -5,6 +5,7 @@ export type SettingsSectionId = | 'appearance' | 'notifications' | 'devices' + | 'desktop' | 'emojis' | 'developer-tools' | 'experimental' @@ -23,6 +24,7 @@ export const settingsSections = [ { id: 'appearance', label: 'Appearance' }, { id: 'notifications', label: 'Notifications' }, { id: 'devices', label: 'Devices' }, + { id: 'desktop', label: 'Desktop' }, { id: 'emojis', label: 'Emojis & Stickers' }, { id: 'developer-tools', label: 'Developer Tools' }, { id: 'experimental', label: 'Experimental' }, diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 6acc1b34cb..e2ed6d0b86 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -189,6 +189,7 @@ const settingsLinkFocusIdsBySection: Record { + return invoke('abort_loopback_fetch', params); +} + +export async function clearMediaSession(): Promise { + return invoke('clear_media_session'); +} + +export async function getDesktopRuntimeState(): Promise { + return invoke('get_desktop_runtime_state'); +} + +export async function hideSnapOverlay(): Promise { + return invoke('hide_snap_overlay'); +} + +export async function isWindowTrackingActive(): Promise { + return invoke('is_window_tracking_active'); +} + +export async function loopbackFetch(params: types.LoopbackFetchParams): Promise { + return invoke('loopback_fetch', params); +} + +export async function setMediaSession(params: types.SetMediaSessionParams): Promise { + return invoke('set_media_session', params); +} + +export async function showSnapOverlay(): Promise { + return invoke('show_snap_overlay'); +} + +export async function startWindowTrackingWithTarget(params: types.StartWindowTrackingWithTargetParams): Promise { + return invoke('start_window_tracking_with_target', params); +} + +export async function stopWindowTracking(): Promise { + return invoke('stop_window_tracking'); +} + +export async function syncDesktopSettings(params: types.SyncDesktopSettingsParams): Promise { + return invoke('sync_desktop_settings', params); +} diff --git a/src/app/generated/tauri/desktop/DesktopRuntimeState.ts b/src/app/generated/tauri/desktop/DesktopRuntimeState.ts new file mode 100644 index 0000000000..b5561254ae --- /dev/null +++ b/src/app/generated/tauri/desktop/DesktopRuntimeState.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DesktopRuntimeState = { trayAvailable: boolean, }; diff --git a/src/app/generated/tauri/desktop/DesktopSettings.ts b/src/app/generated/tauri/desktop/DesktopSettings.ts new file mode 100644 index 0000000000..4367a6ccc7 --- /dev/null +++ b/src/app/generated/tauri/desktop/DesktopSettings.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DesktopSettings = { closeToBackgroundOnClose: boolean, showSystemTrayIcon: boolean, }; diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts new file mode 100644 index 0000000000..0f1d8a0f3b --- /dev/null +++ b/src/app/generated/tauri/index.ts @@ -0,0 +1,11 @@ +/** + * Auto-generated TypeScript bindings for Tauri commands + * Generated by tauri-typegen v0.5.0 + * Generated at: 2026-07-17T13:29:58.806065916+00:00 + * Generator: none + * + * Do not edit manually - regenerate using: cargo tauri-typegen generate + */ + +export * from './types'; +export * from './commands'; diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts new file mode 100644 index 0000000000..448f2cbd0e --- /dev/null +++ b/src/app/generated/tauri/types.ts @@ -0,0 +1,65 @@ +/** + * Auto-generated TypeScript bindings for Tauri commands + * Generated by tauri-typegen v0.5.0 + * Generated at: 2026-07-17T13:29:58.805280378+00:00 + * Generator: none + * + * Do not edit manually - regenerate using: cargo tauri-typegen generate + */ + +export interface DesktopRuntimeState { + trayAvailable: boolean; +} + +export interface DesktopSettings { + closeToBackgroundOnClose: boolean; + showSystemTrayIcon: boolean; +} + +export interface LoopbackFetchRequest { + requestId: string; + method: string; + url: string; + headers: [string, string][]; + body?: number[] | null; +} + +export interface LoopbackFetchResponse { + status: number; + statusText: string; + url: string; + headers: [string, string][]; + body: number[]; +} + +export interface WindowTarget { + window_class?: string | null; + exe_name?: string | null; +} + +export interface AbortLoopbackFetchParams { + requestId: string; + [key: string]: unknown; +} + +export interface LoopbackFetchParams { + request: LoopbackFetchRequest; + [key: string]: unknown; +} + +export interface SetMediaSessionParams { + baseUrl: string; + token: string; + [key: string]: unknown; +} + +export interface StartWindowTrackingWithTargetParams { + target: WindowTarget; + [key: string]: unknown; +} + +export interface SyncDesktopSettingsParams { + settings: DesktopSettings; + [key: string]: unknown; +} + diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts deleted file mode 100644 index 96ac18f747..0000000000 --- a/src/app/hooks/useBlobCache.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useState, useEffect } from 'react'; - -const imageBlobCache = new Map(); -const inflightRequests = new Map>(); - -export function getBlobCacheStats(): { cacheSize: number; inflightCount: number } { - return { cacheSize: imageBlobCache.size, inflightCount: inflightRequests.size }; -} - -export function useBlobCache(url?: string): string | undefined { - const [cacheState, setCacheState] = useState<{ sourceUrl?: string; blobUrl?: string }>({ - sourceUrl: url, - blobUrl: url ? imageBlobCache.get(url) : undefined, - }); - - if (url !== cacheState.sourceUrl) { - setCacheState({ - sourceUrl: url, - blobUrl: url ? imageBlobCache.get(url) : undefined, - }); - } - - useEffect(() => { - if (!url || imageBlobCache.has(url)) return undefined; - - let isMounted = true; - - const fetchBlob = async () => { - if (inflightRequests.has(url)) { - try { - const existingBlobUrl = await inflightRequests.get(url); - if (isMounted) setCacheState({ sourceUrl: url, blobUrl: existingBlobUrl }); - } catch { - // Inflight request failed, silently ignore (consistent with fetchBlob behavior) - } - return; - } - - const requestPromise = (async () => { - try { - const res = await fetch(url, { mode: 'cors' }); - if (!res.ok) { - throw new Error(`Failed to fetch blob: ${res.status} ${res.statusText}`); - } - const blob = await res.blob(); - const objectUrl = URL.createObjectURL(blob); - - imageBlobCache.set(url, objectUrl); - return objectUrl; - } catch (e) { - inflightRequests.delete(url); - throw e; - } - })(); - - inflightRequests.set(url, requestPromise); - - try { - const finalBlobUrl = await requestPromise; - if (isMounted) { - setCacheState({ sourceUrl: url, blobUrl: finalBlobUrl }); - } - } catch { - // silency fail... mrow - } finally { - inflightRequests.delete(url); - } - }; - - fetchBlob(); - - return () => { - isMounted = false; - }; - }, [url]); - - return cacheState.blobUrl || url; -} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 76ca7eae6b..679b3629eb 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from 'react'; +import type { PushTransportConfig } from '$features/settings/notifications/NotificationTransport'; import type { Settings } from '$state/settings'; @@ -25,6 +26,23 @@ export type ClientConfig = { pushNotifyUrl?: string; vapidPublicKey?: string; webPushAppID?: string; + nativePushAppID?: string; + unifiedPushAppID?: string; + unifiedPushGatewayUrl?: string; + }; + + pushTransport?: PushTransportConfig; + + slidingSync?: { + enabled?: boolean; + proxyBaseUrl?: string; + bootstrapClassicOnColdCache?: boolean; + listPageSize?: number; + timelineLimit?: number; + pollTimeoutMs?: number; + maxRooms?: number; + includeInviteList?: boolean; + probeTimeoutMs?: number; }; featuredCommunities?: { diff --git a/src/app/hooks/useIntegrationManager.ts b/src/app/hooks/useIntegrationManager.ts index e9247330a9..3b6710ccd0 100644 --- a/src/app/hooks/useIntegrationManager.ts +++ b/src/app/hooks/useIntegrationManager.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import type { MatrixClient } from '$types/matrix-sdk'; +import { fetch } from '$utils/fetch'; import { useMatrixClient } from './useMatrixClient'; export interface IntegrationManager { diff --git a/src/app/hooks/useRenderableMediaUrl.test.tsx b/src/app/hooks/useRenderableMediaUrl.test.tsx new file mode 100644 index 0000000000..71b01a1225 --- /dev/null +++ b/src/app/hooks/useRenderableMediaUrl.test.tsx @@ -0,0 +1,235 @@ +import type { ReactNode } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const platform = vi.hoisted(() => ({ + hasServiceWorker: vi.fn(), + hasControllingServiceWorker: vi.fn(), +})); + +const mediaTransport = vi.hoisted(() => ({ + fetchMediaBlob: vi.fn(), + getCurrentMediaSessionScope: vi.fn(() => 'anonymous'), +})); + +const tauriApi = vi.hoisted(() => ({ + isTauri: vi.fn(), + convertFileSrc: vi.fn((url: string, protocol: string) => `${protocol}://${url}`), +})); + +vi.mock('$utils/platform', () => platform); +vi.mock('$utils/mediaTransport', () => mediaTransport); +vi.mock('@tauri-apps/api/core', () => tauriApi); + +describe('useRenderableMediaUrl', () => { + beforeEach(() => { + vi.resetModules(); + platform.hasServiceWorker.mockReset(); + platform.hasControllingServiceWorker.mockReset(); + mediaTransport.fetchMediaBlob.mockReset(); + mediaTransport.getCurrentMediaSessionScope.mockReset(); + mediaTransport.getCurrentMediaSessionScope.mockReturnValue('anonymous'); + tauriApi.isTauri.mockReset(); + tauriApi.convertFileSrc.mockReset(); + tauriApi.convertFileSrc.mockImplementation((url: string, protocol: string) => `${protocol}://${url}`); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:rendered-media'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + controller: null, + ready: Promise.resolve({}), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the original url when a service worker runtime is available', async () => { + platform.hasServiceWorker.mockReturnValue(true); + platform.hasControllingServiceWorker.mockReturnValue(true); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const { result } = renderHook(() => useRenderableMediaUrl('https://example.org/media.png')); + + expect(result.current).toBe('https://example.org/media.png'); + expect(mediaTransport.fetchMediaBlob).not.toHaveBeenCalled(); + }, 20_000); + + it('rejects non-browser-safe media urls in service worker runtimes', async () => { + platform.hasServiceWorker.mockReturnValue(true); + platform.hasControllingServiceWorker.mockReturnValue(true); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + const javascriptUrlValue = ['javascript', 'alert(1)'].join(':'); + + const javascriptUrl = renderHook(() => useRenderableMediaUrl(javascriptUrlValue)); + const mxcUrl = renderHook(() => useRenderableMediaUrl('mxc://example.org/media-id')); + const relativeUrl = renderHook(() => useRenderableMediaUrl('/relative/path.png')); + + expect(javascriptUrl.result.current).toBeUndefined(); + expect(mxcUrl.result.current).toBeUndefined(); + expect(relativeUrl.result.current).toBeUndefined(); + expect(mediaTransport.fetchMediaBlob).not.toHaveBeenCalled(); + }); + + it('returns a blob url in no-service-worker runtimes', async () => { + platform.hasServiceWorker.mockReturnValue(false); + platform.hasControllingServiceWorker.mockReturnValue(false); + mediaTransport.fetchMediaBlob.mockResolvedValue(new Blob(['media'], { type: 'image/png' })); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const { result } = renderHook(() => useRenderableMediaUrl('https://example.org/media.png')); + + await waitFor(() => { + expect(result.current).toBe('blob:rendered-media'); + }); + + expect(mediaTransport.fetchMediaBlob).toHaveBeenCalledWith('https://example.org/media.png'); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + }); + + it('does not fetch invalid media urls in no-service-worker runtimes', async () => { + platform.hasServiceWorker.mockReturnValue(false); + platform.hasControllingServiceWorker.mockReturnValue(false); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const { result } = renderHook(() => useRenderableMediaUrl('data:text/html,boom')); + + expect(result.current).toBeUndefined(); + expect(mediaTransport.fetchMediaBlob).not.toHaveBeenCalled(); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); + + it('returns existing blob urls unchanged in no-service-worker runtimes', async () => { + platform.hasServiceWorker.mockReturnValue(false); + platform.hasControllingServiceWorker.mockReturnValue(false); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const { result } = renderHook(() => + useRenderableMediaUrl('blob:http://localhost:8080/blob-id') + ); + + expect(result.current).toBe('blob:http://localhost:8080/blob-id'); + expect(mediaTransport.fetchMediaBlob).not.toHaveBeenCalled(); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); + + it('uses the blob-backed path until the service worker controls the page', async () => { + platform.hasServiceWorker.mockReturnValue(true); + platform.hasControllingServiceWorker.mockReturnValue(false); + mediaTransport.fetchMediaBlob.mockResolvedValue(new Blob(['media'], { type: 'image/png' })); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const { result } = renderHook(() => useRenderableMediaUrl('https://example.org/media.png')); + + await waitFor(() => { + expect(result.current).toBe('blob:rendered-media'); + }); + + expect(mediaTransport.fetchMediaBlob).toHaveBeenCalledWith('https://example.org/media.png'); + }); + + it('refetches blob-backed media when the active session changes', async () => { + platform.hasServiceWorker.mockReturnValue(false); + platform.hasControllingServiceWorker.mockReturnValue(false); + mediaTransport.fetchMediaBlob + .mockResolvedValueOnce(new Blob(['alice'], { type: 'image/png' })) + .mockResolvedValueOnce(new Blob(['bob'], { type: 'image/png' })); + vi.mocked(URL.createObjectURL) + .mockReturnValueOnce('blob:alice-media') + .mockReturnValueOnce('blob:bob-media'); + + const { activeSessionIdAtom } = await import('$state/sessions'); + const store = createStore(); + store.set(activeSessionIdAtom, '@alice:example.org'); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const { result } = renderHook(() => useRenderableMediaUrl('https://example.org/media.png'), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBe('blob:alice-media'); + }); + + act(() => { + store.set(activeSessionIdAtom, '@bob:example.org'); + }); + + expect(result.current).toBeUndefined(); + + await waitFor(() => { + expect(result.current).toBe('blob:bob-media'); + }); + + expect(mediaTransport.fetchMediaBlob).toHaveBeenNthCalledWith( + 1, + 'https://example.org/media.png' + ); + expect(mediaTransport.fetchMediaBlob).toHaveBeenNthCalledWith( + 2, + 'https://example.org/media.png' + ); + }); + + it('revokes the object url when the last consumer unmounts', async () => { + platform.hasServiceWorker.mockReturnValue(false); + platform.hasControllingServiceWorker.mockReturnValue(false); + mediaTransport.fetchMediaBlob.mockResolvedValue(new Blob(['media'], { type: 'image/png' })); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const first = renderHook(() => useRenderableMediaUrl('https://example.org/media.png')); + const second = renderHook(() => useRenderableMediaUrl('https://example.org/media.png')); + + await waitFor(() => { + expect(first.result.current).toBe('blob:rendered-media'); + expect(second.result.current).toBe('blob:rendered-media'); + }); + + first.unmount(); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + + second.unmount(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:rendered-media'); + }); + + it('rewrites raw authenticated-media https URLs under Tauri', async () => { + tauriApi.isTauri.mockReturnValue(true); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const rawAuthUrl = 'https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/abc123?width=96&height=96'; + const { result } = renderHook(() => useRenderableMediaUrl(rawAuthUrl)); + + expect(result.current).toBe(`sable-media://${rawAuthUrl}`); + expect(tauriApi.convertFileSrc).toHaveBeenCalledWith(rawAuthUrl, 'sable-media'); + }); + + it('passes through already-rewritten sable-media:// URLs under Tauri', async () => { + tauriApi.isTauri.mockReturnValue(true); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const rewrittenUrl = 'sable-media://https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/abc123'; + const { result } = renderHook(() => useRenderableMediaUrl(rewrittenUrl)); + + expect(result.current).toBe(rewrittenUrl); + expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); + }); + + it('passes through non-authenticated URLs unchanged under Tauri', async () => { + tauriApi.isTauri.mockReturnValue(true); + const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl'); + + const { result } = renderHook(() => useRenderableMediaUrl('https://example.org/avatar.png')); + + expect(result.current).toBe('https://example.org/avatar.png'); + expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/hooks/useRenderableMediaUrl.ts b/src/app/hooks/useRenderableMediaUrl.ts new file mode 100644 index 0000000000..40f5863442 --- /dev/null +++ b/src/app/hooks/useRenderableMediaUrl.ts @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { isTauri } from '@tauri-apps/api/core'; +import { activeSessionIdAtom } from '$state/sessions'; +import { fetchMediaBlob, getCurrentMediaSessionScope } from '$utils/mediaTransport'; +import { hasControllingServiceWorker, hasServiceWorker } from '$utils/platform'; +import { rewriteAuthenticatedMediaUrl } from '$utils/matrix'; + +type ObjectUrlEntry = { + refs: number; + settled: boolean; + objectUrl?: string; + promise: Promise; +}; + +type ResolvedMediaUrlState = { + cacheKey?: string; + url?: string; +}; + +const objectUrlCache = new Map(); +const inflightRequests = new Map>(); + +function getObjectUrlCacheKey(sessionScope: string, url: string): string { + return `${sessionScope}\x00${url}`; +} + +function normalizeRenderableMediaUrl(url: string | undefined): string | undefined { + if (!url) return undefined; + if (url.startsWith('blob:')) return url; + + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.toString(); + } + } catch { + return undefined; + } + + return undefined; +} + +function createObjectUrlEntry(cacheKey: string, url: string): ObjectUrlEntry { + const entry = { + refs: 0, + settled: false, + objectUrl: undefined, + promise: Promise.resolve(''), + } as ObjectUrlEntry; + + entry.promise = fetchMediaBlob(url) + .then((blob) => { + const objectUrl = URL.createObjectURL(blob); + entry.objectUrl = objectUrl; + return objectUrl; + }) + .finally(() => { + entry.settled = true; + inflightRequests.delete(cacheKey); + if (entry.refs === 0 && entry.objectUrl) { + URL.revokeObjectURL(entry.objectUrl); + objectUrlCache.delete(cacheKey); + } + }); + + objectUrlCache.set(cacheKey, entry); + inflightRequests.set(cacheKey, entry.promise); + + return entry; +} + +function retainObjectUrlEntry(cacheKey: string, url: string): ObjectUrlEntry { + const entry = objectUrlCache.get(cacheKey) ?? createObjectUrlEntry(cacheKey, url); + entry.refs += 1; + return entry; +} + +function releaseObjectUrlEntry(cacheKey: string): void { + const entry = objectUrlCache.get(cacheKey); + if (!entry) return; + + entry.refs -= 1; + if (entry.refs > 0 || !entry.settled) return; + if (entry.objectUrl) { + URL.revokeObjectURL(entry.objectUrl); + } + objectUrlCache.delete(cacheKey); +} + +export function getRenderableMediaUrlStats(): { cacheSize: number; inflightCount: number } { + return { cacheSize: objectUrlCache.size, inflightCount: inflightRequests.size }; +} + +export function useRenderableMediaUrl(url: string | undefined): string | undefined { + const tauri = isTauri(); + const activeSessionId = useAtomValue(activeSessionIdAtom); + const sessionScope = activeSessionId ?? getCurrentMediaSessionScope(); + const renderableUrl = normalizeRenderableMediaUrl(url); + const objectUrlCacheKey = + renderableUrl && !renderableUrl.startsWith('blob:') + ? getObjectUrlCacheKey(sessionScope, renderableUrl) + : undefined; + const [usesControlledServiceWorker, setUsesControlledServiceWorker] = useState(() => + hasControllingServiceWorker() + ); + const needsBlob = !usesControlledServiceWorker; + const usesExistingObjectUrl = renderableUrl?.startsWith('blob:') ?? false; + const [resolvedState, setResolvedState] = useState(() => ({ + cacheKey: objectUrlCacheKey, + url: needsBlob && !usesExistingObjectUrl ? undefined : renderableUrl, + })); + + useEffect(() => { + if (tauri) return undefined; + if (!hasServiceWorker()) { + setUsesControlledServiceWorker(false); + return undefined; + } + + const { serviceWorker } = navigator; + if (!serviceWorker) { + setUsesControlledServiceWorker(false); + return undefined; + } + + const updateControlState = () => { + setUsesControlledServiceWorker(hasControllingServiceWorker()); + }; + + updateControlState(); + serviceWorker.addEventListener('controllerchange', updateControlState); + serviceWorker.ready.then(updateControlState).catch(() => undefined); + + return () => { + serviceWorker.removeEventListener('controllerchange', updateControlState); + }; + }, []); + + useEffect(() => { + if (tauri) return undefined; + if (!renderableUrl) { + setResolvedState({ cacheKey: undefined, url: undefined }); + return undefined; + } + + if (!needsBlob) { + setResolvedState({ cacheKey: undefined, url: renderableUrl }); + return undefined; + } + + if (usesExistingObjectUrl) { + setResolvedState({ cacheKey: undefined, url: renderableUrl }); + return undefined; + } + + if (!objectUrlCacheKey) { + setResolvedState({ cacheKey: undefined, url: undefined }); + return undefined; + } + + const entry = retainObjectUrlEntry(objectUrlCacheKey, renderableUrl); + let cancelled = false; + const { objectUrl } = entry; + + setResolvedState({ cacheKey: objectUrlCacheKey, url: objectUrl }); + + entry.promise + .then((resolvedObjectUrl) => { + if (!cancelled) { + setResolvedState({ cacheKey: objectUrlCacheKey, url: resolvedObjectUrl }); + } + }) + .catch(() => { + if (!cancelled) { + setResolvedState({ cacheKey: objectUrlCacheKey, url: undefined }); + } + }); + + return () => { + cancelled = true; + releaseObjectUrlEntry(objectUrlCacheKey); + }; + }, [needsBlob, objectUrlCacheKey, renderableUrl, usesExistingObjectUrl]); + + if (tauri) { + return rewriteAuthenticatedMediaUrl(url ?? null) ?? undefined; + } + + if (!needsBlob || usesExistingObjectUrl) { + return renderableUrl; + } + + if (resolvedState.cacheKey !== objectUrlCacheKey) { + return undefined; + } + + return resolvedState.url; +} diff --git a/src/app/hooks/useSessionProfiles.test.tsx b/src/app/hooks/useSessionProfiles.test.tsx new file mode 100644 index 0000000000..d56273b01b --- /dev/null +++ b/src/app/hooks/useSessionProfiles.test.tsx @@ -0,0 +1,173 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '$state/sessions'; + +const mediaTransport = vi.hoisted(() => ({ + fetchMediaBlob: vi.fn(), +})); + +vi.mock('$utils/mediaTransport', () => mediaTransport); + +describe('useSessionProfiles', () => { + beforeEach(() => { + vi.resetModules(); + mediaTransport.fetchMediaBlob.mockReset(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.endsWith('/_matrix/client/v3/profile/%40alice%3Aexample.org')) { + return new Response( + JSON.stringify({ + displayname: 'Alice', + avatar_url: 'mxc://example.org/avatar', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return new Response('', { status: 404 }); + }) + ); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:session-avatar'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fetches session avatar thumbnails through the media transport with session-scoped auth', async () => { + const avatarBlob = new Blob(['avatar'], { type: 'image/png' }); + mediaTransport.fetchMediaBlob.mockResolvedValue(avatarBlob); + + const sessions: Session[] = [ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'alice-token', + }, + ]; + + const { useSessionProfiles } = await import('./useSessionProfiles'); + const { result } = renderHook(() => useSessionProfiles(sessions)); + + await waitFor(() => { + expect(result.current['@alice:example.org']).toEqual({ + displayName: 'Alice', + avatarHttpUrl: 'blob:session-avatar', + }); + }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(mediaTransport.fetchMediaBlob).toHaveBeenCalledWith( + 'https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/avatar?width=96&height=96&method=crop', + { + accessToken: 'alice-token', + sessionScope: '@alice:example.org', + } + ); + expect(URL.createObjectURL).toHaveBeenCalledWith(avatarBlob); + }); + + it('refetches profiles when the same user session is reauthenticated', async () => { + mediaTransport.fetchMediaBlob + .mockResolvedValueOnce(new Blob(['avatar-1'], { type: 'image/png' })) + .mockResolvedValueOnce(new Blob(['avatar-2'], { type: 'image/png' })); + vi.mocked(URL.createObjectURL) + .mockReturnValueOnce('blob:session-avatar-1') + .mockReturnValueOnce('blob:session-avatar-2'); + + const { useSessionProfiles } = await import('./useSessionProfiles'); + const { result, rerender } = renderHook( + ({ sessions }: { sessions: Session[] }) => useSessionProfiles(sessions), + { + initialProps: { + sessions: [ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'alice-token-1', + }, + ], + }, + } + ); + + await waitFor(() => { + expect(result.current['@alice:example.org']).toEqual({ + displayName: 'Alice', + avatarHttpUrl: 'blob:session-avatar-1', + }); + }); + + rerender({ + sessions: [ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'alice-token-2', + }, + ], + }); + + await waitFor(() => { + expect(result.current['@alice:example.org']).toEqual({ + displayName: 'Alice', + avatarHttpUrl: 'blob:session-avatar-2', + }); + }); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(mediaTransport.fetchMediaBlob).toHaveBeenNthCalledWith( + 1, + 'https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/avatar?width=96&height=96&method=crop', + { + accessToken: 'alice-token-1', + sessionScope: '@alice:example.org', + } + ); + expect(mediaTransport.fetchMediaBlob).toHaveBeenNthCalledWith( + 2, + 'https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/avatar?width=96&height=96&method=crop', + { + accessToken: 'alice-token-2', + sessionScope: '@alice:example.org', + } + ); + }); + + it('revokes avatar blob urls on unmount', async () => { + mediaTransport.fetchMediaBlob.mockResolvedValue(new Blob(['avatar'], { type: 'image/png' })); + + const sessions: Session[] = [ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'alice-token', + }, + ]; + + const { useSessionProfiles } = await import('./useSessionProfiles'); + const { result, unmount } = renderHook(() => useSessionProfiles(sessions)); + + await waitFor(() => { + expect(result.current['@alice:example.org']).toEqual({ + displayName: 'Alice', + avatarHttpUrl: 'blob:session-avatar', + }); + }); + + unmount(); + + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:session-avatar'); + }); +}); diff --git a/src/app/hooks/useSessionProfiles.ts b/src/app/hooks/useSessionProfiles.ts index 8996b5c6b0..2a68026029 100644 --- a/src/app/hooks/useSessionProfiles.ts +++ b/src/app/hooks/useSessionProfiles.ts @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { Session } from '$state/sessions'; +import { fetch } from '$utils/fetch'; +import { fetchMediaBlob } from '$utils/mediaTransport'; export type SessionProfile = { displayName?: string; @@ -18,8 +20,7 @@ const parseMxc = (mxcUrl: string): { serverName: string; mediaId: string } | und }; const fetchAvatarBlobUrl = async ( - baseUrl: string, - accessToken: string, + session: Session, mxcUrl: string ): Promise => { const parsed = parseMxc(mxcUrl); @@ -27,22 +28,21 @@ const fetchAvatarBlobUrl = async ( const { serverName, mediaId } = parsed; const tryFetch = async (url: string) => { - const res = await fetch(url, { - headers: { Authorization: `Bearer ${accessToken}` }, + const blob = await fetchMediaBlob(url, { + accessToken: session.accessToken, + sessionScope: session.userId, }); - if (!res.ok) throw new Error(`${res.status}`); - const blob = await res.blob(); return URL.createObjectURL(blob); }; try { return await tryFetch( - `${baseUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=96&height=96&method=crop` + `${session.baseUrl}/_matrix/client/v1/media/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}?width=96&height=96&method=crop` ); } catch { try { return await tryFetch( - `${baseUrl}/_matrix/media/v3/thumbnail/${serverName}/${mediaId}?width=96&height=96&method=crop` + `${session.baseUrl}/_matrix/media/v3/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}?width=96&height=96&method=crop` ); } catch { return undefined; @@ -52,12 +52,15 @@ const fetchAvatarBlobUrl = async ( export const useSessionProfiles = (sessions: Session[]): SessionProfiles => { const [profiles, setProfiles] = useState({}); - const blobUrlsRef = useRef([]); const sessionsRef = useRef(sessions); sessionsRef.current = sessions; - const sessionKey = sessions.map((s) => s.userId).join('\x00'); + const sessionIdentityKey = sessions + .map((session) => + [session.userId, session.baseUrl, session.accessToken, session.deviceId].join('\x01') + ) + .join('\x00'); useEffect(() => { let cancelled = false; @@ -75,11 +78,7 @@ export const useSessionProfiles = (sessions: Session[]): SessionProfiles => { let avatarHttpUrl: string | undefined; if (data.avatar_url) { - avatarHttpUrl = await fetchAvatarBlobUrl( - session.baseUrl, - session.accessToken, - data.avatar_url - ); + avatarHttpUrl = await fetchAvatarBlobUrl(session, data.avatar_url); if (avatarHttpUrl) newBlobUrls.push(avatarHttpUrl); } @@ -102,10 +101,9 @@ export const useSessionProfiles = (sessions: Session[]): SessionProfiles => { return () => { cancelled = true; - blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u)); - blobUrlsRef.current = newBlobUrls; + newBlobUrls.forEach((url) => URL.revokeObjectURL(url)); }; - }, [sessionKey]); + }, [sessionIdentityKey]); return profiles; }; diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 141f503765..17ba1c9af7 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -14,10 +14,12 @@ import type { ScreenSize } from '$hooks/useScreenSize'; import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize'; import { useCompositionEndTracking } from '$hooks/useComposingCheck'; import { ErrorPage } from '$components/DefaultErrorPage'; +import { TauriFrontendReady } from '$components/tauri/TauriFrontendReady'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { isReactQueryDevtoolsEnabled } from './reactQueryDevtoolsGate'; import { bootstrapSettingsStore } from '$state/settings'; +import { AppShell } from '$components/app-shell'; const queryClient = new QueryClient(); const ReactQueryDevtools = lazy(async () => { @@ -66,7 +68,6 @@ function renderSentryErrorFallback({ error, eventId }: { error: unknown; eventId function App() { const screenSize = useScreenSize(); useCompositionEndTracking(); - const portalContainer = document.getElementById('portalContainer') ?? undefined; const renderConfiguredApp = useCallback( (clientConfig: ClientConfig) => { @@ -82,17 +83,11 @@ function App() { return ( - - - - - - {renderConfiguredApp} - - - - - + + + {renderConfiguredApp} + + ); } diff --git a/src/app/pages/LandingRouter.tsx b/src/app/pages/LandingRouter.tsx new file mode 100644 index 0000000000..9ab8eff638 --- /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 fa1444ce2b..872cd34277 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -83,6 +83,7 @@ import { } from './MobileFriendly'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; +import { TauriDeepLinkBridge } from './TauriDeepLinkBridge'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; import { HomeCreateRoom } from './client/home/CreateRoom'; import { Create } from './client/create'; @@ -148,6 +149,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) beforeCapture={(scope) => scope.setTag('section', 'auth')} > <> + diff --git a/src/app/pages/TauriDeepLinkBridge.tsx b/src/app/pages/TauriDeepLinkBridge.tsx new file mode 100644 index 0000000000..b89bb968c4 --- /dev/null +++ b/src/app/pages/TauriDeepLinkBridge.tsx @@ -0,0 +1,77 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { isTauri } from '@tauri-apps/api/core'; +import { createLogger } from '$utils/debug'; +import { + parseTauriOidcCallback, + parseTauriSsoCallback, + takeTauriOidcServer, +} from '$pages/auth/SSOTauri'; +import { getLoginPath, withSearchParam } from './pathUtils'; + +const log = createLogger('TauriDeepLinkBridge'); + +const mapDeepLinkToLoginPath = (rawUrl: string): string | undefined => { + const ssoCallback = parseTauriSsoCallback(rawUrl); + if (ssoCallback) { + return withSearchParam(getLoginPath(ssoCallback.server), { + loginToken: ssoCallback.loginToken, + }); + } + + const oidcCallback = parseTauriOidcCallback(rawUrl); + if (oidcCallback) { + return withSearchParam(getLoginPath(takeTauriOidcServer()), { + code: oidcCallback.code, + state: oidcCallback.state, + }); + } + + return undefined; +}; + +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 3d725992e4..5b844adb8f 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -25,6 +25,7 @@ import type { AuthFlows } from '$hooks/useAuthFlows'; import { AuthServerProvider } from '$hooks/useAuthServer'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '$pages/paths'; import { getHomePath } from '$pages/pathUtils'; +import { fetch } from '$utils/fetch'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import type { SpecVersions } from '../../cs-api'; import { ServerPicker } from './ServerPicker'; diff --git a/src/app/pages/auth/AuthShell.tsx b/src/app/pages/auth/AuthShell.tsx new file mode 100644 index 0000000000..6aef5907c8 --- /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 ( + + + +
+ + Cinny Logo + 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 0000000000..22c676fba6 --- /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 742e62e299..45cd43af5e 100644 --- a/src/app/pages/auth/SSOLogin.tsx +++ b/src/app/pages/auth/SSOLogin.tsx @@ -1,8 +1,13 @@ import { Avatar, AvatarImage, Box, Button, Text } from 'folds'; import type { IIdentityProvider, SSOAction } from '$types/matrix-sdk'; import { createClient } from '$types/matrix-sdk'; +import type { MouseEvent } from 'react'; import { 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'; +import { fetch } from '$utils/fetch'; type SSOLoginProps = { providers?: IIdentityProvider[]; @@ -13,7 +18,7 @@ type SSOLoginProps = { export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SSOLoginProps) { const discovery = useAutoDiscoveryInfo(); const baseUrl = discovery['m.homeserver'].base_url; - const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); + const mx = useMemo(() => createClient({ baseUrl, fetchFn: fetch }), [baseUrl]); const getSSOIdUrl = (ssoId?: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId, action); @@ -26,6 +31,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 ? ( @@ -42,6 +55,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" @@ -57,6 +71,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" @@ -80,6 +95,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 0000000000..378924474a --- /dev/null +++ b/src/app/pages/auth/SSOTauri.ts @@ -0,0 +1,99 @@ +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) { + // TODO: disabled for now since it causes issues with the SSO flow. We should find a better solution for this in the future. + // return window.location.origin; + return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`; + } + + 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 TAURI_OIDC_CLIENT_URI = 'https://app.sable.moe'; + +const TAURI_OIDC_PROTOCOL = 'moe.sable.app:'; +const TAURI_OIDC_PATH = '/login'; +const TAURI_OIDC_SERVER_KEY = 'sable:tauri-oidc:server'; + +export const buildTauriOidcRedirectUrl = (): string => + `${TAURI_OIDC_PROTOCOL}${TAURI_OIDC_PATH}`; + +export const rememberTauriOidcServer = (server?: string): void => { + try { + if (server) localStorage.setItem(TAURI_OIDC_SERVER_KEY, server); + else localStorage.removeItem(TAURI_OIDC_SERVER_KEY); + } catch { + // ignore storage failures + } +}; + +export const takeTauriOidcServer = (): string | undefined => { + try { + const server = localStorage.getItem(TAURI_OIDC_SERVER_KEY) ?? undefined; + localStorage.removeItem(TAURI_OIDC_SERVER_KEY); + return server; + } catch { + return undefined; + } +}; + +export const parseTauriOidcCallback = ( + rawUrl: string +): { code: string; state: string } | undefined => { + try { + const callbackUrl = new URL(rawUrl); + if (callbackUrl.protocol !== TAURI_OIDC_PROTOCOL) return undefined; + if (callbackUrl.pathname !== TAURI_OIDC_PATH) return undefined; + + const code = callbackUrl.searchParams.get('code'); + const state = callbackUrl.searchParams.get('state'); + if (!code || !state) return undefined; + + return { code, state }; + } catch { + return undefined; + } +}; + +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 4b399be619..a02ffa279a 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -11,6 +11,8 @@ import { usePathWithOrigin } from '$hooks/usePathWithOrigin'; import type { LoginPathSearchParams } from '$pages/paths'; import { useClientConfig } from '$hooks/useClientConfig'; import { SSOLogin } from '$pages/auth/SSOLogin'; +import { isTauri } from '@tauri-apps/api/core'; +import { buildTauriSsoRedirectUrl } from '$pages/auth/SSOTauri'; import { OrDivider } from '$pages/auth/OrDivider'; import { PasswordLoginForm } from './PasswordLoginForm'; import { TokenLogin } from './TokenLogin'; @@ -89,7 +91,8 @@ export function Login() { const baseUrl = discovery['m.homeserver'].base_url; const [searchParams] = useSearchParams(); const loginSearchParams = useLoginSearchParams(searchParams); - const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server)); + const webSsoRedirectUrl = usePathWithOrigin(getLoginPath(server)); + const ssoRedirectUrl = isTauri() ? buildTauriSsoRedirectUrl(server) : webSsoRedirectUrl; const oidcRedirectUri = usePathWithOrigin(getLoginPath(server), { ignoreHashRouter: true }); const external = getExternalSearchParams(); const absoluteLoginPath = usePathWithOrigin(getLoginPath(server)); @@ -169,6 +172,7 @@ export function Login() { redirectUri={oidcRedirectUri} label={`Continue with ${server}`} notice={oidcNotice} + server={server} /> diff --git a/src/app/pages/auth/login/OidcLogin.tsx b/src/app/pages/auth/login/OidcLogin.tsx index 58de6f9d55..b64413a757 100644 --- a/src/app/pages/auth/login/OidcLogin.tsx +++ b/src/app/pages/auth/login/OidcLogin.tsx @@ -38,6 +38,7 @@ type OidcLoginButtonProps = { label: string; prompt?: string; notice?: string; + server?: string; }; export function OidcLoginButton({ authMetadata, @@ -46,11 +47,12 @@ export function OidcLoginButton({ label, prompt, notice, + server, }: OidcLoginButtonProps) { const [state, start] = useAsyncCallback( useCallback( - () => startOidcLogin(authMetadata, homeserverUrl, redirectUri, { prompt }), - [authMetadata, homeserverUrl, redirectUri, prompt] + () => startOidcLogin(authMetadata, homeserverUrl, redirectUri, { prompt, server }), + [authMetadata, homeserverUrl, redirectUri, prompt, server] ) ); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index c8ca0590b0..fd77e796ce 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -16,6 +16,7 @@ import { getHomePath } from '$pages/pathUtils'; import { activeSessionIdAtom, sessionsAtom, type Session } from '$state/sessions'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import { fetch } from '$utils/fetch'; import { ErrorCode } from '../../../cs-errorcode'; import { autoDiscovery, specVersions } from '../../../cs-api'; @@ -80,7 +81,7 @@ export const login = async ( }); } - const mx = createClient({ baseUrl: url }); + const mx = createClient({ baseUrl: url, fetchFn: fetch }); debugLog.info('general', 'Attempting login', { baseUrl: url, loginType: data.type }); return Sentry.startSpan( diff --git a/src/app/pages/auth/login/oidcLoginUtil.ts b/src/app/pages/auth/login/oidcLoginUtil.ts index 7be1700274..94e195b987 100644 --- a/src/app/pages/auth/login/oidcLoginUtil.ts +++ b/src/app/pages/auth/login/oidcLoginUtil.ts @@ -6,8 +6,15 @@ import { generateOidcAuthorizationUrl, completeAuthorizationCodeGrant, } from '$types/matrix-sdk'; +import { isTauri } from '@tauri-apps/api/core'; +import { openUrl } from '@tauri-apps/plugin-opener'; import { createLogger } from '$utils/debug'; import type { Session } from '$state/sessions'; +import { + TAURI_OIDC_CLIENT_URI, + buildTauriOidcRedirectUrl, + rememberTauriOidcServer, +} from '$pages/auth/SSOTauri'; const log = createLogger('oidcLogin'); @@ -32,14 +39,17 @@ export const startOidcLogin = async ( authMetadata: OidcClientConfig, homeserverUrl: string, redirectUri: string, - opts?: { prompt?: string } + opts?: { prompt?: string; server?: string } ): Promise => { + const tauri = isTauri(); + const effectiveRedirectUri = tauri ? buildTauriOidcRedirectUrl() : redirectUri; + const [registerErr, clientId] = await to( registerOidcClient(authMetadata, { clientName: CLIENT_NAME, - clientUri: window.location.origin, - applicationType: 'web', - redirectUris: [redirectUri], + clientUri: tauri ? TAURI_OIDC_CLIENT_URI : window.location.origin, + applicationType: tauri ? 'native' : 'web', + redirectUris: [effectiveRedirectUri], contacts: undefined, tosUri: undefined, policyUri: undefined, @@ -54,11 +64,17 @@ export const startOidcLogin = async ( metadata: authMetadata, clientId, homeserverUrl, - redirectUri, + redirectUri: effectiveRedirectUri, nonce: crypto.randomUUID(), prompt: opts?.prompt, }); + if (tauri) { + rememberTauriOidcServer(opts?.server); + await openUrl(authUrl); + return; + } + window.location.assign(authUrl); }; diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index 49824aaf60..dcd355647d 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -38,6 +38,7 @@ import { UIAFlowOverlay } from '$components/UIAFlowOverlay'; import type { RequestEmailTokenCallback, RequestEmailTokenResponse } from '$hooks/types'; import { FieldError } from '$pages/auth/FiledError'; import { deviceDisplayName } from '$utils/user-agent'; +import { fetch } from '$utils/fetch'; import type { RegisterResult } from './registerUtil'; import { RegisterError, register, useRegisterComplete } from './registerUtil'; @@ -183,7 +184,7 @@ export function PasswordRegisterForm({ }: PasswordRegisterFormProps) { const serverDiscovery = useAutoDiscoveryInfo(); const baseUrl = serverDiscovery['m.homeserver'].base_url; - const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); + const mx = useMemo(() => createClient({ baseUrl, fetchFn: fetch }), [baseUrl]); const params = useUIAParams(authData); const termUrl = getLoginTermUrl(params); const [formData, setFormData] = useState(); diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx index f9294e4ec9..913235915a 100644 --- a/src/app/pages/auth/register/Register.tsx +++ b/src/app/pages/auth/register/Register.tsx @@ -11,6 +11,8 @@ import { getLoginPath, withSearchParam } from '$pages/pathUtils'; import { usePathWithOrigin } from '$hooks/usePathWithOrigin'; import type { RegisterPathSearchParams } from '$pages/paths'; import { SSOLogin } from '$pages/auth/SSOLogin'; +import { isTauri } from '@tauri-apps/api/core'; +import { buildTauriSsoRedirectUrl } from '$pages/auth/SSOTauri'; import { OrDivider } from '$pages/auth/OrDivider'; import { OidcLoginButton } from '$pages/auth/login/OidcLogin'; import { PasswordRegisterForm, SUPPORTED_REGISTER_STAGES } from './PasswordRegisterForm'; @@ -35,7 +37,8 @@ export function Register() { const { sso } = useParsedLoginFlows(loginFlows.flows); // redirect to /login because only that path handle m.login.token and the OIDC callback - const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server)); + const webSsoRedirectUrl = usePathWithOrigin(getLoginPath(server)); + const ssoRedirectUrl = isTauri() ? buildTauriSsoRedirectUrl(server) : webSsoRedirectUrl; const oidcRedirectUri = usePathWithOrigin(getLoginPath(server), { ignoreHashRouter: true }); const isAddingAccount = searchParams.get('addAccount') === '1'; @@ -57,6 +60,7 @@ export function Register() { redirectUri={oidcRedirectUri} label={`Continue with ${server}`} prompt="create" + server={server} /> diff --git a/src/app/pages/auth/reset-password/PasswordResetForm.tsx b/src/app/pages/auth/reset-password/PasswordResetForm.tsx index f4209d1cb0..d155df120a 100644 --- a/src/app/pages/auth/reset-password/PasswordResetForm.tsx +++ b/src/app/pages/auth/reset-password/PasswordResetForm.tsx @@ -28,6 +28,7 @@ import { EmailStageDialog } from '$components/uia-stages'; import { getLoginPath, withSearchParam } from '$pages/pathUtils'; import { getUIAError, getUIAErrorCode } from '$utils/matrix-uia'; import { FieldError } from '$pages/auth/FiledError'; +import { fetch } from '$utils/fetch'; import type { ResetPasswordResult } from './resetPasswordUtil'; import { resetPassword } from './resetPasswordUtil'; @@ -85,7 +86,7 @@ export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) { const serverDiscovery = useAutoDiscoveryInfo(); const baseUrl = serverDiscovery['m.homeserver'].base_url; - const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); + const mx = useMemo(() => createClient({ baseUrl, fetchFn: fetch }), [baseUrl]); const [formData, setFormData] = useState(); diff --git a/src/app/pages/client/AutoDiscovery.tsx b/src/app/pages/client/AutoDiscovery.tsx index 4b936e60ac..53366a06df 100644 --- a/src/app/pages/client/AutoDiscovery.tsx +++ b/src/app/pages/client/AutoDiscovery.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { useCallback, useMemo } from 'react'; import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo'; import { AsyncStatus, useAsyncCallbackValue } from '../../hooks/useAsyncCallback'; +import { fetch } from '../../utils/fetch'; import type { AutoDiscoveryInfo } from '../../cs-api'; import { autoDiscovery } from '../../cs-api'; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index c181d7b067..8ec8e4dad6 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -58,7 +58,6 @@ import { NotificationBanner } from '$components/notification-banner'; import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner'; import { TelemetryConsentBanner } from '$components/telemetry-consent'; import { useIncomingCallSignaling } from '$hooks/useCallSignaling'; -import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { resolveIncomingCallFromNotificationData } from '$features/call/callNotificationBridge'; @@ -66,7 +65,9 @@ import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; import { incomingCallAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; +import { NotificationTransportRuntimeFeature } from '$features/settings/notifications/NotificationTransportRuntimeFeature'; import { UnverifiedNoticeBanner } from '$components/unverified-notice'; +import { getRenderableMediaUrlStats } from '$hooks/useRenderableMediaUrl'; const pushRelayLog = createDebugLogger('push-relay'); @@ -611,7 +612,7 @@ function PrivacyBlurFeature() { function HealthMonitor() { useEffect(() => { const id = window.setInterval(() => { - const { cacheSize, inflightCount } = getBlobCacheStats(); + const { cacheSize, inflightCount } = getRenderableMediaUrlStats(); Sentry.metrics.gauge('sable.media.blob_cache_size', cacheSize); if (inflightCount > 0) { Sentry.metrics.gauge('sable.media.inflight_requests', inflightCount); @@ -930,6 +931,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/pages/client/sessionRefresh.test.ts b/src/app/pages/client/sessionRefresh.test.ts new file mode 100644 index 0000000000..5656fad140 --- /dev/null +++ b/src/app/pages/client/sessionRefresh.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createSessionRefreshHandler } from './sessionRefresh'; + +describe('createSessionRefreshHandler', () => { + it('updates the session that created the client, not whichever account is active later', () => { + const setSessions = vi.fn(); + const pushSession = vi.fn(); + + const handler = createSessionRefreshHandler( + '@alice:example.org', + () => ({ + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'ALICE', + accessToken: 'alice-access', + refreshToken: 'alice-refresh', + }), + setSessions, + pushSession + ); + + handler('alice-access-2', 'alice-refresh-2'); + + expect(setSessions).toHaveBeenCalledWith({ + type: 'PUT', + session: { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'ALICE', + accessToken: 'alice-access-2', + refreshToken: 'alice-refresh-2', + }, + }); + expect(pushSession).toHaveBeenCalledWith( + 'https://matrix.example.org', + 'alice-access-2', + '@alice:example.org' + ); + }); + + it('merges refreshed tokens into the latest stored session fields', () => { + const setSessions = vi.fn(); + const pushSession = vi.fn(); + + const handler = createSessionRefreshHandler( + '@alice:example.org', + () => ({ + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'ALICE', + accessToken: 'alice-access', + refreshToken: 'alice-refresh', + slidingSyncOptIn: true, + }), + setSessions, + pushSession + ); + + handler('alice-access-2', 'alice-refresh-2'); + + expect(setSessions).toHaveBeenCalledWith({ + type: 'PUT', + session: { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'ALICE', + accessToken: 'alice-access-2', + refreshToken: 'alice-refresh-2', + slidingSyncOptIn: true, + }, + }); + }); +}); diff --git a/src/app/pages/client/sessionRefresh.ts b/src/app/pages/client/sessionRefresh.ts new file mode 100644 index 0000000000..7f00f0a4f6 --- /dev/null +++ b/src/app/pages/client/sessionRefresh.ts @@ -0,0 +1,23 @@ +import type { Session, SessionsAction } from '$state/sessions'; + +export function createSessionRefreshHandler( + userId: string, + getSession: () => Session | undefined, + setSessions: (action: SessionsAction) => void, + pushSession: (baseUrl?: string, accessToken?: string, userId?: string) => void +): (newAccessToken: string, newRefreshToken?: string) => void { + return (newAccessToken: string, newRefreshToken?: string) => { + const session = getSession(); + if (!session) return; + + setSessions({ + type: 'PUT', + session: { + ...session, + accessToken: newAccessToken, + ...(newRefreshToken !== undefined && { refreshToken: newRefreshToken }), + }, + }); + pushSession(session.baseUrl, newAccessToken, userId); + }; +} diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index a7f0f63234..2c6ac97baf 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -111,6 +111,7 @@ import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; import * as css from './styles.css'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import { UserQuickTools } from '../sidebar/UserQuickTools'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; const debugLog = createDebugLogger('Space'); @@ -286,7 +287,8 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) const bannerState = useStateEvent(space, CustomStateEvent.RoomBanner); const bannerMXC = bannerState?.getContent()?.url; - const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication); + const rawBannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication); + const bannerURI = rawBannerURI && useRenderableMediaUrl(rawBannerURI) const hasBanner = !!(bannerURI && !hideText && showBanners); const [bannerViewerOpen, setBannerViewerOpen] = useState(false); diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index d01bff279e..7e202ecd5d 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -1,6 +1,7 @@ import type { Path } from 'react-router-dom'; import { generatePath } from 'react-router-dom'; import { trimLeadingSlash, trimTrailingSlash } from '$utils/common'; +import { getAppOrigin } from '$utils/platform'; import type { HashRouterConfig } from '$hooks/useClientConfig'; import type { SettingsPathSearchParams } from './paths'; import { @@ -44,7 +45,7 @@ export const encodeSearchParamValueArray = (ids: string[]): string => ids.join(' export const decodeSearchParamValueArray = (idsParam: string): string[] => idsParam.split(','); export const getOriginBaseUrl = (hashRouterConfig?: HashRouterConfig): string => { - const baseUrl = `${trimTrailingSlash(window.location.origin)}${import.meta.env.BASE_URL}`; + const baseUrl = `${trimTrailingSlash(getAppOrigin())}${import.meta.env.BASE_URL}`; if (hashRouterConfig?.enabled) { return `${trimTrailingSlash(baseUrl)}/#${hashRouterConfig.basename}`; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 05917aa2c7..f128909e0e 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -103,4 +103,5 @@ export const SPACE_SETTINGS_PATH = '/space-settings/'; export const ROOM_SETTINGS_PATH = '/room-settings/'; +export const SSO_CALLBACK_PATH = '/lp/sso-callback'; export const SETTINGS_PATH = '/settings/:section?/'; diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts index 960ae8550f..7ca29a7b1b 100644 --- a/src/app/plugins/arborium/runtime.test.ts +++ b/src/app/plugins/arborium/runtime.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type * as Arborium from '@arborium/arborium'; -import type { HighlightResult } from '.'; +import type { HighlightResult } from './runtime'; type ArboriumModule = typeof Arborium; @@ -25,7 +25,7 @@ describe('highlightCode', () => { } as unknown as ArboriumModule; const loadModule = vi.fn<() => Promise>(async () => module); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { @@ -60,7 +60,7 @@ describe('highlightCode', () => { } as unknown as ArboriumModule; const loadModule = vi.fn<() => Promise>(async () => module); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { @@ -95,7 +95,7 @@ describe('highlightCode', () => { } as unknown as ArboriumModule; const loadModule = vi.fn<() => Promise>(async () => module); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { @@ -143,7 +143,7 @@ describe('highlightCode', () => { } as unknown as ArboriumModule; const loadModule = vi.fn<() => Promise>(async () => module); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { @@ -176,7 +176,7 @@ describe('highlightCode', () => { } as unknown as ArboriumModule; const loadModule = vi.fn<() => Promise>(async () => module); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { @@ -211,7 +211,7 @@ describe('highlightCode', () => { } as unknown as ArboriumModule; const loadModule = vi.fn<() => Promise>(async () => module); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { @@ -236,7 +236,7 @@ describe('highlightCode', () => { throw new Error('boom'); }); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { @@ -265,7 +265,7 @@ describe('highlightCode', () => { } as unknown as ArboriumModule; const loadModule = vi.fn<() => Promise>(async () => module); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { @@ -298,7 +298,7 @@ describe('highlightCode', () => { } as unknown as ArboriumModule; const loadModule = vi.fn<() => Promise>(async () => module); - const { highlightCode } = await import('.'); + const { highlightCode } = await import('./runtime'); const result: HighlightResult = await highlightCode( { diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 87b013eb18..4f6091ec3a 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -10,6 +10,7 @@ import { } from 'matrix-widget-api'; import { CallWidgetDriver } from './CallWidgetDriver'; import { trimTrailingSlash } from '../../utils/common'; +import { getAppOrigin } from '../../utils/platform'; import type { ElementCallThemeKind, ElementMediaStateDetail } from './types'; import { color, config } from 'folds'; import { ElementCallIntent, ElementWidgetActions } from './types'; @@ -96,7 +97,7 @@ export class CallEmbed { ): Widget { const userId = mx.getSafeUserId(); const deviceId = mx.getDeviceId() ?? ''; - const clientOrigin = window.location.origin; + const clientOrigin = getAppOrigin(); const widgetId = 'call-embed'; const params = new URLSearchParams({ @@ -125,7 +126,7 @@ export class CallEmbed { let widgetUrl: URL; if (elementCallUrl && elementCallUrl.trim()) { try { - widgetUrl = new URL(elementCallUrl, window.location.origin); + widgetUrl = new URL(elementCallUrl, clientOrigin); } catch (error) { debugLog.warn( 'call', @@ -137,13 +138,13 @@ export class CallEmbed { ); widgetUrl = new URL( `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, - window.location.origin + clientOrigin ); } } else { widgetUrl = new URL( `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, - window.location.origin + clientOrigin ); } widgetUrl.search = params.toString(); diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index 99f89b15dc..ae51f34ce7 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -367,7 +367,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, { + getAccessToken: () => this.mx.getAccessToken(), + sessionScope: this.mx.getUserId() ?? undefined, + }); return { file: blob }; } diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index f83029540a..f343522baa 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -407,6 +407,17 @@ describe('react custom html parser', () => { expect(logSpy).not.toHaveBeenCalled(); }); + it('does not fall back to rendering unsafe non-mxc image urls from unsanitized html', () => { + const unsafeUrl = ['javascript', 'alert(1)'].join(':'); + const { container } = renderParsedHtml( + `unsafe media`, + { sanitize: false } + ); + + expect(screen.getByText('unsafe media')).toBeInTheDocument(); + expect(container.querySelector('img')).toBeNull(); + }); + it('linkifies bare urls in formatted html text nodes even when abbreviation replacement runs', () => { const parserOptions = getReactCustomHtmlParser(createMatrixClient(), '!room:example.com', { settingsLinkBaseUrl, diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index cd5c9a3432..dce7b9c709 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -33,6 +33,7 @@ import { onEnterOrSpace } from '$utils/keyboard'; import { copyToClipboard } from '$utils/dom'; import { isMatrixHexColor } from '$utils/matrixHtml'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { getSettingsLinkChipLabel, parseSettingsLink } from '$features/settings/settingsLink'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { CodeHighlightRenderer } from '$components/code-highlight'; @@ -557,11 +558,13 @@ export function CodeBlock({ */ function FallbackImg({ fallback, + src, ...props }: ComponentPropsWithoutRef<'img'> & { fallback: ReactNode }) { const [failed, setFailed] = useState(false); + const renderableSrc = useRenderableMediaUrl(typeof src === 'string' ? src : undefined); if (failed) return <>{fallback}; - return setFailed(true)} />; + return setFailed(true)} />; } export const getReactCustomHtmlParser = ( diff --git a/src/app/state/desktopSettings.test.ts b/src/app/state/desktopSettings.test.ts new file mode 100644 index 0000000000..dd049593eb --- /dev/null +++ b/src/app/state/desktopSettings.test.ts @@ -0,0 +1,240 @@ +import { createStore } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_DESKTOP_SETTINGS, + desktopRuntimeStateAtom, + desktopSettingsSyncingAtom, + desktopSettingsFromStoreValues, + desktopSettingsAtom, + desktopSettingsReadyAtom, + getDesktopSetting, + getDesktopSettings, + saveDesktopSettings, + setDesktopSetting, +} from './desktopSettings'; + +const { mockClose, mockEntries, mockGetDesktopRuntimeState, mockSet, mockSyncDesktopSettings } = + vi.hoisted(() => ({ + mockClose: vi.fn(), + mockEntries: vi.fn(), + mockGetDesktopRuntimeState: vi.fn().mockResolvedValue({ trayAvailable: true }), + mockSet: vi.fn(), + mockSyncDesktopSettings: vi.fn().mockResolvedValue({ trayAvailable: false }), + })); + +vi.mock('@tauri-apps/plugin-store', () => ({ + LazyStore: class { + get: (key: string) => Promise; + + set: (key: string, value: unknown) => Promise; + + close: () => Promise; + + constructor() { + this.get = async (key: string) => { + const entries = (await mockEntries()) as Array<[string, unknown]>; + return entries.find(([entryKey]) => entryKey === key)?.[1]; + }; + this.set = async (key: string, value: unknown) => mockSet(key, value); + this.close = async () => mockClose(); + } + }, +})); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => true, +})); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: () => 'windows', +})); + +vi.mock('$generated/tauri/commands', () => ({ + getDesktopRuntimeState: mockGetDesktopRuntimeState, + syncDesktopSettings: mockSyncDesktopSettings, +})); + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + + return { promise, resolve }; +} + +describe('desktop settings state', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEntries.mockResolvedValue([]); + }); + + it('loads defaults when the store does not contain values', async () => { + mockEntries.mockResolvedValue([]); + + await expect(getDesktopSettings()).resolves.toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it('loads a single desktop setting by key', async () => { + mockEntries.mockResolvedValue([ + ['closeToBackgroundOnClose', false], + ['showSystemTrayIcon', false], + ]); + + await expect(getDesktopSetting('showSystemTrayIcon')).resolves.toBe(false); + }); + + it('migrates the legacy background-running flag into close behavior', () => { + expect(desktopSettingsFromStoreValues(false, false, true)).toEqual({ + closeToBackgroundOnClose: true, + showSystemTrayIcon: false, + }); + }); + + it('preserves an explicit close-off setting when the legacy flag is off', () => { + expect(desktopSettingsFromStoreValues(false, true, false)).toEqual({ + closeToBackgroundOnClose: false, + showSystemTrayIcon: true, + }); + }); + + it('writes through the desktop settings atom and syncs runtime state', async () => { + const store = createStore(); + const unsubscribe = store.sub(desktopSettingsReadyAtom, () => {}); + + await vi.waitFor(() => { + expect(store.get(desktopSettingsReadyAtom)).toBe(true); + }); + + await store.set(desktopSettingsAtom, { + closeToBackgroundOnClose: true, + showSystemTrayIcon: true, + }); + + expect(mockSet).not.toHaveBeenCalled(); + expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ + settings: { + closeToBackgroundOnClose: true, + showSystemTrayIcon: true, + }, + }); + expect(store.get(desktopRuntimeStateAtom)).toEqual({ trayAvailable: false }); + + unsubscribe(); + }); + + it('marks desktop settings sync as pending while tray changes are in flight', async () => { + const deferred = createDeferred<{ trayAvailable: boolean }>(); + mockEntries.mockResolvedValue([ + ['closeToBackgroundOnClose', true], + ['showSystemTrayIcon', false], + ]); + mockSyncDesktopSettings.mockImplementationOnce(() => deferred.promise); + + const store = createStore(); + const unsubscribe = store.sub(desktopSettingsReadyAtom, () => {}); + + await vi.waitFor(() => { + expect(store.get(desktopSettingsReadyAtom)).toBe(true); + }); + + const writePromise = store.set(desktopSettingsAtom, { + closeToBackgroundOnClose: true, + showSystemTrayIcon: true, + }); + + await vi.waitFor(() => { + expect(store.get(desktopSettingsSyncingAtom)).toBe(true); + }); + + deferred.resolve({ trayAvailable: true }); + await writePromise; + + expect(store.get(desktopSettingsSyncingAtom)).toBe(false); + + unsubscribe(); + }); + + it('persists and syncs when saving desktop settings directly', async () => { + await expect( + saveDesktopSettings({ + closeToBackgroundOnClose: false, + showSystemTrayIcon: false, + }) + ).resolves.toEqual({ trayAvailable: false }); + + expect(mockSet).toHaveBeenCalledTimes(3); + expect(mockSet).toHaveBeenCalledWith('closeToBackgroundOnClose', false); + expect(mockSet).toHaveBeenCalledWith('showSystemTrayIcon', false); + expect(mockSet).toHaveBeenCalledWith('keepBackgroundRunning', false); + expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ + settings: { + closeToBackgroundOnClose: false, + showSystemTrayIcon: false, + }, + }); + }); + + it('persists a single close-behavior setting and mirrors the legacy flag', async () => { + mockEntries.mockResolvedValue([ + ['closeToBackgroundOnClose', false], + ['showSystemTrayIcon', false], + ['keepBackgroundRunning', false], + ]); + + await expect(setDesktopSetting('closeToBackgroundOnClose', true)).resolves.toEqual({ + trayAvailable: false, + }); + + expect(mockSet).toHaveBeenCalledWith('closeToBackgroundOnClose', true); + expect(mockSet).toHaveBeenCalledWith('keepBackgroundRunning', true); + expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ + settings: { + closeToBackgroundOnClose: true, + showSystemTrayIcon: false, + }, + }); + }); + + it('turning off close behavior also clears the legacy flag', async () => { + mockEntries.mockResolvedValue([ + ['closeToBackgroundOnClose', true], + ['showSystemTrayIcon', true], + ['keepBackgroundRunning', true], + ]); + + await expect(setDesktopSetting('closeToBackgroundOnClose', false)).resolves.toEqual({ + trayAvailable: false, + }); + + expect(mockSet).toHaveBeenCalledWith('closeToBackgroundOnClose', false); + expect(mockSet).toHaveBeenCalledWith('keepBackgroundRunning', false); + expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ + settings: { + closeToBackgroundOnClose: false, + showSystemTrayIcon: true, + }, + }); + }); + + it('persists the tray visibility setting without touching close behavior', async () => { + mockEntries.mockResolvedValue([ + ['closeToBackgroundOnClose', true], + ['showSystemTrayIcon', true], + ['keepBackgroundRunning', true], + ]); + + await expect(setDesktopSetting('showSystemTrayIcon', false)).resolves.toEqual({ + trayAvailable: false, + }); + + expect(mockSet).toHaveBeenCalledTimes(1); + expect(mockSet).toHaveBeenCalledWith('showSystemTrayIcon', false); + expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ + settings: { + closeToBackgroundOnClose: true, + showSystemTrayIcon: false, + }, + }); + }); +}); diff --git a/src/app/state/desktopSettings.ts b/src/app/state/desktopSettings.ts new file mode 100644 index 0000000000..4d3c06e496 --- /dev/null +++ b/src/app/state/desktopSettings.ts @@ -0,0 +1,236 @@ +import { atom } from 'jotai'; +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; +import { LazyStore } from '@tauri-apps/plugin-store'; +import { getDesktopRuntimeState, syncDesktopSettings } from '$generated/tauri/commands'; +import type { DesktopSettings as GeneratedDesktopSettings } from '$generated/tauri/desktop/DesktopSettings'; +import type { DesktopRuntimeState } from '$generated/tauri/desktop/DesktopRuntimeState'; + +type DesktopSettingsState = { + ready: boolean; + value: DesktopSettings; +}; + +type DesktopRuntimeStateValue = { + ready: boolean; + value: DesktopRuntimeState; +}; + +export type DesktopSettings = GeneratedDesktopSettings; +export type { DesktopRuntimeState }; + +const DESKTOP_SETTINGS_STORE_PATH = 'desktop-preferences.json' as const; +const LEGACY_KEEP_BACKGROUND_RUNNING_KEY = 'keepBackgroundRunning' as const; + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + closeToBackgroundOnClose: true, + showSystemTrayIcon: true, +}; +export type DesktopSettingKey = keyof DesktopSettings; + +export const DEFAULT_DESKTOP_RUNTIME_STATE: DesktopRuntimeState = { + trayAvailable: true, +}; + +const DESKTOP_SETTING_KEYS = Object.keys(DEFAULT_DESKTOP_SETTINGS) as DesktopSettingKey[]; + +const desktopSettingsStore = new LazyStore(DESKTOP_SETTINGS_STORE_PATH, { + defaults: DEFAULT_DESKTOP_SETTINGS, +}); + +let currentDesktopSettings = DEFAULT_DESKTOP_SETTINGS; +let currentDesktopRuntimeState = DEFAULT_DESKTOP_RUNTIME_STATE; + +function isDesktopTauri(): boolean { + if (!isTauri()) return false; + + const os = osType(); + return os === 'windows' || os === 'linux' || os === 'macos'; +} + +function readBoolean(value: boolean | undefined, fallback: boolean): boolean { + return value === undefined ? fallback : value; +} + +async function persistDesktopSettings( + current: DesktopSettings, + next: DesktopSettings +): Promise { + const updates = DESKTOP_SETTING_KEYS.filter((key) => current[key] !== next[key]).map((key) => + desktopSettingsStore.set(key, next[key]) + ); + + if (current.closeToBackgroundOnClose !== next.closeToBackgroundOnClose) { + updates.push( + desktopSettingsStore.set(LEGACY_KEEP_BACKGROUND_RUNNING_KEY, next.closeToBackgroundOnClose) + ); + } + + await Promise.all(updates); +} + +export function desktopSettingsFromStoreValues( + closeToBackgroundOnClose: boolean | undefined, + showSystemTrayIcon: boolean | undefined, + legacyKeepBackgroundRunning: boolean | undefined +): DesktopSettings { + return { + closeToBackgroundOnClose: + readBoolean(closeToBackgroundOnClose, DEFAULT_DESKTOP_SETTINGS.closeToBackgroundOnClose) || + readBoolean(legacyKeepBackgroundRunning, false), + showSystemTrayIcon: readBoolean( + showSystemTrayIcon, + DEFAULT_DESKTOP_SETTINGS.showSystemTrayIcon + ), + }; +} + +async function applyDesktopSettings( + current: DesktopSettings, + settings: DesktopSettings +): Promise { + currentDesktopSettings = settings; + + if (!isDesktopTauri()) { + currentDesktopRuntimeState = DEFAULT_DESKTOP_RUNTIME_STATE; + return currentDesktopRuntimeState; + } + + await persistDesktopSettings(current, settings); + + currentDesktopRuntimeState = await syncDesktopSettings({ settings }); + return currentDesktopRuntimeState; +} + +export async function getDesktopSettings(): Promise { + if (!isDesktopTauri()) return DEFAULT_DESKTOP_SETTINGS; + + const [closeToBackgroundOnClose, showSystemTrayIcon, legacyKeepBackgroundRunning] = + await Promise.all([ + desktopSettingsStore.get('closeToBackgroundOnClose'), + desktopSettingsStore.get('showSystemTrayIcon'), + desktopSettingsStore.get(LEGACY_KEEP_BACKGROUND_RUNNING_KEY), + ]); + + currentDesktopSettings = desktopSettingsFromStoreValues( + closeToBackgroundOnClose, + showSystemTrayIcon, + legacyKeepBackgroundRunning + ); + + return currentDesktopSettings; +} + +export async function getDesktopSetting( + key: K +): Promise { + const settings = await getDesktopSettings(); + return settings[key]; +} + +export async function saveDesktopSettings(settings: DesktopSettings): Promise { + const current = isDesktopTauri() ? await getDesktopSettings() : currentDesktopSettings; + return applyDesktopSettings(current, settings); +} + +export async function setDesktopSetting( + key: K, + value: DesktopSettings[K] +): Promise { + const current = isDesktopTauri() ? await getDesktopSettings() : currentDesktopSettings; + const next = { ...current, [key]: value } as DesktopSettings; + + return applyDesktopSettings(current, next); +} + +const baseDesktopSettingsAtom = atom({ + ready: false, + value: DEFAULT_DESKTOP_SETTINGS, +}); + +baseDesktopSettingsAtom.onMount = (setAtom) => { + if (!isDesktopTauri()) { + setAtom({ + ready: true, + value: DEFAULT_DESKTOP_SETTINGS, + }); + return undefined; + } + + let cancelled = false; + + getDesktopSettings().then((settings) => { + if (cancelled) return; + + setAtom({ + ready: true, + value: settings, + }); + }); + + return () => { + cancelled = true; + }; +}; + +const baseDesktopRuntimeStateAtom = atom({ + ready: false, + value: DEFAULT_DESKTOP_RUNTIME_STATE, +}); + +const baseDesktopSettingsSyncCountAtom = atom(0); + +baseDesktopRuntimeStateAtom.onMount = (setAtom) => { + if (!isDesktopTauri()) { + setAtom({ + ready: true, + value: DEFAULT_DESKTOP_RUNTIME_STATE, + }); + return undefined; + } + + let cancelled = false; + + getDesktopRuntimeState().then((runtimeState) => { + currentDesktopRuntimeState = runtimeState; + + if (cancelled) return; + + setAtom({ + ready: true, + value: runtimeState, + }); + }); + + return () => { + cancelled = true; + }; +}; + +export const desktopSettingsAtom = atom( + (get) => get(baseDesktopSettingsAtom).value, + async (_get, set, settings: DesktopSettings) => { + set(baseDesktopSettingsAtom, { + ready: true, + value: settings, + }); + set(baseDesktopSettingsSyncCountAtom, (count) => count + 1); + + try { + const runtimeState = await saveDesktopSettings(settings); + set(baseDesktopRuntimeStateAtom, { + ready: true, + value: runtimeState, + }); + } finally { + set(baseDesktopSettingsSyncCountAtom, (count) => Math.max(0, count - 1)); + } + } +); + +export const desktopRuntimeStateAtom = atom((get) => get(baseDesktopRuntimeStateAtom).value); +export const desktopSettingsSyncingAtom = atom((get) => get(baseDesktopSettingsSyncCountAtom) > 0); + +export const desktopSettingsReadyAtom = atom( + (get) => get(baseDesktopSettingsAtom).ready && get(baseDesktopRuntimeStateAtom).ready +); diff --git a/src/app/state/hooks/desktopSettings.test.tsx b/src/app/state/hooks/desktopSettings.test.tsx new file mode 100644 index 0000000000..07d7e1998e --- /dev/null +++ b/src/app/state/hooks/desktopSettings.test.tsx @@ -0,0 +1,129 @@ +import { renderHook, act } from '@testing-library/react'; +import { createStore, Provider } from 'jotai'; +import { createElement, type ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { desktopSettingsReadyAtom } from '../desktopSettings'; +import { useDesktopSetting } from './desktopSettings'; + +const { mockEntries, mockGetDesktopRuntimeState, mockSet, mockSyncDesktopSettings } = vi.hoisted( + () => ({ + mockEntries: vi.fn(), + mockGetDesktopRuntimeState: vi.fn().mockResolvedValue({ trayAvailable: true }), + mockSet: vi.fn(), + mockSyncDesktopSettings: vi.fn().mockResolvedValue({ trayAvailable: true }), + }) +); + +vi.mock('@tauri-apps/plugin-store', () => ({ + LazyStore: class { + get: (key: string) => Promise; + + set: (key: string, value: unknown) => Promise; + + close: () => Promise; + + constructor() { + this.get = async (key: string) => { + const entries = (await mockEntries()) as Array<[string, unknown]>; + return entries.find(([entryKey]) => entryKey === key)?.[1]; + }; + this.set = async (key: string, value: unknown) => mockSet(key, value); + this.close = async () => undefined; + } + }, +})); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => true, +})); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: () => 'windows', +})); + +vi.mock('$generated/tauri/commands', () => ({ + getDesktopRuntimeState: mockGetDesktopRuntimeState, + syncDesktopSettings: mockSyncDesktopSettings, +})); + +function makeWrapper(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(Provider, { store }, children); + }; +} + +describe('useDesktopSetting', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEntries.mockResolvedValue([]); + }); + + it('reads and updates a desktop setting via the desktop settings hook', async () => { + mockEntries.mockResolvedValue([ + ['closeToBackgroundOnClose', false], + ['showSystemTrayIcon', false], + ['keepBackgroundRunning', false], + ]); + + const store = createStore(); + const readyUnsubscribe = store.sub(desktopSettingsReadyAtom, () => {}); + const { result } = renderHook(() => useDesktopSetting('closeToBackgroundOnClose'), { + wrapper: makeWrapper(store), + }); + + await vi.waitFor(() => { + expect(store.get(desktopSettingsReadyAtom)).toBe(true); + }); + + expect(result.current[0]).toBe(false); + + await act(async () => { + await result.current[1](true); + }); + + expect(mockSet).toHaveBeenCalledTimes(2); + expect(mockSet).toHaveBeenCalledWith('closeToBackgroundOnClose', true); + expect(mockSet).toHaveBeenCalledWith('keepBackgroundRunning', true); + expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ + settings: { + closeToBackgroundOnClose: true, + showSystemTrayIcon: false, + }, + }); + + readyUnsubscribe(); + }); + + it('turning off close behavior through the hook also clears the legacy flag', async () => { + mockEntries.mockResolvedValue([ + ['closeToBackgroundOnClose', true], + ['showSystemTrayIcon', true], + ['keepBackgroundRunning', true], + ]); + + const store = createStore(); + const readyUnsubscribe = store.sub(desktopSettingsReadyAtom, () => {}); + const { result } = renderHook(() => useDesktopSetting('closeToBackgroundOnClose'), { + wrapper: makeWrapper(store), + }); + + await vi.waitFor(() => { + expect(store.get(desktopSettingsReadyAtom)).toBe(true); + }); + + await act(async () => { + await result.current[1](false); + }); + + expect(mockSet).toHaveBeenCalledWith('closeToBackgroundOnClose', false); + expect(mockSet).toHaveBeenCalledWith('keepBackgroundRunning', false); + expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ + settings: { + closeToBackgroundOnClose: false, + showSystemTrayIcon: true, + }, + }); + + readyUnsubscribe(); + }); +}); diff --git a/src/app/state/hooks/desktopSettings.ts b/src/app/state/hooks/desktopSettings.ts new file mode 100644 index 0000000000..131cf26a04 --- /dev/null +++ b/src/app/state/hooks/desktopSettings.ts @@ -0,0 +1,57 @@ +import { atom, useAtomValue, useSetAtom } from 'jotai'; +import { selectAtom } from 'jotai/utils'; +import { useMemo } from 'react'; +import { + type DesktopRuntimeState, + type DesktopSettingKey, + type DesktopSettings, + desktopRuntimeStateAtom, + desktopSettingsAtom, + desktopSettingsReadyAtom, + desktopSettingsSyncingAtom, +} from '$state/desktopSettings'; + +export type DesktopSettingSetter = + | DesktopSettings[K] + | ((value: DesktopSettings[K]) => DesktopSettings[K]); + +function resolveDesktopSettingValue( + current: DesktopSettings[K], + value: DesktopSettingSetter +): DesktopSettings[K] { + if (typeof value === 'function') { + return (value as (next: DesktopSettings[K]) => DesktopSettings[K])(current); + } + + return value; +} + +export const useSetDesktopSetting = (key: K) => { + const setterAtom = useMemo( + () => + atom], Promise>(null, (get, set, value) => { + const settings = get(desktopSettingsAtom); + const nextValue = resolveDesktopSettingValue(settings[key], value); + return set(desktopSettingsAtom, { ...settings, [key]: nextValue } as DesktopSettings); + }), + [key] + ); + + return useSetAtom(setterAtom); +}; + +export const useDesktopSetting = ( + key: K +): [DesktopSettings[K], ReturnType>] => { + const selector = useMemo(() => (settings: DesktopSettings) => settings[key], [key]); + const setting = useAtomValue(selectAtom(desktopSettingsAtom, selector)); + const setter = useSetDesktopSetting(key); + + return [setting, setter]; +}; + +export const useDesktopSettingsReady = (): boolean => useAtomValue(desktopSettingsReadyAtom); +export const useDesktopSettingsSyncing = (): boolean => useAtomValue(desktopSettingsSyncingAtom); + +export const useDesktopRuntimeState = (): DesktopRuntimeState => + useAtomValue(desktopRuntimeStateAtom); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d918344093..ca14dd4f2d 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -1,6 +1,11 @@ import { atom, type WritableAtom } from 'jotai'; import type { Store } from 'jotai/vanilla/store'; import { mobileOrTablet } from '$utils/user-agent'; +import type { + NotificationTransportMode, + NotificationTransportProvider, + PushTransportOverrides, +} from '$features/settings/notifications/NotificationTransport'; import type { IImageInfo } from '$types/matrix/common'; const STORAGE_KEY = 'settings'; @@ -136,6 +141,7 @@ export interface Settings { showEncInteractiveMap: boolean; usePushNotifications: boolean; + useUnifiedPush: boolean; useInAppNotifications: boolean; useSystemNotifications: boolean; isNotificationSounds: boolean; @@ -143,6 +149,10 @@ export interface Settings { showMessageContentInNotifications: boolean; showMessageContentInEncryptedNotifications: boolean; clearNotificationsOnRead: boolean; + backgroundPushEnabled: boolean; + backgroundPushProvider: NotificationTransportProvider | null; + pushTransportMode: NotificationTransportMode; + pushTransportOverride: PushTransportOverrides; hour24Clock: boolean; dateFormatString: string; @@ -304,6 +314,7 @@ export 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, @@ -311,6 +322,10 @@ export const defaultSettings: Settings = { showMessageContentInNotifications: false, showMessageContentInEncryptedNotifications: false, clearNotificationsOnRead: false, + backgroundPushEnabled: mobileOrTablet(), + backgroundPushProvider: null, + pushTransportMode: 'auto', + pushTransportOverride: {}, hour24Clock: false, dateFormatString: 'D MMM YYYY', diff --git a/src/app/state/titlebarStatus.ts b/src/app/state/titlebarStatus.ts new file mode 100644 index 0000000000..c5b6bf5487 --- /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 0000000000..7e25eb9473 --- /dev/null +++ b/src/app/state/unifiedPushEndpoint.ts @@ -0,0 +1,40 @@ +import { atom } from 'jotai'; +import type { UnifiedPushRegistrationStatus } from '$features/settings/notifications/UnifiedPushTransport'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from './utils/atomWithLocalStorage'; + +const UP_ENDPOINT_KEY = 'unifiedPushEndpoint'; + +export type UnifiedPushState = { + endpoint?: string; + instance?: string; + appId?: string; + gatewayUrl?: string; + status?: UnifiedPushRegistrationStatus; + distributor?: string; + error?: string; + permissionState?: 'granted' | 'denied' | 'default'; + distributors?: string[]; + pubKeySet?: { + pubKey: string; + auth: 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/edgeToEdgeInsets.test.ts b/src/app/styles/edgeToEdgeInsets.test.ts new file mode 100644 index 0000000000..8dc4ec92d0 --- /dev/null +++ b/src/app/styles/edgeToEdgeInsets.test.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const rootDir = path.resolve(__dirname, '../../..'); + +const readWorkspaceFile = (relativePath: string): string => + fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); + +describe('android edge-to-edge inset contract', () => { + it('wires the mobile edge-to-edge plugin through Cargo and Tauri setup', () => { + const cargoToml = readWorkspaceFile('src-tauri/Cargo.toml'); + const tauriLib = readWorkspaceFile('src-tauri/src/lib.rs'); + + expect(cargoToml).toContain( + 'tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" }' + ); + expect(tauriLib).toContain('builder = builder.plugin(tauri_plugin_edge_to_edge::init());'); + }); + + it('keeps MainActivity out of the inset injection path', () => { + const mainActivity = readWorkspaceFile( + 'src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt' + ); + + expect(mainActivity).toContain('enableEdgeToEdge()'); + expect(mainActivity).not.toContain('s.setProperty('); + expect(mainActivity).not.toContain('setOnApplyWindowInsetsListener'); + expect(mainActivity).not.toContain('webView.webViewClient'); + }); + + it('moves portal ownership into the app shell', () => { + const indexHtml = readWorkspaceFile('index.html'); + const appTsx = readWorkspaceFile('src/app/pages/App.tsx'); + const appShell = readWorkspaceFile('src/app/components/app-shell/AppShell.tsx'); + const systemBarShell = readWorkspaceFile('src/app/components/app-shell/SystemBarShell.tsx'); + + expect(indexHtml).not.toContain('id="portalContainer"'); + expect(appTsx).toContain(''); + expect(appShell).toContain('const [portalContainer, setPortalContainer] = useState'); + expect(appShell).toContain(''); + expect(systemBarShell).toContain('ref={onPortalContainerChange}'); + }); + + it('uses the App shell as the only safe-area owner', () => { + const appShell = readWorkspaceFile('src/app/components/app-shell/AppShell.tsx'); + const systemBarShell = readWorkspaceFile('src/app/components/app-shell/SystemBarShell.tsx'); + const mobileCapability = readWorkspaceFile('src-tauri/capabilities/mobile.json'); + + expect(appShell).toContain('const contentHeight = useCustomWindowsTitleBar'); + expect(appShell).toContain("height: '100%'"); + expect(appShell).toContain('height: contentHeight'); + expect(appShell).toContain(''); + expect(systemBarShell).toContain('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))'); + expect(systemBarShell).toContain( + 'var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))' + ); + expect(systemBarShell).toContain('var(--sable-bg-container-line)'); + expect(systemBarShell).toContain("borderTop: '1px solid var(--sable-bg-container-line)'"); + expect(mobileCapability).toContain('"edge-to-edge:default"'); + }); + + it('removes the scattered safe-area css consumers', () => { + const indexCss = readWorkspaceFile('src/index.css'); + const pageStyles = readWorkspaceFile('src/app/components/page/style.css.ts'); + const sidebarStyles = readWorkspaceFile('src/app/components/sidebar/Sidebar.css.ts'); + const roomView = readWorkspaceFile('src/app/features/room/RoomView.tsx'); + const roomViewTypingStyles = readWorkspaceFile('src/app/features/room/RoomViewTyping.css.ts'); + const threadDrawerStyles = readWorkspaceFile('src/app/features/room/ThreadDrawer.css.ts'); + + expect(indexCss).not.toContain('--sable-inset-top'); + expect(indexCss).not.toContain('--sable-inset-bottom'); + expect(pageStyles).not.toContain('--sable-inset-'); + expect(sidebarStyles).not.toContain('--sable-inset-'); + expect(roomView).not.toContain('--sable-inset-'); + expect(roomViewTypingStyles).not.toContain('--sable-inset-'); + expect(threadDrawerStyles).not.toContain('--sable-inset-'); + }); + + it('keeps web banners viewport-anchored', () => { + const notificationBannerStyles = readWorkspaceFile( + 'src/app/components/notification-banner/NotificationBanner.css.ts' + ); + const telemetryBannerStyles = readWorkspaceFile( + 'src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts' + ); + + expect(notificationBannerStyles).toContain("position: 'fixed'"); + expect(notificationBannerStyles).toContain("top: 'env(safe-area-inset-top, 0)'"); + expect(telemetryBannerStyles).toContain("position: 'fixed'"); + expect(telemetryBannerStyles).toContain("bottom: 'env(safe-area-inset-bottom, 0)'"); + }); +}); diff --git a/src/app/styles/overrides/TauriDesktop.css b/src/app/styles/overrides/TauriDesktop.css new file mode 100644 index 0000000000..f09f856e0b --- /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/dom.ts b/src/app/utils/dom.ts index 50cba5424d..7e471868c4 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -1,3 +1,6 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { fetchMediaBlob, type MediaTransportOptions } from './mediaTransport'; + export const targetFromEvent = (evt: Event, selector: string): Element | undefined => { const targets = evt.composedPath() as Element[]; return targets.find((target) => target.matches?.(selector)); @@ -216,13 +219,27 @@ export const copyImageToClipboard = async (blob: Blob): Promise => { const ctx = canvas.getContext('2d'); ctx?.drawImage(bitmap, 0, 0); - try { - const finalBlob = await new Promise((resolve) => { - canvas.toBlob((result) => { - if (result) resolve(result); - }, 'image/png'); - }); + const finalBlob = await new Promise((resolve) => { + canvas.toBlob((result) => { + if (result) resolve(result); + }, 'image/png'); + }); + if (isTauri()) { + try { + const [{ writeImage }, { Image }] = await Promise.all([ + import('@tauri-apps/plugin-clipboard-manager'), + import('@tauri-apps/api/image'), + ]); + const image = await Image.fromBytes(await finalBlob.arrayBuffer()); + await writeImage(image); + return true; + } catch { + // fall back to the web clipboard API + } + } + + try { await navigator.clipboard.write([ new ClipboardItem({ 'image/png': finalBlob, @@ -235,22 +252,12 @@ export const copyImageToClipboard = async (blob: Blob): Promise => { } }; -export const copyToClipboard = async (text: string): Promise => { - if (navigator.clipboard) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } - } - - const host = document.body; +const legacyCopyToClipboard = (text: string): boolean => { const copyInput = document.createElement('input'); copyInput.style.position = 'fixed'; copyInput.style.opacity = '0'; copyInput.value = text; - host.append(copyInput); + document.body.append(copyInput); copyInput.select(); copyInput.setSelectionRange(0, 99999); @@ -266,6 +273,43 @@ export const copyToClipboard = async (text: string): Promise => { return copied; }; +// navigator.clipboard rejects on WebKitGTK (Tauri/Linux); prefer the native plugin. +export const copyToClipboard = async (text: string): Promise => { + if (isTauri()) { + try { + const { writeText } = await import('@tauri-apps/plugin-clipboard-manager'); + await writeText(text); + return true; + } catch { + // fall back to the web clipboard API + } + } + + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // fall back to execCommand + } + } + + return legacyCopyToClipboard(text); +}; + +export const readClipboardText = async (): Promise => { + if (isTauri()) { + try { + const { readText } = await import('@tauri-apps/plugin-clipboard-manager'); + return await readText(); + } catch { + // fall back to the web clipboard API + } + } + + return navigator.clipboard.readText(); +}; + export const setFavicon = (url: string): void => { const favicon = document.querySelector('#favicon'); if (!favicon) return; @@ -311,3 +355,18 @@ export const downloadTextFile = ( document.body.removeChild(a); URL.revokeObjectURL(url); }; + +export const loadImageElementFromMediaUrl = async ( + url: string, + options?: MediaTransportOptions +): Promise<{ blob: Blob; image: HTMLImageElement }> => { + const blob = await fetchMediaBlob(url, options); + const objectUrl = URL.createObjectURL(blob); + + try { + const image = await loadImageElement(objectUrl); + return { blob, image }; + } finally { + URL.revokeObjectURL(objectUrl); + } +}; diff --git a/src/app/utils/download.ts b/src/app/utils/download.ts index 6e853e6c94..7ab1442431 100644 --- a/src/app/utils/download.ts +++ b/src/app/utils/download.ts @@ -1,3 +1,8 @@ +import FileSaver from 'file-saver'; +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; +import { fetch } from '$utils/fetch'; + const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g; const CONTROL_CHARS = /\p{Cc}/gu; const BIDI_CONTROL_CHARS = /[\u202a-\u202e\u2066-\u2069]/g; @@ -44,3 +49,34 @@ export const getDownloadFilename = ( body?: unknown, fallback = 'download' ): string => sanitizeDownloadFilename(getAttachmentFilename(filename, body, fallback), fallback); + +async function resolveBlob(input: Blob | string): Promise { + if (typeof input !== 'string') return input; + const response = await fetch(input); + return response.blob(); +} + +// On Android the browser anchor download is a no-op (the WebView has no download +// handler), so route through the native file system plugin; elsewhere file-saver works. +export async function saveFileToDevice( + input: Blob | string, + filename: string, + mimeType?: string +): Promise { + if (isTauri() && osType() === 'android') { + const blob = await resolveBlob(input); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const { AndroidFs, AndroidPublicGeneralPurposeDir } = await import( + 'tauri-plugin-android-fs-api' + ); + const uri = await AndroidFs.createNewPublicFile( + AndroidPublicGeneralPurposeDir.Download, + filename, + mimeType || blob.type || null + ); + await AndroidFs.writeFile(uri, bytes); + return; + } + + FileSaver.saveAs(input, filename); +} diff --git a/src/app/utils/fetch.test.ts b/src/app/utils/fetch.test.ts new file mode 100644 index 0000000000..12bfb3de17 --- /dev/null +++ b/src/app/utils/fetch.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const nativeFetch = vi.fn(); +const tauriFetch = vi.fn(); +const invoke = vi.fn(); +const isTauri = vi.fn(); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke, + isTauri, +})); + +vi.mock('@tauri-apps/plugin-http', () => ({ + fetch: tauriFetch, +})); + +describe('app fetch wrapper', () => { + const TEST_TIMEOUT = 20_000; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubGlobal('fetch', nativeFetch); + isTauri.mockReturnValue(false); + nativeFetch.mockResolvedValue(new Response('native')); + tauriFetch.mockResolvedValue(new Response('tauri')); + invoke.mockResolvedValue({ + status: 200, + statusText: 'OK', + url: 'http://localhost:8008/_matrix/client/versions', + headers: [['content-type', 'application/json']], + body: Array.from(new TextEncoder().encode('{"ok":true}')), + }); + }); + + it( + 'uses native fetch on web', + async () => { + const { fetch } = await import('./fetch'); + + const response = await fetch('https://matrix.example.org/_matrix/client/versions'); + + expect(nativeFetch).toHaveBeenCalledWith( + 'https://matrix.example.org/_matrix/client/versions', + undefined + ); + expect(tauriFetch).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + expect(await response.text()).toBe('native'); + }, + TEST_TIMEOUT + ); + + it( + 'uses native fetch for relative URLs in Tauri', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + + await fetch('/config.json', { method: 'GET' }); + + expect(nativeFetch).toHaveBeenCalledWith('/config.json', { method: 'GET' }); + expect(tauriFetch).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + }, + TEST_TIMEOUT + ); + + it( + 'uses native fetch for blob and data URLs in Tauri', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + + await fetch('blob:https://sable.chat/blob-id'); + await fetch('data:text/plain,hi'); + + expect(nativeFetch).toHaveBeenCalledTimes(2); + expect(tauriFetch).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + }, + TEST_TIMEOUT + ); + + it( + 'uses plugin-http for remote https URLs in Tauri', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + + await fetch('https://matrix.example.org/_matrix/client/versions'); + + expect(tauriFetch).toHaveBeenCalledTimes(1); + expect(nativeFetch).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + }, + TEST_TIMEOUT + ); + + it( + 'uses loopback_fetch for localhost in Tauri', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + + const response = await fetch('http://localhost:8008/_matrix/client/versions'); + + expect(invoke).toHaveBeenCalledWith( + 'loopback_fetch', + expect.objectContaining({ + request: expect.objectContaining({ + method: 'GET', + url: 'http://localhost:8008/_matrix/client/versions', + }), + }) + ); + expect(tauriFetch).not.toHaveBeenCalled(); + expect(nativeFetch).not.toHaveBeenCalled(); + expect(await response.json()).toEqual({ ok: true }); + }, + TEST_TIMEOUT + ); + + it( + 'uses loopback_fetch for 127.0.0.1 in Tauri', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + + await fetch('http://127.0.0.1:8008/_matrix/client/versions'); + + expect(invoke).toHaveBeenCalledWith( + 'loopback_fetch', + expect.objectContaining({ + request: expect.objectContaining({ + url: 'http://127.0.0.1:8008/_matrix/client/versions', + }), + }) + ); + }, + TEST_TIMEOUT + ); + + it( + 'uses loopback_fetch for https localhost in Tauri', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + + await fetch('https://localhost:8448/_matrix/client/versions'); + + expect(invoke).toHaveBeenCalledWith( + 'loopback_fetch', + expect.objectContaining({ + request: expect.objectContaining({ + url: 'https://localhost:8448/_matrix/client/versions', + }), + }) + ); + expect(tauriFetch).not.toHaveBeenCalled(); + expect(nativeFetch).not.toHaveBeenCalled(); + }, + TEST_TIMEOUT + ); + + it( + 'uses loopback_fetch for https 127.0.0.1 in Tauri', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + + await fetch('https://127.0.0.1:8448/_matrix/client/versions'); + + expect(invoke).toHaveBeenCalledWith( + 'loopback_fetch', + expect.objectContaining({ + request: expect.objectContaining({ + url: 'https://127.0.0.1:8448/_matrix/client/versions', + }), + }) + ); + expect(tauriFetch).not.toHaveBeenCalled(); + expect(nativeFetch).not.toHaveBeenCalled(); + }, + TEST_TIMEOUT + ); + + it( + 'uses loopback_fetch for [::1] in Tauri', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + + await fetch('http://[::1]:8008/_matrix/client/versions'); + + expect(invoke).toHaveBeenCalledWith( + 'loopback_fetch', + expect.objectContaining({ + request: expect.objectContaining({ + url: 'http://[::1]:8008/_matrix/client/versions', + }), + }) + ); + }, + TEST_TIMEOUT + ); + + it( + 'does not invoke loopback_fetch when the signal is already aborted', + async () => { + isTauri.mockReturnValue(true); + const { fetch } = await import('./fetch'); + const controller = new AbortController(); + controller.abort(); + + await expect( + fetch('http://localhost:8008/_matrix/client/versions', { + signal: controller.signal, + }) + ).rejects.toMatchObject({ name: 'AbortError' }); + + expect(invoke).not.toHaveBeenCalled(); + }, + TEST_TIMEOUT + ); + + it( + 'aborts loopback_fetch when the signal aborts after invoke starts', + async () => { + isTauri.mockReturnValue(true); + const controller = new AbortController(); + invoke.mockImplementationOnce(() => { + controller.abort(); + return new Promise(() => {}); + }); + const { fetch } = await import('./fetch'); + + const request = fetch('http://localhost:8008/_matrix/client/versions', { + signal: controller.signal, + }); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(invoke).toHaveBeenCalledTimes(2); + expect(invoke).toHaveBeenNthCalledWith( + 1, + 'loopback_fetch', + expect.objectContaining({ + request: expect.objectContaining({ + requestId: expect.any(String), + }), + }) + ); + const loopbackRequestId = invoke.mock.calls[0]?.[1]?.request?.requestId; + expect(invoke).toHaveBeenNthCalledWith(2, 'abort_loopback_fetch', { + requestId: loopbackRequestId, + }); + }, + TEST_TIMEOUT + ); +}); diff --git a/src/app/utils/fetch.ts b/src/app/utils/fetch.ts new file mode 100644 index 0000000000..e8e9642ece --- /dev/null +++ b/src/app/utils/fetch.ts @@ -0,0 +1,177 @@ +import { invoke, isTauri } from '@tauri-apps/api/core'; + +type AppFetch = typeof globalThis.fetch; + +type LoopbackFetchRequest = { + requestId: string; + method: string; + url: string; + headers: [string, string][]; + body: number[] | null; +}; + +type LoopbackFetchResponse = { + status: number; + statusText: string; + url: string; + headers: [string, string][]; + body: number[]; +}; + +const nativeFetch: AppFetch = (input, init) => globalThis.fetch(input, init); +let tauriFetchPromise: Promise | undefined; +const ABSOLUTE_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const createRequestId = (): string => + globalThis.crypto?.randomUUID?.() ?? + `loopback-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +const isSameOriginUrl = (url: URL): boolean => url.origin === window.location.origin; + +const isLoopbackHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '[::1]'; + +const isNetworkUrl = (url: URL): boolean => url.protocol === 'http:' || url.protocol === 'https:'; + +// Tauri custom-protocol hosts (`.localhost`) are served by the webview, not the network. +const isTauriProtocolHost = (hostname: string): boolean => hostname.endsWith('.localhost'); + +const getAbortSignal = (input: RequestInfo | URL, init?: RequestInit): AbortSignal | undefined => { + if (input instanceof Request) { + return init?.signal ?? input.signal ?? undefined; + } + + return init?.signal ?? undefined; +}; + +const createAbortError = (signal?: AbortSignal): DOMException => + new DOMException( + signal?.reason instanceof Error ? signal.reason.message : 'The operation was aborted', + 'AbortError' + ); + +const throwIfAborted = (signal?: AbortSignal): void => { + if (signal?.aborted) { + throw createAbortError(signal); + } +}; + +async function raceWithAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return promise; + } + + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + const handleAbort = () => { + reject(createAbortError(signal)); + }; + + signal.addEventListener('abort', handleAbort, { once: true }); + + promise.then( + (value) => { + signal.removeEventListener('abort', handleAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', handleAbort); + reject(error); + } + ); + }); +} + +async function buildLoopbackRequest( + input: RequestInfo | URL, + requestId: string, + init?: RequestInit +): Promise { + const request = new Request(input, init); + const body = await request.arrayBuffer(); + const headers: [string, string][] = []; + + request.headers.forEach((value, key) => { + headers.push([key, value]); + }); + + return { + requestId, + method: request.method, + url: request.url, + headers, + body: body.byteLength > 0 ? Array.from(new Uint8Array(body)) : null, + }; +} + +async function abortLoopbackFetch(requestId: string): Promise { + try { + await invoke('abort_loopback_fetch', { requestId }); + } catch { + // Best-effort cancellation. A completed request may already be gone. + } +} + +async function loopbackFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const signal = getAbortSignal(input, init); + throwIfAborted(signal); + + const requestId = createRequestId(); + const request = await buildLoopbackRequest(input, requestId, init); + throwIfAborted(signal); + const handleAbort = () => { + abortLoopbackFetch(requestId).catch(() => undefined); + }; + + signal?.addEventListener('abort', handleAbort, { once: true }); + + try { + const response = await raceWithAbort( + invoke('loopback_fetch', { request }), + signal + ); + + return new Response(new Uint8Array(response.body), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } finally { + signal?.removeEventListener('abort', handleAbort); + } +} + +async function getTauriFetch(): Promise { + if (!tauriFetchPromise) { + tauriFetchPromise = import('@tauri-apps/plugin-http').then(({ fetch }) => fetch as AppFetch); + } + + return tauriFetchPromise; +} + +export const fetch: AppFetch = async (input, init) => { + if (!isTauri()) { + return nativeFetch(input, init); + } + + if (typeof input === 'string' && !ABSOLUTE_SCHEME_RE.test(input) && !input.startsWith('//')) { + return nativeFetch(input, init); + } + + const request = new Request(input, init); + const url = new URL(request.url, window.location.href); + + if (!isNetworkUrl(url) || isSameOriginUrl(url) || isTauriProtocolHost(url.hostname)) { + return nativeFetch(input, init); + } + + if (isLoopbackHost(url.hostname)) { + return loopbackFetch(request); + } + + const tauriFetch = await getTauriFetch(); + return tauriFetch(request, init); +}; diff --git a/src/app/utils/matrix.test.ts b/src/app/utils/matrix.test.ts new file mode 100644 index 0000000000..91730d3efd --- /dev/null +++ b/src/app/utils/matrix.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tauriApi = vi.hoisted(() => ({ + isTauri: vi.fn(), + convertFileSrc: vi.fn((url: string, protocol: string) => `${protocol}://${url}`), +})); + +vi.mock('@tauri-apps/api/core', () => tauriApi); + +const { rewriteAuthenticatedMediaUrl } = await import('./matrix'); + +describe('rewriteAuthenticatedMediaUrl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null for null input', () => { + tauriApi.isTauri.mockReturnValue(true); + expect(rewriteAuthenticatedMediaUrl(null)).toBeNull(); + }); + + it('passes through non-Tauri without rewriting', () => { + tauriApi.isTauri.mockReturnValue(false); + const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/abc'; + expect(rewriteAuthenticatedMediaUrl(url)).toBe(url); + expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); + }); + + it('passes through plain https URLs that are not authenticated media', () => { + tauriApi.isTauri.mockReturnValue(true); + const url = 'https://example.org/avatar.png'; + expect(rewriteAuthenticatedMediaUrl(url)).toBe(url); + expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); + }); + + it('rewrites authenticated-media download URLs under Tauri', () => { + tauriApi.isTauri.mockReturnValue(true); + const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/abc123'; + expect(rewriteAuthenticatedMediaUrl(url)).toBe(`sable-media://${url}`); + expect(tauriApi.convertFileSrc).toHaveBeenCalledWith(url, 'sable-media'); + }); + + it('rewrites authenticated-media thumbnail URLs under Tauri', () => { + tauriApi.isTauri.mockReturnValue(true); + const url = 'https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/abc123?width=96&height=96&method=crop'; + expect(rewriteAuthenticatedMediaUrl(url)).toBe(`sable-media://${url}`); + }); + + it('passes through already-rewritten sable-media:// URLs', () => { + tauriApi.isTauri.mockReturnValue(true); + const url = 'sable-media://https://matrix.example.org/_matrix/client/v1/media/download/example.org/abc123'; + expect(rewriteAuthenticatedMediaUrl(url)).toBe(url); + expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 5ecf009668..ae63f97d90 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -1,5 +1,6 @@ import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { decryptAttachment, encryptAttachment } from 'browser-encrypt-attachment'; +import { convertFileSrc, isTauri } from '@tauri-apps/api/core'; import type { AccountDataEvents, EventTimelineSet, @@ -19,6 +20,7 @@ import * as Sentry from '@sentry/react'; import { getEventReactions, getStateEvent } from './room'; import { getReactionContent } from './messageReaction'; import { matchMxId, validMxId } from './mxIdHelper'; +import { fetchMediaBlob, type MediaTransportOptions } from './mediaTransport'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -313,6 +315,14 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): ); }; +export const rewriteAuthenticatedMediaUrl = (httpUrl: string | null): string | null => { + if (!httpUrl) return null; + if (!isTauri()) return httpUrl; + if (httpUrl.startsWith('sable-media://')) return httpUrl; + if (!httpUrl.includes('/_matrix/client/v1/media/')) return httpUrl; + return convertFileSrc(httpUrl, 'sable-media'); +}; + export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string, @@ -321,8 +331,8 @@ export const mxcUrlToHttp = ( height?: number, resizeMethod?: string, allowDirectLinks?: boolean -): string | null => - mx.mxcUrlToHttp( +): string | null => { + const httpUrl = mx.mxcUrlToHttp( mxcUrl.replace(/^["']|["']$/g, ''), width, height, @@ -332,21 +342,23 @@ 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; + // Authenticated media has no service worker under Tauri to attach the token, so route it + // through the native sable-media:// protocol which injects the token in Rust. + if (httpUrl && useAuthentication) { + return rewriteAuthenticatedMediaUrl(httpUrl); + } + return httpUrl; }; +export const downloadMedia = async (src: string, options?: MediaTransportOptions): Promise => + fetchMediaBlob(src, options); + export const downloadEncryptedMedia = async ( src: string, decryptContent: (buf: ArrayBuffer) => Promise ): Promise => { const encryptedContent = await downloadMedia(src); - const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer()); - - return decryptedContent; + return decryptContent(await encryptedContent.arrayBuffer()); }; const sleepForMs = (ms: number) => diff --git a/src/app/utils/mediaCache.ts b/src/app/utils/mediaCache.ts new file mode 100644 index 0000000000..1f013c7687 --- /dev/null +++ b/src/app/utils/mediaCache.ts @@ -0,0 +1,68 @@ +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; + } +} + +function getCacheRequest(url: string): Request { + const isAbsoluteHttpUrl = /^https?:\/\//i.test(url); + if (!isAbsoluteHttpUrl) { + return new Request(`https://sable-media-cache.invalid/${encodeURIComponent(url)}`); + } + + try { + return new Request(url); + } catch { + return new Request(`https://sable-media-cache.invalid/${encodeURIComponent(url)}`); + } +} + +export async function getFromMediaCache(url: string): Promise { + const cache = await openCache(); + if (!cache) return undefined; + try { + const response = await cache.match(getCacheRequest(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. + } +} + +// Cache contract: +// - keyed by the final media URL +// - stores successful responses only +// - persists blobs, never error responses or intermediate auth state +export async function putInMediaCache(url: string, blob: Blob): Promise { + const cache = await openCache(); + if (!cache) return; + try { + await cache.put( + getCacheRequest(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/mediaTransport.test.ts b/src/app/utils/mediaTransport.test.ts new file mode 100644 index 0000000000..ef4a7c88c2 --- /dev/null +++ b/src/app/utils/mediaTransport.test.ts @@ -0,0 +1,413 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const platform = vi.hoisted(() => ({ + hasControllingServiceWorker: vi.fn(), +})); + +const mediaCache = vi.hoisted(() => { + const cache = new Map(); + return { + cache, + getFromMediaCache: vi.fn(async (url: string) => cache.get(url)), + putInMediaCache: vi.fn(async (url: string, blob: Blob) => { + cache.set(url, blob); + }), + }; +}); + +const appFetch = vi.hoisted(() => ({ + fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init)), +})); + +vi.mock('$utils/platform', () => platform); +vi.mock('$utils/fetch', () => appFetch); +vi.mock('./mediaCache', () => mediaCache); + +describe('fetchMediaBlob', () => { + const TEST_TIMEOUT = 20_000; + + beforeEach(() => { + vi.resetModules(); + platform.hasControllingServiceWorker.mockReset(); + platform.hasControllingServiceWorker.mockReturnValue(false); + appFetch.fetch.mockClear(); + mediaCache.cache.clear(); + mediaCache.getFromMediaCache.mockClear(); + mediaCache.putInMediaCache.mockClear(); + localStorage.clear(); + vi.stubGlobal('fetch', vi.fn()); + }); + + it( + 'returns cached blobs for default requests', + async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/media.png'; + const cachedBlob = new Blob(['cached'], { type: 'image/png' }); + const scopedUrl = `anonymous:${url}`; + mediaCache.cache.set(scopedUrl, cachedBlob); + + const blob = await fetchMediaBlob(url); + + expect(blob).toBe(cachedBlob); + expect(mediaCache.getFromMediaCache).toHaveBeenCalledWith(scopedUrl); + expect(fetch).not.toHaveBeenCalled(); + expect(mediaCache.putInMediaCache).not.toHaveBeenCalled(); + }, + TEST_TIMEOUT + ); + + it( + 'does not reuse cached blobs across different active sessions', + async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/media.png'; + + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'alice-token', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@alice:example.org'); + + const aliceBlob = new Blob(['alice'], { type: 'image/png' }); + vi.mocked(fetch).mockResolvedValueOnce(new Response(aliceBlob, { status: 200 })); + + await expect(fetchMediaBlob(url)).resolves.toEqual(aliceBlob); + + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@bob:example.org', + deviceId: 'DEVICE', + accessToken: 'bob-token', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@bob:example.org'); + + const bobBlob = new Blob(['bob'], { type: 'image/png' }); + vi.mocked(fetch).mockResolvedValueOnce(new Response(bobBlob, { status: 200 })); + + await expect(fetchMediaBlob(url)).resolves.toEqual(bobBlob); + + expect(fetch).toHaveBeenCalledTimes(2); + }, + TEST_TIMEOUT + ); + + it( + 'uses caller-provided auth and cache scope when present', + async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/media.png'; + const freshBlob = new Blob(['fresh'], { type: 'image/png' }); + const getAccessToken = vi.fn(() => 'widget-token'); + const headersSeen: Array = []; + + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + return new Response(freshBlob, { status: 200 }); + }); + + const blob = await fetchMediaBlob(url, { + getAccessToken, + sessionScope: '@widget:example.org', + }); + + expect(blob).toEqual(freshBlob); + expect(getAccessToken).toHaveBeenCalledTimes(1); + expect(headersSeen).toEqual(['Bearer widget-token']); + expect(mediaCache.putInMediaCache).toHaveBeenCalledWith( + '@widget:example.org:https://example.org/media.png', + freshBlob + ); + }, + TEST_TIMEOUT + ); + + it( + 'does not fall back to stored auth when an override getter returns undefined', + async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/media.png'; + const freshBlob = new Blob(['fresh'], { type: 'image/png' }); + const getAccessToken = vi.fn(() => undefined); + const headersSeen: Array = []; + + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@bob:example.org', + deviceId: 'DEVICE', + accessToken: 'bob-token', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@bob:example.org'); + + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + return new Response(freshBlob, { status: 200 }); + }); + + const blob = await fetchMediaBlob(url, { + getAccessToken, + sessionScope: undefined, + }); + + expect(blob).toEqual(freshBlob); + expect(getAccessToken).toHaveBeenCalledTimes(1); + expect(headersSeen).toEqual([null]); + expect(mediaCache.putInMediaCache).toHaveBeenCalledWith( + 'anonymous:https://example.org/media.png', + freshBlob + ); + }, + TEST_TIMEOUT + ); + + it('bypasses cache reads for reload requests but still stores successes', async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/media.png'; + const scopedUrl = `anonymous:${url}`; + mediaCache.cache.set(scopedUrl, new Blob(['stale'], { type: 'image/png' })); + const freshBlob = new Blob(['fresh'], { type: 'image/png' }); + vi.mocked(fetch).mockResolvedValueOnce(new Response(freshBlob, { status: 200 })); + + const blob = await fetchMediaBlob(url, { cache: 'reload' }); + + expect(blob).toEqual(freshBlob); + expect(mediaCache.getFromMediaCache).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(mediaCache.putInMediaCache).toHaveBeenCalledWith(scopedUrl, freshBlob); + }); + + it('skips cache reads and writes for bypass requests', async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/media.png'; + const freshBlob = new Blob(['fresh'], { type: 'image/png' }); + vi.mocked(fetch).mockResolvedValueOnce(new Response(freshBlob, { status: 200 })); + + const blob = await fetchMediaBlob(url, { cache: 'bypass' }); + + expect(blob).toEqual(freshBlob); + expect(mediaCache.getFromMediaCache).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(mediaCache.putInMediaCache).not.toHaveBeenCalled(); + }); + + it('dedupes inflight requests for the same url and cache mode', async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/media.png'; + let resolveFetch: (value: Response) => void = () => undefined; + const pending = new Promise((resolve) => { + resolveFetch = resolve; + }); + vi.mocked(fetch).mockReturnValueOnce(pending); + + const promiseA = fetchMediaBlob(url); + const promiseB = fetchMediaBlob(url); + + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + + resolveFetch(new Response('deduped', { status: 200 })); + + await expect(promiseA).resolves.toHaveProperty('size', 7); + await expect(promiseB).resolves.toHaveProperty('size', 7); + expect(mediaCache.putInMediaCache).toHaveBeenCalledTimes(1); + }); + + it('re-resolves auth once after a 401 in direct-fetch mode', async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id'; + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'token-1', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@alice:example.org'); + + const headersSeen: Array = []; + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + if (headersSeen.length === 1) { + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'token-2', + }, + ]) + ); + return new Response('denied', { status: 401 }); + } + return new Response('ok', { status: 200 }); + }); + + const blob = await fetchMediaBlob(url); + + expect(await blob.text()).toBe('ok'); + expect(headersSeen).toEqual(['Bearer token-1', 'Bearer token-2']); + expect(fetch).toHaveBeenCalledTimes(2); + expect(mediaCache.putInMediaCache).toHaveBeenCalledTimes(1); + }); + + it('does not send stored auth to untrusted origins', async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://attacker.example/media.png'; + const headersSeen: Array = []; + + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'token-1', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@alice:example.org'); + + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + return new Response('ok', { status: 200 }); + }); + + const blob = await fetchMediaBlob(url); + + expect(await blob.text()).toBe('ok'); + expect(headersSeen).toEqual([null]); + }); + + it('does not send stored auth to non-media paths on the homeserver origin', async () => { + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://matrix.example.org/not-media.png'; + const headersSeen: Array = []; + + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'token-1', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@alice:example.org'); + + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + return new Response('ok', { status: 200 }); + }); + + const blob = await fetchMediaBlob(url); + + expect(await blob.text()).toBe('ok'); + expect(headersSeen).toEqual([null]); + }); + + it('retries once on the service worker path without direct auth headers', async () => { + platform.hasControllingServiceWorker.mockReturnValue(true); + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/auth-media.png'; + + const headersSeen: Array = []; + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + if (headersSeen.length === 1) { + return new Response('denied', { status: 403 }); + } + return new Response('ok', { status: 200 }); + }); + + const blob = await fetchMediaBlob(url); + + expect(await blob.text()).toBe('ok'); + expect(headersSeen).toEqual([null, null]); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('bypasses the service worker path when explicit auth overrides are provided', async () => { + platform.hasControllingServiceWorker.mockReturnValue(true); + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://example.org/auth-media.png'; + const getAccessToken = vi.fn(() => 'widget-token'); + const headersSeen: Array = []; + + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + return new Response('ok', { status: 200 }); + }); + + const blob = await fetchMediaBlob(url, { + getAccessToken, + sessionScope: '@widget:example.org', + }); + + expect(await blob.text()).toBe('ok'); + expect(getAccessToken).toHaveBeenCalledTimes(1); + expect(headersSeen).toEqual(['Bearer widget-token']); + }); + + it('uses direct auth fetches when service workers are supported but not controlling', async () => { + platform.hasControllingServiceWorker.mockReturnValue(false); + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id'; + const headersSeen: Array = []; + + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'token-1', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@alice:example.org'); + + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + return new Response('ok', { status: 200 }); + }); + + const blob = await fetchMediaBlob(url); + + expect(await blob.text()).toBe('ok'); + expect(headersSeen).toEqual(['Bearer token-1']); + }); +}); diff --git a/src/app/utils/mediaTransport.ts b/src/app/utils/mediaTransport.ts new file mode 100644 index 0000000000..0fb286f925 --- /dev/null +++ b/src/app/utils/mediaTransport.ts @@ -0,0 +1,286 @@ +import { hasControllingServiceWorker } from '$utils/platform'; +import { fetch } from '$utils/fetch'; +import { getFromMediaCache, putInMediaCache } from './mediaCache'; + +type StoredSession = { + baseUrl?: string; + userId: string; + accessToken: string; +}; + +export type MediaFetchCacheMode = 'default' | 'reload' | 'bypass'; + +export type MediaTransportOptions = { + cache?: MediaFetchCacheMode; + accessToken?: string | null; + getAccessToken?: () => string | null | undefined; + sessionScope?: string; +}; + +const inflightRequests = new Map>(); + +const MATRIX_SESSIONS_KEY = 'matrixSessions'; +const ACTIVE_SESSION_KEY = 'matrixActiveSession'; +const FALLBACK_ACCESS_TOKEN_KEY = 'cinny_access_token'; +const FALLBACK_USER_ID_KEY = 'cinny_user_id'; +const FALLBACK_BASE_URL_KEY = 'cinny_hs_base_url'; +const MATRIX_MEDIA_PATH_PREFIXES = ['/_matrix/media/', '/_matrix/client/v1/media/']; + +function parseStoredSessions(): StoredSession[] { + if (typeof localStorage === 'undefined') return []; + + try { + const raw = localStorage.getItem(MATRIX_SESSIONS_KEY); + if (!raw) return []; + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + + return parsed.flatMap((session): StoredSession[] => { + if ( + typeof session !== 'object' || + session === null || + typeof (session as { userId?: unknown }).userId !== 'string' || + typeof (session as { accessToken?: unknown }).accessToken !== 'string' + ) { + return []; + } + + return [ + { + baseUrl: + typeof (session as { baseUrl?: unknown }).baseUrl === 'string' + ? (session as { baseUrl: string }).baseUrl + : undefined, + userId: (session as StoredSession).userId, + accessToken: (session as StoredSession).accessToken, + }, + ]; + }); + } catch { + return []; + } +} + +function getActiveStoredSession(): StoredSession | undefined { + if (typeof localStorage === 'undefined') return undefined; + + const sessions = parseStoredSessions(); + const activeSessionId = localStorage.getItem(ACTIVE_SESSION_KEY); + return ( + (activeSessionId + ? sessions.find((session) => session.userId === activeSessionId) + : undefined) ?? sessions[0] + ); +} + +function getUrlOrigin(url: string): string | undefined { + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +function isMatrixMediaPath(pathname: string): boolean { + return MATRIX_MEDIA_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} + +export function isTrustedMatrixMediaUrl(url: string, baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') return false; + return parsedUrl.origin === getUrlOrigin(baseUrl) && isMatrixMediaPath(parsedUrl.pathname); + } catch { + return false; + } +} + +function getStoredAccessToken(url: string): string | undefined { + if (typeof localStorage === 'undefined') return undefined; + + const activeSession = getActiveStoredSession(); + + if (activeSession?.accessToken && isTrustedMatrixMediaUrl(url, activeSession.baseUrl)) { + return activeSession.accessToken; + } + + const fallbackBaseUrl = localStorage.getItem(FALLBACK_BASE_URL_KEY); + const fallbackUserId = localStorage.getItem(FALLBACK_USER_ID_KEY); + const fallbackAccessToken = localStorage.getItem(FALLBACK_ACCESS_TOKEN_KEY); + + if ( + fallbackBaseUrl && + fallbackUserId && + fallbackAccessToken && + isTrustedMatrixMediaUrl(url, fallbackBaseUrl) + ) { + return fallbackAccessToken; + } + + return undefined; +} + +export function getActiveMediaSession(): { baseUrl: string; accessToken: string } | undefined { + const activeSession = getActiveStoredSession(); + if (activeSession?.baseUrl && activeSession.accessToken) { + return { baseUrl: activeSession.baseUrl, accessToken: activeSession.accessToken }; + } + return undefined; +} + +export function getCurrentMediaSessionScope(): string { + if (typeof localStorage === 'undefined') return 'anonymous'; + + const activeSession = getActiveStoredSession(); + + if (activeSession) { + return activeSession.userId; + } + + const fallbackBaseUrl = localStorage.getItem(FALLBACK_BASE_URL_KEY); + const fallbackUserId = localStorage.getItem(FALLBACK_USER_ID_KEY); + + if (fallbackBaseUrl && fallbackUserId) { + return fallbackUserId; + } + + return 'anonymous'; +} + +function getFetchCacheMode(cacheMode: MediaFetchCacheMode): RequestCache { + if (cacheMode === 'reload') return 'reload'; + if (cacheMode === 'bypass') return 'no-store'; + return 'default'; +} + +function getRequestKey(url: string, cacheMode: MediaFetchCacheMode): string { + return `${cacheMode}:${url}`; +} + +function getScopedMediaCacheKey(url: string, sessionScope?: string): string { + return `${sessionScope ?? getCurrentMediaSessionScope()}:${url}`; +} + +function resolveAccessToken(url: string, options?: MediaTransportOptions): string | undefined { + if (options && Object.hasOwn(options, 'getAccessToken')) { + return typeof options.getAccessToken === 'function' + ? (options.getAccessToken() ?? undefined) + : undefined; + } + + if (options && Object.hasOwn(options, 'accessToken')) { + return options.accessToken ?? undefined; + } + + return getStoredAccessToken(url); +} + +function resolveSessionScope(options?: MediaTransportOptions): string { + if (options && Object.hasOwn(options, 'sessionScope')) { + return options.sessionScope ?? 'anonymous'; + } + + return getCurrentMediaSessionScope(); +} + +function hasExplicitMediaAuthOverride(options?: MediaTransportOptions): boolean { + if (!options) return false; + + return ( + Object.hasOwn(options, 'accessToken') || + Object.hasOwn(options, 'getAccessToken') || + Object.hasOwn(options, 'sessionScope') + ); +} + +function isRetryableAuthError(response: Response): boolean { + return response.status === 401 || response.status === 403; +} + +async function fetchMediaResponse( + url: string, + accessToken?: string | null, + cacheMode: MediaFetchCacheMode = 'default' +): Promise { + const headers: HeadersInit = {}; + const token = accessToken ?? undefined; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const init: RequestInit = { + method: 'GET', + cache: getFetchCacheMode(cacheMode), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + }; + + return fetch(url, init); +} + +async function fetchMediaBlobInternal(url: string, options?: MediaTransportOptions): Promise { + const cacheMode = options?.cache ?? 'default'; + const resolvedScope = resolveSessionScope(options); + const scopedCacheKey = getScopedMediaCacheKey(url, resolvedScope); + + if (cacheMode === 'default') { + const cachedBlob = await getFromMediaCache(scopedCacheKey); + if (cachedBlob) return cachedBlob; + } + + const useServiceWorker = hasControllingServiceWorker() && !hasExplicitMediaAuthOverride(options); + const fetchAndCache = async (response: Response): Promise => { + if (!response.ok) { + throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + if (cacheMode !== 'bypass') { + await putInMediaCache(scopedCacheKey, blob); + } + return blob; + }; + + if (useServiceWorker) { + const response = await fetchMediaResponse(url, undefined, cacheMode); + if (response.ok || !isRetryableAuthError(response)) { + return fetchAndCache(response); + } + return fetchAndCache(await fetchMediaResponse(url, undefined, cacheMode)); + } + + const initialAccessToken = resolveAccessToken(url, options); + const initialResponse = await fetchMediaResponse(url, initialAccessToken, cacheMode); + if (initialResponse.ok) { + return fetchAndCache(initialResponse); + } + + if (!isRetryableAuthError(initialResponse)) { + throw new Error( + `Failed to fetch media: ${initialResponse.status} ${initialResponse.statusText}` + ); + } + + const retryAccessToken = resolveAccessToken(url, options); + return fetchAndCache(await fetchMediaResponse(url, retryAccessToken, cacheMode)); +} + +export async function fetchMediaBlob(url: string, options?: MediaTransportOptions): Promise { + const cacheMode = options?.cache ?? 'default'; + const requestKey = getRequestKey( + getScopedMediaCacheKey(url, resolveSessionScope(options)), + cacheMode + ); + + const inflight = inflightRequests.get(requestKey); + if (inflight) return inflight; + + const request = fetchMediaBlobInternal(url, options).finally(() => { + inflightRequests.delete(requestKey); + }); + + inflightRequests.set(requestKey, request); + return request; +} diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts index a25842e4b5..c3b9032f05 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts @@ -1,5 +1,6 @@ import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; import { 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); @@ -37,4 +38,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 0000000000..d5bf964bb7 --- /dev/null +++ b/src/app/utils/platform.ts @@ -0,0 +1,17 @@ +import { isTauri } from '@tauri-apps/api/core'; + +export function hasServiceWorker(): boolean { + // Android WebViews (Tauri) do not support service workers. + return 'serviceWorker' in navigator && !isTauri(); +} + +export function hasControllingServiceWorker(): boolean { + return hasServiceWorker() && navigator.serviceWorker.controller !== null; +} + +// window.location.origin is "null" on Tauri (tauri:// is opaque per WHATWG). +export function getAppOrigin(): string { + return window.location.origin === 'null' + ? `${window.location.protocol}//${window.location.host}` + : window.location.origin; +} diff --git a/src/app/utils/sanitize.test.ts b/src/app/utils/sanitize.test.ts index 57fad523ac..c68146eb60 100644 --- a/src/app/utils/sanitize.test.ts +++ b/src/app/utils/sanitize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { sanitizeCustomHtml } from './sanitize'; +import { getSafeMediaUrl, sanitizeCustomHtml } from './sanitize'; describe('sanitizeCustomHtml', () => { it('keeps permitted Matrix v1.18 tags', () => { @@ -156,3 +156,18 @@ describe('sanitizeCustomHtml', () => { expect((result.match(/
/g) ?? []).length).toBeLessThanOrEqual(100); }); }); + +describe('getSafeMediaUrl', () => { + it('allows blob, http, https, and valid mxc urls only', () => { + const scriptUrl = ['javascript', 'alert(1)'].join(':'); + + expect(getSafeMediaUrl('blob:fake-url')).toBe('blob:fake-url'); + expect(getSafeMediaUrl('https://example.com/image.png')).toBe('https://example.com/image.png'); + expect(getSafeMediaUrl('http://example.com/image.png')).toBe('http://example.com/image.png'); + expect(getSafeMediaUrl('mxc://example.com/abc123')).toBe('mxc://example.com/abc123'); + + expect(getSafeMediaUrl(scriptUrl)).toBeUndefined(); + expect(getSafeMediaUrl('data:text/html,boom')).toBeUndefined(); + expect(getSafeMediaUrl('/relative/path.png')).toBeUndefined(); + }); +}); diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index 7fd9561e8e..90d25c52d6 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -129,6 +129,30 @@ function isAllowedMxcUri(value: string): boolean { } } +export function getSafeMediaUrl(url: string | undefined): string | undefined { + if (!url) return undefined; + + if (url.startsWith('blob:')) { + return url; + } + + try { + const parsed = new URL(url); + + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return url; + } + + if (isAllowedMxcUri(url)) { + return url; + } + } catch { + return undefined; + } + + return undefined; +} + function normalizeCodeClasses(attrValue: string): string | undefined { const classes = attrValue.split(/\s+/).filter(Boolean); if ( diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 499a73a333..585a8fb9f5 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -25,6 +25,8 @@ describe('NON_SYNCABLE_KEYS', () => { it('contains all device-local and security-sensitive keys', () => { const expected = [ 'usePushNotifications', + 'backgroundPushEnabled', + 'backgroundPushProvider', 'useInAppNotifications', 'useSystemNotifications', 'pageZoom', @@ -143,6 +145,8 @@ describe('deserializeFromSync', () => { v: SETTINGS_SYNC_VERSION, settings: { pageZoom: 200, + backgroundPushEnabled: false, + backgroundPushProvider: 'native', isPeopleDrawer: false, callRingtoneVolume: 20, settingsSyncEnabled: true, @@ -152,6 +156,8 @@ describe('deserializeFromSync', () => { const local = { ...base, pageZoom: 100, + backgroundPushEnabled: true, + backgroundPushProvider: 'unifiedpush' as const, isPeopleDrawer: true, callRingtoneVolume: 80, settingsSyncEnabled: false, @@ -159,6 +165,8 @@ describe('deserializeFromSync', () => { const result = deserializeFromSync(remote, local); expect(result).not.toBeNull(); expect(result!.pageZoom).toBe(100); + expect(result!.backgroundPushEnabled).toBe(true); + expect(result!.backgroundPushProvider).toBe('unifiedpush'); expect(result!.isPeopleDrawer).toBe(true); expect(result!.callRingtoneVolume).toBe(80); expect(result!.settingsSyncEnabled).toBe(false); diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index bd565356b0..ab2bc3016a 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -7,6 +7,8 @@ import type { Settings } from '$state/settings'; export const NON_SYNCABLE_KEYS = new Set([ // Platform / permission-level — differ per device/browser 'usePushNotifications', + 'backgroundPushEnabled', + 'backgroundPushProvider', 'useInAppNotifications', 'useSystemNotifications', // Personal device-level preferences diff --git a/src/app/utils/tauriMediaAuth.ts b/src/app/utils/tauriMediaAuth.ts new file mode 100644 index 0000000000..7b2c8de31e Binary files /dev/null and b/src/app/utils/tauriMediaAuth.ts differ diff --git a/src/client/oidcTokenRefresher.ts b/src/client/oidcTokenRefresher.ts index 0dc1f7f411..4e8295f2fe 100644 --- a/src/client/oidcTokenRefresher.ts +++ b/src/client/oidcTokenRefresher.ts @@ -8,6 +8,7 @@ import { } from '$state/sessions'; import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { pushSessionToSW } from '../sw-session'; +import { getAppOrigin } from '$utils/platform'; export class SessionOidcTokenRefresher extends OidcTokenRefresher { private readonly userId: string; @@ -21,7 +22,7 @@ export class SessionOidcTokenRefresher extends OidcTokenRefresher { super( session.oidc.issuer, session.oidc.clientId, - window.location.origin, + getAppOrigin(), session.deviceId, session.oidc.idTokenClaims ?? ({} as never) ); diff --git a/src/index.tsx b/src/index.tsx index c847fc376b..ccd98486e9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,8 @@ import './index.css'; import './app/styles/themes.css'; import './app/styles/overrides/General.css'; import './app/styles/overrides/Privacy.css'; +import './app/styles/overrides/TauriDesktop.css'; +import { isTauri } from '@tauri-apps/api/core'; import { pushSessionToSW } from './sw-session'; import type { Sessions } from './app/state/sessions'; import { getFallbackSession, MATRIX_SESSIONS_KEY, ACTIVE_SESSION_KEY } from './app/state/sessions'; @@ -24,6 +26,8 @@ import { createLogger } from './app/utils/debug'; import { getLocalStorageItem } from './app/state/utils/atomWithLocalStorage'; import { installConsolePasteScamWarning } from './app/utils/consolePasteScamWarning'; import { registerMatrixUriProtocol } from './app/plugins/matrix-uri'; +import { initTauriMediaSession } from './app/utils/tauriMediaAuth'; +import { registerAppServiceWorker } from './serviceWorkerBootstrap'; enableMapSet(); installConsolePasteScamWarning(); @@ -32,6 +36,8 @@ const log = createLogger('index'); document.body.classList.add(configClass, varsClass); +registerAppServiceWorker(); + const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => { const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt'; const userPreference = localStorage.getItem(DONT_SHOW_PROMPT_KEY); @@ -57,67 +63,7 @@ const sendSessionToSW = () => { pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId); }; -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'; - } - - navigator.serviceWorker.register(swUrl, swRegisterOptions).then((registration) => { - registration.addEventListener('updatefound', () => { - const installingWorker = registration.installing; - if (installingWorker) { - installingWorker.addEventListener('statechange', () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - showUpdateAvailablePrompt(registration); - } - } - }); - } - }); - }); - - 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); - }); - - 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?, - }); - */ - } - }); -} +initTauriMediaSession(); const controllerRefreshed = localStorage.getItem('controllerRefreshed') === 'true'; diff --git a/src/serviceWorkerBootstrap.test.ts b/src/serviceWorkerBootstrap.test.ts new file mode 100644 index 0000000000..7d66e2016b --- /dev/null +++ b/src/serviceWorkerBootstrap.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerAppServiceWorker } from './serviceWorkerBootstrap'; + +const { + mockHasServiceWorker, + mockRegister, + mockAddEventListener, + mockReady, + mockPushSessionToSW, + mockWarn, +} = vi.hoisted(() => ({ + mockHasServiceWorker: vi.fn(), + mockRegister: vi.fn().mockResolvedValue({ + addEventListener: vi.fn(), + installing: null, + waiting: null, + }), + mockAddEventListener: vi.fn(), + mockReady: Promise.resolve(undefined), + mockPushSessionToSW: vi.fn(), + mockWarn: vi.fn(), +})); + +vi.mock('./app/utils/platform', () => ({ + hasServiceWorker: mockHasServiceWorker, +})); + +vi.mock('./sw-session', () => ({ + pushSessionToSW: mockPushSessionToSW, +})); + +vi.mock('./app/state/sessions', () => ({ + getFallbackSession: vi.fn(() => undefined), + MATRIX_SESSIONS_KEY: 'matrix-sessions', + ACTIVE_SESSION_KEY: 'active-session', +})); + +vi.mock('./app/state/utils/atomWithLocalStorage', () => ({ + getLocalStorageItem: vi.fn((_: string, fallback: unknown) => fallback), +})); + +vi.mock('./app/utils/debug', () => ({ + createLogger: () => ({ + warn: mockWarn, + }), +})); + +describe('registerAppServiceWorker', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHasServiceWorker.mockReturnValue(false); + Object.defineProperty(window, 'confirm', { + configurable: true, + value: vi.fn(() => false), + }); + + Object.defineProperty(window, 'navigator', { + configurable: true, + value: { + serviceWorker: { + register: mockRegister, + ready: mockReady, + controller: null, + addEventListener: mockAddEventListener, + }, + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('skips service worker startup when the platform should not use one', () => { + registerAppServiceWorker(); + + expect(mockRegister).not.toHaveBeenCalled(); + expect(mockAddEventListener).not.toHaveBeenCalled(); + expect(mockPushSessionToSW).not.toHaveBeenCalled(); + }); + + it('registers the service worker when the platform supports it', async () => { + mockHasServiceWorker.mockReturnValue(true); + + registerAppServiceWorker(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockRegister).toHaveBeenCalled(); + expect(mockAddEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + }); + + it('pushes the active session immediately when a controller already exists', () => { + mockHasServiceWorker.mockReturnValue(true); + + Object.defineProperty(window, 'navigator', { + configurable: true, + value: { + serviceWorker: { + register: mockRegister, + ready: mockReady, + controller: { postMessage: vi.fn() }, + addEventListener: mockAddEventListener, + }, + }, + }); + + registerAppServiceWorker(); + + expect(mockPushSessionToSW).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/serviceWorkerBootstrap.ts b/src/serviceWorkerBootstrap.ts new file mode 100644 index 0000000000..b6b185a618 --- /dev/null +++ b/src/serviceWorkerBootstrap.ts @@ -0,0 +1,104 @@ +import { trimTrailingSlash } from './app/utils/common'; +import { createLogger } from './app/utils/debug'; +import { + getFallbackSession, + MATRIX_SESSIONS_KEY, + Sessions, + ACTIVE_SESSION_KEY, +} from './app/state/sessions'; +import { getLocalStorageItem } from './app/state/utils/atomWithLocalStorage'; +import { hasServiceWorker } from './app/utils/platform'; +import { pushSessionToSW } from './sw-session'; + +const log = createLogger('service-worker-bootstrap'); + +export function registerAppServiceWorker() { + if (!hasServiceWorker()) 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'; + } + + 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; + } + + // 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' }); + } + window.location.reload(); + } + }; + + 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, active?.userId); + }; + + sendSessionToSW(); + + const registrationPromise = navigator.serviceWorker.register(swUrl, swRegisterOptions); + + registrationPromise + .then((registration) => { + registration.addEventListener('updatefound', () => { + const installingWorker = registration.installing; + if (installingWorker) { + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed' && 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, + }); + } else if (data.type === 'openRoom' && data.id) { + /* Example: + event.source.postMessage({ + replyTo: event.data.id, + payload: success?, + }); + */ + } + }); +} diff --git a/src/sw-session-persistence.test.ts b/src/sw-session-persistence.test.ts new file mode 100644 index 0000000000..97f6c7528f --- /dev/null +++ b/src/sw-session-persistence.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { readPersistedSession } from './sw-session-persistence'; + +describe('readPersistedSession', () => { + it('keeps older persisted sessions instead of expiring them after one minute', () => { + const persistedAt = Date.now() - 1000 * 60 * 60 * 6; + + expect( + readPersistedSession({ + accessToken: 'token', + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + persistedAt, + }) + ).toEqual({ + accessToken: 'token', + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + persistedAt, + }); + }); +}); diff --git a/src/sw-session-persistence.ts b/src/sw-session-persistence.ts new file mode 100644 index 0000000000..2c31d8a5c3 --- /dev/null +++ b/src/sw-session-persistence.ts @@ -0,0 +1,28 @@ +export type PersistedSessionInfo = { + accessToken: string; + baseUrl: string; + userId?: string; + persistedAt?: number; +}; + +export function readPersistedSession(value: unknown): PersistedSessionInfo | undefined { + if (typeof value !== 'object' || value === null) return undefined; + + const session = value as { + accessToken?: unknown; + baseUrl?: unknown; + userId?: unknown; + persistedAt?: unknown; + }; + + if (typeof session.accessToken !== 'string' || typeof session.baseUrl !== 'string') { + return undefined; + } + + return { + accessToken: session.accessToken, + baseUrl: session.baseUrl, + userId: typeof session.userId === 'string' ? session.userId : undefined, + persistedAt: typeof session.persistedAt === 'number' ? session.persistedAt : undefined, + }; +} diff --git a/src/sw-session.ts b/src/sw-session.ts index bbfd81fc39..b5815dd811 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,4 +1,17 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { clearMediaSession, setMediaSession } from '$generated/tauri/commands'; + export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) { + if (isTauri()) { + // No service worker under Tauri; hand the token to the native sable-media protocol instead. + if (baseUrl && accessToken) { + void setMediaSession({ baseUrl, token: accessToken }).catch(() => undefined); + } else { + void clearMediaSession().catch(() => undefined); + } + return; + } + if (!('serviceWorker' in navigator)) return; if (!navigator.serviceWorker.controller) return; diff --git a/src/types/tauri-plugin-notifications-api.d.ts b/src/types/tauri-plugin-notifications-api.d.ts new file mode 100644 index 0000000000..8b7b673653 --- /dev/null +++ b/src/types/tauri-plugin-notifications-api.d.ts @@ -0,0 +1,56 @@ +declare module '@sableclient/tauri-plugin-notifications-api' { + export const Importance: { + readonly Default: string; + }; + + export type MessagingStylePerson = { + name: string; + key?: string; + iconUrl?: string; + }; + + export type MessagingStyleMessage = { + text: string; + timestamp: number; + sender?: MessagingStylePerson; + }; + + export function isPermissionGranted(): Promise; + export function requestPermission(): Promise; + export function registerForPushNotifications(): Promise; + export function unregisterForPushNotifications(): Promise; + + export function registerForUnifiedPush(): Promise<{ + endpoint: string; + instance: string; + pubKeySet?: { + pubKey: string; + auth: string; + }; + }>; + export function unregisterFromUnifiedPush(): Promise; + export function getUnifiedPushDistributors(): Promise<{ distributors: string[] }>; + export function getUnifiedPushDistributor(): Promise<{ distributor: string }>; + export function saveUnifiedPushDistributor(distributor: string): Promise; + + export function createChannel(channel: { + id: string; + name: string; + description?: string; + importance?: string; + vibration?: boolean; + }): Promise; + + export function sendNotification(payload: Record): Promise; + export function removeActive(payload: Array<{ id: number }>): Promise; + + export function onUnifiedPushMessage(listener: (payload: Record) => void): { + unregister: () => Promise | void; + }; + + export function onUnifiedPushEndpoint( + listener: (payload: { endpoint: string; instance: string }) => void + ): { + unregister: () => Promise | void; + }; +} diff --git a/tsconfig.json b/tsconfig.json index 77dac6f3d2..6055562daa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,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 bcfc3e6194..f1b590379b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,6 +49,10 @@ 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 tauriBuildMinify = !isTauriDebug ? 'esbuild' : false; const isReleaseTag = (() => { const envVal = process.env.VITE_IS_RELEASE_TAG; @@ -124,9 +128,11 @@ function serverMatrixSdkCryptoWasm() { } export default defineConfig(({ command }) => ({ + clearScreen: false, appType: 'spa', publicDir: false, base: buildConfig.base, + envPrefix: ['VITE_', 'TAURI_ENV_*'], define: { APP_VERSION: JSON.stringify(appVersion), BUILD_HASH: JSON.stringify(buildHash ?? ''), @@ -142,6 +148,7 @@ export default defineConfig(({ command }) => ({ $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'), @@ -150,7 +157,18 @@ export default defineConfig(({ command }) => ({ }, server: { port: 8080, - host: true, + strictPort: true, + host: tauriDevHost || true, + hmr: tauriDevHost + ? { + protocol: 'ws', + host: tauriDevHost, + port: 1421, + } + : undefined, + watch: { + ignored: ['**/src-tauri/**'], + }, allowedHosts: command === 'serve' ? true : undefined, fs: { // Allow serving files from one level up to the project root @@ -187,25 +205,37 @@ export default defineConfig(({ command }) => ({ enabled: true, type: 'module', }, - }), - cloudflare({ - config: { - compatibility_date: '2026-03-03', - assets: { - not_found_handling: 'single-page-application', - }, + 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', + ], }, }), - compression({ - algorithms: [ - defineAlgorithm('brotliCompress', { - params: { - [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY, - }, - }), - ], - include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|txt|map)$/, - }), + ...(!isTauriBuild + ? [ + cloudflare({ + config: { + compatibility_date: '2026-03-03', + assets: { + not_found_handling: 'single-page-application', + }, + }, + }), + compression({ + algorithms: [ + defineAlgorithm('brotliCompress', { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY }, + }), + ], + include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|txt|map)$/, + }), + ] + : []), // Sentry source map upload — only active when credentials are provided at build time ...(process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT ? [ @@ -228,6 +258,8 @@ export default defineConfig(({ command }) => ({ : []), ], 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. @@ -252,14 +284,24 @@ export default defineConfig(({ command }) => ({ }, }, build: { - outDir: 'dist', - sourcemap: true, - copyPublicDir: false, // es2022+ avoids esbuild 0.27.7 failing to downlevel destructuring when // vite-plugin-top-level-await re-transpiles chunks (see vitejs/vite#22225). target: 'es2022', + minify: isTauriBuild ? tauriBuildMinify : undefined, + sourcemap: isTauriBuild ? isTauriDebug : true, + outDir: 'dist', + copyPublicDir: false, rollupOptions: { plugins: [inject({ Buffer: ['buffer', 'Buffer'] }) as PluginOption], + output: { + manualChunks: (id) => { + if (id.includes('pdfjs-dist')) return 'pdf'; + if (id.includes('@sableclient/sable-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; + }, + }, }, }, })); diff --git a/vitest.config.ts b/vitest.config.ts index 609b42c2b0..39f54b49e9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ $plugins: path.resolve(__dirname, 'src/app/plugins'), $components: path.resolve(__dirname, 'src/app/components'), $features: path.resolve(__dirname, 'src/app/features'), + $generated: path.resolve(__dirname, 'src/app/generated'), $state: path.resolve(__dirname, 'src/app/state'), $styles: path.resolve(__dirname, 'src/app/styles'), $utils: path.resolve(__dirname, 'src/app/utils'),