diff --git a/.changeset/tauri.md b/.changeset/tauri.md new file mode 100644 index 0000000000..45ccde0da2 --- /dev/null +++ b/.changeset/tauri.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add beta builds for desktop and mobile. diff --git a/.github/scripts/build-updater-manifest.mjs b/.github/scripts/build-updater-manifest.mjs new file mode 100644 index 0000000000..7cb7ddacdf --- /dev/null +++ b/.github/scripts/build-updater-manifest.mjs @@ -0,0 +1,64 @@ +// Composes the Tauri updater manifest (latest.json) from the per-platform .sig +// assets already attached to the release. +// Required env: TAG, REPO, GH_TOKEN. + +import { execSync } from 'node:child_process'; +import { readFileSync, readdirSync, writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const TAG = process.env.TAG; +const REPO = process.env.REPO; +if (!TAG || !REPO) { + console.error('TAG and REPO env vars are required'); + process.exit(1); +} +const version = TAG.replace(/^v/, ''); + +const dir = mkdtempSync(join(tmpdir(), 'sable-sigs-')); +execSync(`gh release download "${TAG}" --repo "${REPO}" --pattern '*.sig' --dir "${dir}"`, { + stdio: 'inherit', +}); + +const urlFor = (name) => + `https://github.com/${REPO}/releases/download/${TAG}/${encodeURIComponent(name)}`; + +const platforms = {}; +let windowsIsNsis = false; + +for (const sig of readdirSync(dir).filter((f) => f.endsWith('.sig'))) { + const artifact = sig.replace(/\.sig$/, ''); + const entry = { signature: readFileSync(join(dir, sig), 'utf8').trim(), url: urlFor(artifact) }; + const name = artifact.toLowerCase(); + + if (name.endsWith('.app.tar.gz')) { + platforms['darwin-aarch64'] = entry; + platforms['darwin-x86_64'] = entry; + } else if (name.endsWith('.appimage')) { + platforms['linux-x86_64'] = entry; + } else if (name.endsWith('-setup.exe') || name.endsWith('.nsis.zip')) { + platforms['windows-x86_64'] = entry; + windowsIsNsis = true; + } else if (name.endsWith('.msi') && !windowsIsNsis) { + platforms['windows-x86_64'] = entry; + } +} + +if (Object.keys(platforms).length === 0) { + console.error('No .sig assets found on the release; nothing to publish.'); + process.exit(1); +} + +let notes = `Sable ${version}`; +try { + notes = + execSync(`gh release view "${TAG}" --repo "${REPO}" --json body -q .body`, { + encoding: 'utf8', + }).trim() || notes; +} catch { + // keep the default note +} + +const manifest = { version, notes, pub_date: new Date().toISOString(), platforms }; +writeFileSync('latest.json', JSON.stringify(manifest, null, 2)); +console.warn(JSON.stringify(manifest, null, 2)); diff --git a/.github/scripts/set-tauri-version.mjs b/.github/scripts/set-tauri-version.mjs new file mode 100644 index 0000000000..3c11d21518 --- /dev/null +++ b/.github/scripts/set-tauri-version.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/* oxlint-disable no-console */ + +// Stamps a version into src-tauri/tauri.conf.json, which can drift from the +// package.json version Knope tags on. Used by tauri-build.yml. + +import { readFileSync, writeFileSync } from 'node:fs'; +import process from 'node:process'; + +const version = process.argv[2]; +if (!version || !/^\d+\.\d+\.\d+/.test(version)) { + console.error(`Usage: set-tauri-version.mjs (got: ${version ?? ''})`); + process.exit(1); +} + +const file = 'src-tauri/tauri.conf.json'; +const config = JSON.parse(readFileSync(file, 'utf8')); +config.version = version; +writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); +console.log(`Set ${file} version to ${version}`); diff --git a/.github/workflows/rust-quality.yml b/.github/workflows/rust-quality.yml new file mode 100644 index 0000000000..81d3b5ff27 --- /dev/null +++ b/.github/workflows/rust-quality.yml @@ -0,0 +1,81 @@ +name: Rust quality checks + +on: + pull_request: + paths: + - 'src-tauri/**' + - '.github/workflows/rust-quality.yml' + push: + branches: [dev] + paths: + - 'src-tauri/**' + - '.github/workflows/rust-quality.yml' + merge_group: + +permissions: {} + +jobs: + format: + name: Rust format + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable + with: + toolchain: stable + components: rustfmt + + - name: Check Rust formatting + run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check + + checks: + name: Rust check, test, and Clippy + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Tauri build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libsoup-3.0-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + pkg-config + + - name: Setup Rust + uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable + with: + toolchain: stable + components: clippy + + - name: Cache Rust build + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: src-tauri + + - name: Check native app + run: cargo check --locked --manifest-path src-tauri/Cargo.toml + + - name: Run Rust tests + run: cargo test --locked --manifest-path src-tauri/Cargo.toml + + - name: Run Clippy + run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml new file mode 100644 index 0000000000..920303b0bc --- /dev/null +++ b/.github/workflows/tauri-build.yml @@ -0,0 +1,266 @@ +name: Build desktop apps + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build and attach desktop bundles to (e.g. v1.20.0)' + required: true + type: string + +permissions: {} + +concurrency: + group: tauri-build-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.name }} + runs-on: ${{ matrix.platform }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - name: macOS + platform: macos-latest + rust-targets: aarch64-apple-darwin,x86_64-apple-darwin + args: --target universal-apple-darwin + - name: Windows + platform: windows-latest + rust-targets: '' + args: '' + - name: Linux (CEF) + platform: ubuntu-22.04 + rust-targets: '' + cef: true + env: + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + NODE_OPTIONS: --max-old-space-size=8192 + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + persist-credentials: false + + - name: Wait for release to exist + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + for i in $(seq 1 12); do + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG found." + exit 0 + fi + echo "Waiting for release $TAG to be published ($i/12)…" + sleep 15 + done + echo "Release $TAG was not found." >&2 + exit 1 + + - name: Install Linux build dependencies + if: matrix.cef + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libsoup-3.0-dev \ + libssl-dev \ + libxdo-dev \ + build-essential \ + file \ + rpm \ + ruby \ + ruby-dev + sudo gem install --no-document fpm + + - name: Setup app + uses: ./.github/actions/setup + + - name: Setup Rust + uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable + with: + toolchain: stable + targets: ${{ matrix.rust-targets }} + + - name: Cache Rust build + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: src-tauri + + - name: Stamp release version into tauri.conf.json + shell: bash + run: node .github/scripts/set-tauri-version.mjs "${TAG#v}" + + # --- macOS / Windows: standard wry build via tauri-action --- + - name: Build desktop bundles + id: tauri + if: ${{ !matrix.cef }} + uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # action-v1.0.0 + with: + args: ${{ matrix.args }} + + - name: Attach bundles to release + if: ${{ !matrix.cef }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ steps.tauri.outputs.artifactPaths }} + run: | + echo "$ARTIFACTS" | jq -r '.[]' | while read -r path; do + [ -n "$path" ] || continue + echo "Uploading $path" + gh release upload "$TAG" "$path" --clobber + done + + # --- Linux: CEF runtime, packaged as deb / rpm / AppImage --- + - name: Build frontend + if: matrix.cef + shell: bash + run: pnpm build + + - name: Build CEF binary + if: matrix.cef + shell: bash + run: | + cargo build --release --features cef --no-default-features \ + --manifest-path src-tauri/Cargo.toml + + - name: Build native packages + if: matrix.cef + shell: bash + run: | + VERSION="${TAG#v}" + STAGE="src-tauri/target/release" + + # Binary plus the full CEF runtime (cef-copy-libs is the single source + # of truth for the runtime file set). + stage_runtime() { + local dest="$1" + mkdir -p "$dest" + cp "$STAGE/sable" "$dest/" + scripts/cef-copy-libs.sh release "$dest" + } + + write_desktop() { + cat > "$1" <<'EOF' + [Desktop Entry] + Type=Application + Name=Sable + Comment=A Matrix client + Exec=sable %U + Icon=sable + Terminal=false + Categories=Network;InstantMessaging;Chat; + StartupWMClass=sable + MimeType=x-scheme-handler/sable;x-scheme-handler/moe.sable.app; + EOF + } + + ROOT="pkgroot" + stage_runtime "$ROOT/opt/sable" + mkdir -p "$ROOT/usr/bin" "$ROOT/usr/share/applications" \ + "$ROOT/usr/share/icons/hicolor/128x128/apps" + cat > "$ROOT/usr/bin/sable" <<'EOF' + #!/bin/sh + exec /opt/sable/sable "$@" + EOF + chmod 755 "$ROOT/usr/bin/sable" + write_desktop "$ROOT/usr/share/applications/sable.desktop" + cp src-tauri/icons/128x128.png "$ROOT/usr/share/icons/hicolor/128x128/apps/sable.png" + + COMMON=(-s dir -n sable -v "$VERSION" --iteration 1 + --description "Sable — a Matrix client" + --url "https://sable.moe" --maintainer "SableClient" + --category net) + # Stable-named deps only: version-suffixed names (e.g. the 24.04 t64 + # set) would break install on other releases; CEF bundles its own libs. + DEB_DEPS=(--depends libwebkit2gtk-4.1-0 --depends libgtk-3-0 + --depends libnss3 --depends libnspr4 --depends libgbm1 + --depends libdrm2 --depends libxkbcommon0 --depends xdg-utils) + fpm "${COMMON[@]}" -t deb -a amd64 "${DEB_DEPS[@]}" -C "$ROOT" . + + RPM_DEPS=(--depends webkit2gtk4.1 --depends gtk3 --depends nss + --depends nspr --depends mesa-libgbm --depends libdrm + --depends libxkbcommon --depends xdg-utils) + fpm "${COMMON[@]}" -t rpm -a x86_64 "${RPM_DEPS[@]}" -C "$ROOT" . + + APPDIR="Sable.AppDir" + stage_runtime "$APPDIR/usr/bin" + # nosuid mount: a setuid chrome-sandbox can't work here and would stop + # Chromium from starting, so drop it and use the user-namespace sandbox. + rm -f "$APPDIR/usr/bin/chrome-sandbox" + write_desktop "$APPDIR/sable.desktop" + cp src-tauri/icons/128x128.png "$APPDIR/sable.png" + cat > "$APPDIR/AppRun" <<'EOF' + #!/bin/sh + HERE="$(dirname "$(readlink -f "$0")")" + exec "$HERE/usr/bin/sable" "$@" + EOF + chmod 755 "$APPDIR/AppRun" + + APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + curl -fsSL -o appimagetool \ + https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage + echo "${APPIMAGETOOL_SHA256} appimagetool" | sha256sum -c - + chmod +x appimagetool + # Runner has no FUSE; extract-and-run instead of mounting. + APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 \ + ./appimagetool "$APPDIR" "Sable_${VERSION}_amd64.AppImage" + + - name: Sign the AppImage for the updater + if: ${{ matrix.cef && env.TAURI_SIGNING_PRIVATE_KEY != '' }} + shell: bash + run: | + for f in Sable_*_amd64.AppImage; do + [ -e "$f" ] || continue + pnpm tauri signer sign "$f" + done + + - name: Attach native packages to release + if: matrix.cef + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + for f in sable_*.deb sable-*.rpm Sable_*_amd64.AppImage Sable_*_amd64.AppImage.sig; do + [ -e "$f" ] || continue + echo "Uploading $f" + gh release upload "$TAG" "$f" --clobber + done + + updater-manifest: + name: Publish updater manifest + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + REPO: ${{ github.repository }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + persist-credentials: false + + - name: Build and upload latest.json + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + node .github/scripts/build-updater-manifest.mjs + gh release upload "$TAG" latest.json --clobber diff --git a/.gitignore b/.gitignore index 76af755426..b672c5d3b5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ devAssets .DS_Store .idea - +*.code-workspace .env .wrangler @@ -24,6 +24,33 @@ 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 Apple outputs +src-tauri/gen/apple/* +# 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..3956dfcb6e 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,182 @@ 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"; + + RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; + }; + + 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"; + + RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; }; }; + }; }; } 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..5075b01ce2 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"], - "ignore": ["oxlint.config.ts", "oxfmt.config.ts"], + "entry": ["src/sw.ts", "scripts/**/*.js"], + "ignore": ["src/test/choochmeque-notifications-stub.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 cb417155b8..04cc4e690e 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 = "Security", types = [ diff --git a/oxfmt.config.ts b/oxfmt.config.ts index c193a0268b..a2bd4e77ba 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -8,6 +8,8 @@ export default { ignorePatterns: [ 'dist', 'node_modules', + 'src/app/generated', + 'src-tauri/ios-project.yml', 'package.json', 'pnpm-lock.yaml', 'LICENSE', diff --git a/oxlint.config.ts b/oxlint.config.ts index d52bbe3305..b4dba8c2a3 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'oxlint'; export default defineConfig({ + ignorePatterns: ['src/app/generated/**/*'], options: { typeAware: true, }, diff --git a/package.json b/package.json index fa6e151af6..342b04f884 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,13 +26,17 @@ "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", "@atlaskit/pragmatic-drag-and-drop": "^2.0.1", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0", + "@choochmeque/tauri-plugin-notifications-api": "0.5.0-rc.11", "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", "@phosphor-icons/react": "^2.1.10", @@ -38,6 +45,16 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-virtual": "^3.14.5", + "@tauri-apps/api": "2.11.1", + "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-deep-link": "2.4.9", + "@tauri-apps/plugin-dialog": "~2.7.1", + "@tauri-apps/plugin-http": "2.5.9", + "@tauri-apps/plugin-opener": "2.5.4", + "@tauri-apps/plugin-os": "2.3.2", + "@tauri-apps/plugin-process": "~2.3.1", + "@tauri-apps/plugin-store": "^2.4.3", + "@tauri-apps/plugin-updater": "~2.10.1", "@use-gesture/react": "10.3.1", "@vanilla-extract/css": "^1.21.1", "@vanilla-extract/recipes": "^0.5.7", @@ -88,6 +105,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 +117,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.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -140,4 +159,4 @@ "jsdom>undici": "^7.28.0" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 082e727ca0..2856921370 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@atlaskit/pragmatic-drag-and-drop-hitbox': specifier: ^2.0.0 version: 2.0.0 + '@choochmeque/tauri-plugin-notifications-api': + specifier: 0.5.0-rc.11 + version: 0.5.0-rc.11 '@fontsource-variable/nunito': specifier: 5.2.7 version: 5.2.7 @@ -47,6 +50,36 @@ 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.1 + version: 2.11.1 + '@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-dialog': + specifier: ~2.7.1 + version: 2.7.1 + '@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-process': + specifier: ~2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-store': + specifier: ^2.4.3 + version: 2.4.3 + '@tauri-apps/plugin-updater': + specifier: ~2.10.1 + version: 2.10.1 '@use-gesture/react': specifier: 10.3.1 version: 10.3.1(react@18.3.1) @@ -197,6 +230,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 +261,9 @@ importers: '@sentry/vite-plugin': specifier: ^5.3.0 version: 5.3.0(rollup@2.80.0) + '@tauri-apps/cli': + specifier: 2.11.4 + version: 2.11.4 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -884,6 +923,9 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@choochmeque/tauri-plugin-notifications-api@0.5.0-rc.11': + resolution: {integrity: sha512-TSuf8HQ4Ayp9IKKB5v2yTdgKMa/I0bqbcACj0IZ46CSpzn/HKner/BKG7rM8tGOarEcyBd/VXnDhK5cJA/YMtw==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -2784,6 +2826,112 @@ packages: '@tanstack/virtual-core@3.17.3': resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-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-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + + '@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-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-store@2.4.3': + resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -3210,6 +3358,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 +4899,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'} @@ -6006,6 +6161,10 @@ snapshots: dependencies: css-tree: 3.2.1 + '@choochmeque/tauri-plugin-notifications-api@0.5.0-rc.11': + dependencies: + '@tauri-apps/api': 2.11.1 + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260630.1)': @@ -7270,6 +7429,91 @@ snapshots: '@tanstack/virtual-core@3.17.3': {} + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + + '@tauri-apps/plugin-clipboard-manager@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-deep-link@2.4.9': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-http@2.5.9': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-os@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-store@2.4.3': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -7784,6 +8028,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 +9794,11 @@ snapshots: tar-mini@0.2.0: {} + tauri-plugin-android-fs-api@28.4.0: + dependencies: + '@tauri-apps/api': 2.11.1 + 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/cef-copy-libs.sh b/scripts/cef-copy-libs.sh new file mode 100644 index 0000000000..881d75d48d --- /dev/null +++ b/scripts/cef-copy-libs.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Copy the CEF runtime (libcef.so + resources) next to a built binary so the app +# is self-contained: works standalone, when launched by the OS via a sable:// +# deep link, or from a release package. Pair with the rpath=$ORIGIN that +# build.rs adds for CEF builds. +# +# Usage: scripts/cef-copy-libs.sh [debug|release] [dest-dir] +# dest-dir defaults to src-tauri/target/ (beside the built binary). +set -euo pipefail +PROFILE="${1:-debug}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEST="${2:-$ROOT/src-tauri/target/$PROFILE}" + +CEF_DIR="$(find "$ROOT/src-tauri/target/$PROFILE/build" -type d -name cef_linux_x86_64 2>/dev/null | head -1)" +if [ -z "$CEF_DIR" ]; then + echo "❌ CEF dist not found under target/$PROFILE/build — build with --features cef first." >&2 + exit 1 +fi + +mkdir -p "$DEST" +echo "→ copying CEF runtime from $CEF_DIR to $DEST" +# Libraries (libcef.so + ANGLE/SwiftShader), crashpad handler, then resources. +cp -f "$CEF_DIR"/*.so* "$DEST/" 2>/dev/null || true +cp -f "$CEF_DIR"/chrome_crashpad_handler "$DEST/" 2>/dev/null || true +cp -f "$CEF_DIR"/*.pak "$CEF_DIR"/*.dat "$CEF_DIR"/*.bin "$CEF_DIR"/*.json "$DEST/" 2>/dev/null || true +cp -rf "$CEF_DIR"/locales "$DEST/" 2>/dev/null || true + +# The setuid sandbox helper only works when installed as root (deb/rpm). In a +# nosuid context such as an AppImage, drop chrome-sandbox and let Chromium use +# the user-namespace sandbox instead. +if cp -f "$CEF_DIR"/chrome-sandbox "$DEST/" 2>/dev/null; then + chmod 4755 "$DEST/chrome-sandbox" 2>/dev/null || true +fi +echo "✅ done." 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..ff265ac5a3 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,8677 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[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-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[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 = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[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 = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[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 = "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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[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 = "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 = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[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 = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.1", + "polling", + "rustix", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix", + "wayland-backend", + "wayland-client", +] + +[[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-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform 0.1.9", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform 0.3.3", + "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", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cef" +version = "148.0.0+147.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2445d2f1e820efc064c511faa3a07c3ee79e565fdae026ca62ed3c80d0459223" +dependencies = [ + "anyhow", + "cargo_metadata 0.23.1", + "cef-dll-sys", + "clap", + "libloading 0.9.0", + "objc2", + "plist", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "cef-dll-sys" +version = "148.0.0+147.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d32a7c89af6c77688ad58dc75555c9398c5ded2c328a3ff83554fccc840af57" +dependencies = [ + "anyhow", + "cmake", + "download-cef", + "serde_json", +] + +[[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 = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[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 = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "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", + "indexmap 2.14.0", + "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.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +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 = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[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 = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[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_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "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 = "dioxus-debug-cell" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea539174bb236e0e7dc9c12b19b88eae3cb574dedbd0252a2d43ea7e6de13e2" + +[[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 = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[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 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + +[[package]] +name = "dos-date-time" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6bf3a6a5a5068b83e2bc1251735e040c3c0d54bc0bb829bec86d76eb1197689" +dependencies = [ + "chrono", + "jiff", + "time", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "download-cef" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c169adf067a787e1f1c58ed62906a557de85388bee4b54fb878b722ff606b113" +dependencies = [ + "bzip2", + "clap", + "fs-err", + "indicatif 0.18.6", + "regex", + "semver", + "serde", + "serde_json", + "sha1_smol", + "tar", + "thiserror 2.0.18", + "ureq", +] + +[[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 = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.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 = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "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" + +[[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.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[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 0.15.11", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console 0.16.4", + "portable-atomic", + "unicode-width", + "unit-prefix", + "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 = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[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 = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.2", + "libc", +] + +[[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 = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.11.1", + "serde", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.14.0", + "selectors 0.24.0", +] + +[[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 0.7.4", + "once_cell", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[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 = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[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 = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[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" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "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 0.7.0", + "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-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 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 = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "notify-rust" +version = "4.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 = "nt-time" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "293dcb75775acfac821c0be754ea8489cc606689f1a23faa11425c0154e5a85c" +dependencies = [ + "chrono", + "dos-date-time", + "jiff", + "rand 0.10.1", + "time", +] + +[[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", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-application-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69282c2b5bc58fba07cb9de2113619532eb551e98efe3d8d695509ef45fbd53b" +dependencies = [ + "objc2-core-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", + "block2", + "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", + "libc", + "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-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.1", + "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-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "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 = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[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 = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[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 0.5.18", + "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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.6", +] + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.3", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.3", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[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 = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "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-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[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 = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.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.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_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.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.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 = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +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 = "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-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[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 = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.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 = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.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 = [ + "cef", + "enigo", + "gtk", + "jni 0.21.1", + "log", + "objc2", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-android-fs", + "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-edge-to-edge", + "tauri-plugin-http", + "tauri-plugin-log", + "tauri-plugin-notifications", + "tauri-plugin-opener", + "tauri-plugin-os", + "tauri-plugin-process", + "tauri-plugin-single-instance", + "tauri-plugin-store", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tauri-runtime-cef", + "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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.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 = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd3accc0f3f4bbaf2c9e1957a030dc582028130c67660d44c0a0345a22ca69b" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[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 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[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 = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[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 = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[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 = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[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 = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[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 0.5.18", + "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 = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +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.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "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 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.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-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" +dependencies = [ + "android_logger", + "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.5.0-rc.11" +source = "git+https://github.com/eleboucher/tauri-plugin-notifications.git?branch=feat%2Fandroid-unifiedpush#9902657bf36b5bbb0d18a07e03a252c36088d5ac" +dependencies = [ + "log", + "notify-rust", + "nt-time", + "rand 0.10.1", + "serde", + "serde_json", + "serde_repr", + "swift-bridge", + "swift-bridge-build", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "uuid", + "windows", + "windows-core 0.61.2", + "zbus", +] + +[[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-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tokio", + "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-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.3", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-cef" +version = "0.1.0" +source = "git+https://github.com/byeongsu-hong/tauri-runtime-cef?rev=c215d6e52dd507956d9b8cc868906d690dc477c2#c215d6e52dd507956d9b8cc868906d690dc477c2" +dependencies = [ + "base64 0.22.1", + "cef", + "cef-dll-sys", + "dioxus-debug-cell", + "dirs", + "dispatch2", + "dlopen2", + "gtk", + "html5ever 0.29.1", + "http", + "kuchikiki", + "log", + "objc2", + "objc2-app-kit", + "objc2-application-services", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "serde", + "serde_json", + "sha2", + "softbuffer", + "tauri-runtime", + "tauri-utils", + "url", + "windows", + "winit", + "x11-dl", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-typegen" +version = "0.5.0" +source = "git+https://github.com/SableClient/tauri-typegen?branch=fix%2Fnondeterministic-generation-cache#645ac948bbd940704cda4419a203e307e7f0d429" +dependencies = [ + "chrono", + "clap", + "indicatif 0.17.11", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata 0.19.2", + "ctor", + "dom_query", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.13.1", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[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 = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[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", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "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 = [ + "log", + "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.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +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 = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[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 = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[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-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[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", + "rand 0.10.1", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.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", + "scoped-tls", + "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-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix", + "wayland-client", + "xcursor", +] + +[[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-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "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 = [ + "dlib", + "log", + "once_cell", + "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 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.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 = "winit" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2879d2854d1a43e48f67322d4bd097afcb6eb8f8f775c8de0260a71aea1df1aa" +dependencies = [ + "bitflags 2.11.1", + "cfg_aliases", + "cursor-icon", + "dpi", + "libc", + "raw-window-handle", + "rustix", + "smol_str", + "tracing", + "winit-android", + "winit-appkit", + "winit-common", + "winit-core", + "winit-orbital", + "winit-uikit", + "winit-wayland", + "winit-web", + "winit-win32", + "winit-x11", +] + +[[package]] +name = "winit-android" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d9c0d2cd93efec3a9f9ad819cfaf0834782403af7c0d248c784ec0c61761df" +dependencies = [ + "android-activity", + "bitflags 2.11.1", + "dpi", + "ndk", + "raw-window-handle", + "smol_str", + "tracing", + "winit-core", +] + +[[package]] +name = "winit-appkit" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21310ca07851a49c348e0c2cc768e36b52ca65afda2c2354d78ed4b90074d8aa" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch2", + "dpi", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation", + "raw-window-handle", + "smol_str", + "tracing", + "winit-common", + "winit-core", +] + +[[package]] +name = "winit-common" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45375fbac4cbb77260d83a30b1f9d8105880dbac99a9ae97f56656694680ff69" +dependencies = [ + "memmap2", + "objc2", + "objc2-core-foundation", + "smol_str", + "tracing", + "winit-core", + "x11-dl", + "xkbcommon-dl", +] + +[[package]] +name = "winit-core" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f0ccd7abb43740e2c6124ac7cae7d865ecec74eec63783e8922577ac232583" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "dpi", + "keyboard-types 0.8.3", + "raw-window-handle", + "smol_str", + "web-time", +] + +[[package]] +name = "winit-orbital" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51ea1fb262e7209f265f12bd0cc792c399b14355675e65531e9c8a87db287d46" +dependencies = [ + "bitflags 2.11.1", + "dpi", + "orbclient", + "raw-window-handle", + "redox_syscall 0.5.18", + "smol_str", + "tracing", + "winit-core", +] + +[[package]] +name = "winit-uikit" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680a356e798837d8eb274d4556e83bceaf81698194e31aafc5cfb8a9f2fab643" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch2", + "dpi", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "raw-window-handle", + "smol_str", + "tracing", + "winit-common", + "winit-core", +] + +[[package]] +name = "winit-wayland" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce5afb2ba07da603f84b722c95f9f9396d2cedae3944fb6c0cda4a6f88de545" +dependencies = [ + "ahash", + "bitflags 2.11.1", + "calloop", + "cursor-icon", + "dpi", + "libc", + "memmap2", + "raw-window-handle", + "rustix", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "winit-common", + "winit-core", +] + +[[package]] +name = "winit-web" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c2490a953fb776fbbd5e295d54f1c3847f4f15b6c3929ec53c09acda6487a92" +dependencies = [ + "atomic-waker", + "bitflags 2.11.1", + "concurrent-queue", + "cursor-icon", + "dpi", + "js-sys", + "pin-project", + "raw-window-handle", + "smol_str", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winit-core", +] + +[[package]] +name = "winit-win32" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644ea78af0e858aa3b092e5d1c67c41995a98220c81813f1353b28bc8bb91eaa" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "dpi", + "raw-window-handle", + "smol_str", + "tracing", + "unicode-segmentation", + "windows-sys 0.59.0", + "winit-core", +] + +[[package]] +name = "winit-x11" +version = "0.31.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa5b600756534c7041aa93cd0d244d44b09fca1b89e202bd1cd80dd9f3636c46" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "calloop", + "cursor-icon", + "dpi", + "libc", + "percent-encoding", + "raw-window-handle", + "rustix", + "smol_str", + "tracing", + "winit-common", + "winit-core", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[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 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[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 = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "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", + "tokio", + "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 = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.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..61e0d271a1 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,133 @@ +[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.6.3", 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.5", default-features = false, features = [ + "tray-icon", + "dynamic-acl", + "compression", + "x11", + "common-controls-v6", + "dbus", + "image-png", +] } + +tauri-plugin-log = "2.9.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.3", features = ["deep-link"] } + +[target.'cfg(any(windows, target_os = "linux"))'.dependencies] +tauri-plugin-notifications = { git = "https://github.com/eleboucher/tauri-plugin-notifications.git", branch = "feat/android-unifiedpush" } + +# default-features = false drops notify-rust so macOS uses the native +# UNUserNotificationCenter backend (needs a signed .app to deliver). +[target.'cfg(target_os = "macos")'.dependencies] +tauri-plugin-notifications = { git = "https://github.com/eleboucher/tauri-plugin-notifications.git", branch = "feat/android-unifiedpush", default-features = false } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-updater = "2" +tauri-plugin-process = "2" +tauri-plugin-dialog = "2" +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" + +# CEF (Chromium) runtime — Linux only; other platforms use wry. +[target.'cfg(target_os = "linux")'.dependencies] +tauri-runtime-cef = { git = "https://github.com/byeongsu-hong/tauri-runtime-cef", rev = "c215d6e52dd507956d9b8cc868906d690dc477c2", optional = true } +cef = { version = "=148.0.0", optional = true } + +[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] +tauri-plugin-notifications = { git = "https://github.com/eleboucher/tauri-plugin-notifications.git", branch = "feat/android-unifiedpush", features = [ + "push-notifications", +] } +tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } + +[target.'cfg(target_os = "ios")'.dependencies] +objc2 = "0.6" + +[target.'cfg(target_os = "android")'.dependencies] +tauri-plugin-android-fs = { version = "=28.4.0", features = ["legacy-storage-permission"] } +jni = "0.21" + +[features] +default = ["wry"] +custom-protocol = ["tauri/custom-protocol"] +wry = ["tauri/wry"] +# Linux uses the CEF runtime; wry stays available for the other platforms. +cef = ["dep:tauri-runtime-cef", "dep:cef", "tauri/wry"] +[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..99b9ecb70b --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,14 @@ + + + + + CFBundleDisplayName + Sable + CFBundleName + Sable + 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..5bf985ab8d --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,20 @@ +fn main() { + println!("cargo:rustc-env=TS_RS_EXPORT_DIR=../src/app/generated/tauri"); + + // Find libcef.so next to the binary (CEF ships it there). + if std::env::var_os("CARGO_FEATURE_CEF").is_some() + && std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") + { + println!("cargo:rustc-link-arg-bins=-Wl,-rpath,$ORIGIN"); + } + + // The notifications plugin links the Swift runtime via @rpath. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + println!("cargo:rustc-link-arg-bins=-Wl,-rpath,/usr/lib/swift"); + } + + 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..3a9071785c --- /dev/null +++ b/src-tauri/capabilities/android.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "android-capability", + "platforms": ["android"], + "windows": ["main"], + "permissions": [ + "android-fs:allow-create-new-public-file", + "android-fs:allow-write-file", + "android-fs:allow-set-public-file-pending" + ] +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000000..cbc3c169b4 --- /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": "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..8f6696cd11 --- /dev/null +++ b/src-tauri/capabilities/desktop.json @@ -0,0 +1,15 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "desktop-capability", + "platforms": ["macOS", "windows", "linux"], + "windows": ["main"], + "permissions": [ + "core:window:allow-set-badge-count", + "window-state:default", + "store:default", + "notifications:default", + "updater:default", + "process:default", + "dialog:default" + ] +} diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json new file mode 100644 index 0000000000..f1c5c554a1 --- /dev/null +++ b/src-tauri/capabilities/mobile.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile-capability", + "platforms": ["android", "iOS"], + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-set-badge-count", + "deep-link:default", + "edge-to-edge:default", + "notifications:default" + ] +} 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..a8d3a0936b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..7130a13a32 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt @@ -0,0 +1,55 @@ +package moe.sable.client + +import android.graphics.Color +import android.os.Bundle +import androidx.activity.enableEdgeToEdge +import androidx.core.view.WindowCompat + +class MainActivity : TauriActivity() { + private external fun nativeInitStatusBar() + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + instance = this + runCatching { nativeInitStatusBar() } + } + + override fun onDestroy() { + if (instance === this) instance = null + super.onDestroy() + } + + companion object { + private var instance: MainActivity? = null + + // Bars stay transparent (edge-to-edge plugin) so the webview strips supply the color + // on every version; these only adapt icon contrast. setStatusBarColor/setNavigationBarColor + // are no-ops under enforced edge-to-edge on Android 15+, so we avoid them. + @JvmStatic + fun setStatusBarColorNative(color: Int) { + val activity = instance ?: return + activity.runOnUiThread { + val window = activity.window + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = + isLight(color) + } + } + + @JvmStatic + fun setNavigationBarColorNative(color: Int) { + val activity = instance ?: return + activity.runOnUiThread { + val window = activity.window + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightNavigationBars = + isLight(color) + } + } + + private fun isLight(color: Int): Boolean { + val luminance = + (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255.0 + return luminance > 0.5 + } + } +} 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_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_foreground.png new file mode 100644 index 0000000000..9f0452a13e Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_foreground.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_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_foreground.png new file mode 100644 index 0000000000..55da193da2 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_foreground.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_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_foreground.png new file mode 100644 index 0000000000..5596dc90f2 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_foreground.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_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_foreground.png new file mode 100644 index 0000000000..65c786a8da Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_foreground.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_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_foreground.png new file mode 100644 index 0000000000..03a758e7e5 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_foreground.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..eec7242f5a --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + 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..eec7242f5a --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + 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..a2ea9ba0ee 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..8e5fc5c0a7 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..edf0662cc2 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..355c0b8391 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..54757a7b4a 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..9327e3a0bc 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..aad167f443 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..54492f07a0 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..3e788c74a8 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..040a8726d6 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..eee9570985 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..1a029990b7 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..f33f3ebc0b 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..1ce3a49676 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..a7f85a4a57 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,8 @@ + + + + + + + + \ 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..ed9ac5e8af 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..8af2357e5a 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..32db07c0d1 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/ios-project.yml b/src-tauri/ios-project.yml new file mode 100644 index 0000000000..0c757aa354 --- /dev/null +++ b/src-tauri/ios-project.yml @@ -0,0 +1,172 @@ +name: {{app.name}} +options: + bundleIdPrefix: {{app.identifier}} + deploymentTarget: + iOS: {{apple.ios-version}} +fileGroups: [{{join file-groups}}] +configs: + debug: debug + release: release +settingGroups: + app: + base: + PRODUCT_NAME: {{app.stylized-name}} + PRODUCT_BUNDLE_IDENTIFIER: {{app.identifier}} + {{#if apple.development-team}} + DEVELOPMENT_TEAM: {{apple.development-team}} + {{/if}} +targetTemplates: + app: + type: application + sources: + - path: Sources + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + groups: [app] +targets: + {{app.name}}_iOS: + type: application + platform: iOS + sources: + - path: Sources + - path: Assets.xcassets + - path: Externals + buildPhase: none + - path: {{app.name}}_iOS + - path: {{app.asset-dir}} + buildPhase: resources + type: folder + {{~#each asset-catalogs}} + - {{prefix-path this}}{{/each}} + {{~#each ios-additional-targets}} + - path: {{prefix-path this}}{{/each}} + - path: LaunchScreen.storyboard + info: + path: {{app.name}}_iOS/Info.plist + properties: + LSRequiresIPhoneOS: true + UILaunchStoryboardName: LaunchScreen + UIRequiredDeviceCapabilities: [arm64, metal] + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + CFBundleShortVersionString: {{apple.bundle-version-short}} + CFBundleVersion: "{{apple.bundle-version}}" + {{~#each apple.plist-pairs}} + {{this.key}}: {{this.value}}{{/each}} + entitlements: + path: {{app.name}}_iOS/{{app.name}}_iOS.entitlements + properties: + aps-environment: development + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + {{~#if ios-command-line-arguments}} + commandLineArguments: + {{~#each ios-command-line-arguments}} + "{{this}}": true + {{/each}}{{~/if}} + settings: + base: + ENABLE_BITCODE: false + ARCHS: [{{join ios-valid-archs}}] + VALID_ARCHS: {{~#each ios-valid-archs}} {{this}} {{/each}} + LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME) + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 + groups: [app] + dependencies: + - framework: {{ lib-output-file-name }} + embed: false + {{~#each ios-libraries}} + - framework: {{this}} + embed: false{{/each}}{{#if ios-vendor-frameworks}}{{~#each ios-vendor-frameworks}} + - framework: {{this}}{{/each}}{{/if}}{{#if ios-vendor-sdks}}{{~#each ios-vendor-sdks}} + - sdk: {{prefix-path this}}{{/each}}{{/if}} + - sdk: CoreGraphics.framework + - sdk: Metal.framework + - sdk: MetalKit.framework + - sdk: QuartzCore.framework + - sdk: Security.framework + - sdk: UIKit.framework{{#if this.ios-frameworks}}{{~#each ios-frameworks}} + - sdk: {{this}}.framework{{/each}}{{/if}} + - sdk: WebKit.framework + preBuildScripts: + {{~#each ios-pre-build-scripts}}{{#if this.path}} + - path {{this.path}}{{/if}}{{#if this.script}} + - script: {{this.script}}{{/if}}{{#if this.name}} + name: {{this.name}}{{/if}}{{#if this.input-files}} + inputFiles: {{~#each this.input-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-files}} + outputFiles: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.input-file-lists}} + inputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-file-lists}} + outputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.shell}} + shell: {{this.shell}}{{/if}}{{#if this.show-env-vars}} + showEnvVars: {{this.show_env_vars}}{{/if}}{{#if this.run-only-when-installing}} + runOnlyWhenInstalling: {{this.run-only-when-installing}}{{/if}}{{#if this.based-on-dependency-analysis}} + basedOnDependencyAnalysis: {{this.based-on-dependency-analysis}}{{/if}}{{#if this.discovered-dependency-file}} + discoveredDependencyFile: {{this.discovered-dependency-file}}{{/if}} + {{~/each}} + + - script: {{ tauri-binary }} {{ tauri-binary-args-str }} -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + name: Build Rust Code + basedOnDependencyAnalysis: false + outputFiles: + - $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/{{ lib-output-file-name }} + - $(SRCROOT)/Externals/arm64/${CONFIGURATION}/{{ lib-output-file-name }} + {{~#if ios-post-compile-scripts}} + postCompileScripts: + {{~#each ios-post-compile-scripts}}{{#if this.path}} + - path {{this.path}}{{/if}}{{#if this.script}} + - script: {{this.script}}{{/if}}{{#if this.name}} + name: {{this.name}}{{/if}}{{#if this.input-files}} + inputFiles: {{~#each this.input-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-files}} + outputFiles: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.input-file-lists}} + inputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-file-lists}} + outputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.shell}} + shell: {{this.shell}}{{/if}}{{#if this.show-env-vars}} + showEnvVars: {{this.show_env_vars}}{{/if}}{{#if this.run-only-when-installing}} + runOnlyWhenInstalling: {{this.run-only-when-installing}}{{/if}}{{#if this.based-on-dependency-analysis}} + basedOnDependencyAnalysis: {{this.based-on-dependency-analysis}}{{/if}}{{#if this.discovered-dependency-file}} + discoveredDependencyFile: {{this.discovered-dependency-file}}{{/if}} + {{~/each~}} + {{~/if~}} + {{~#if ios-post-build-scripts}} + postBuildScripts: + {{~#each ios-post-build-scripts}}{{#if this.path}} + - path {{this.path}}{{/if}}{{#if this.script}} + - script: {{this.script}}{{/if}}{{#if this.name}} + name: {{this.name}}{{/if}}{{#if this.input-files}} + inputFiles: {{~#each this.input-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-files}} + outputFiles: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.input-file-lists}} + inputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-file-lists}} + outputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.shell}} + shell: {{this.shell}}{{/if}}{{#if this.show-env-vars}} + showEnvVars: {{this.show_env_vars}}{{/if}}{{#if this.run-only-when-installing}} + runOnlyWhenInstalling: {{this.run-only-when-installing}}{{/if}}{{#if this.based-on-dependency-analysis}} + basedOnDependencyAnalysis: {{this.based-on-dependency-analysis}}{{/if}}{{#if this.discovered-dependency-file}} + discoveredDependencyFile: {{this.discovered-dependency-file}}{{/if}} + {{~/each~}} + {{~/if}} diff --git a/src-tauri/src/deep_link_ipc.rs b/src-tauri/src/deep_link_ipc.rs new file mode 100644 index 0000000000..82978229b7 --- /dev/null +++ b/src-tauri/src/deep_link_ipc.rs @@ -0,0 +1,191 @@ +//! Deep-link forwarding for the Linux CEF build. +//! +//! CEF is one-process-per-cache, so a deep-link relaunch can't init Chromium to +//! forward itself. The primary binds a socket; the secondary forwards the URL +//! and exits before touching CEF. Delivery re-emits `deep-link://new-url`, the +//! event `@tauri-apps/plugin-deep-link`'s `onOpenUrl` already listens to. + +use std::{ + io::{BufRead, BufReader, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::PathBuf, + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; + +// OIDC uses the private-use `moe.sable.app:/login?...` form; other flows `sable://`. +const SCHEMES: &[&str] = &["moe.sable.app:", "sable:"]; + +fn is_deep_link(arg: &str) -> bool { + SCHEMES.iter().any(|scheme| arg.starts_with(scheme)) +} + +/// Stable socket path. Prefers $XDG_RUNTIME_DIR (per-user tmpfs, cleaned on +/// reboot); falls back to the temp dir. +fn socket_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(dir).join("moe.sable.client-deeplink.sock"); + } + std::env::temp_dir().join("moe.sable.client-deeplink.sock") +} + +fn collect_deep_link_urls_from_args(args: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter() + .skip(1) + .filter_map(|arg| { + let arg = arg.as_ref(); + is_deep_link(arg).then(|| arg.to_string()) + }) + .collect() +} + +pub enum ForwardResult { + /// URLs were written to the primary's socket; the caller must exit(0). + Forwarded, + /// A deep-link URL was in argv but no primary is listening → become primary. + NoPrimary, + /// No deep-link URLs in argv; a normal launch. + NoUrls, +} + +/// Forward any `sable://` URLs in argv to the primary instance. Call BEFORE +/// any CEF initialization. +pub fn try_forward_deep_links() -> ForwardResult { + let urls = collect_deep_link_urls_from_args(std::env::args()); + if urls.is_empty() { + return ForwardResult::NoUrls; + } + + let path = socket_path(); + match UnixStream::connect(&path) { + Ok(mut stream) => { + stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); + for url in &urls { + let _ = writeln!(stream, "{url}"); + } + log::info!("[deep-link-ipc] forwarded {} URL(s) to primary", urls.len()); + ForwardResult::Forwarded + } + Err(_) => ForwardResult::NoPrimary, + } +} + +static PENDING_URLS: OnceLock>>> = OnceLock::new(); +type LiveHandler = Box; +static LIVE_HANDLER: OnceLock>> = OnceLock::new(); + +fn pending_queue() -> &'static Arc>> { + PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new()))) +} +fn live_handler() -> &'static Mutex> { + LIVE_HANDLER.get_or_init(|| Mutex::new(None)) +} + +/// Query/fragment carry OIDC tokens — never log them. +fn redact_for_log(url: &str) -> String { + url.split(['?', '#']) + .next() + .unwrap_or("") + .to_string() +} + +fn dispatch_url(url: String) { + if let Ok(guard) = live_handler().lock() { + if let Some(handler) = guard.as_ref() { + handler(url); + return; + } + } + if let Ok(mut q) = pending_queue().lock() { + q.push(url); + } +} + +/// Removes the socket file on drop. +pub struct DeepLinkSocketGuard { + path: PathBuf, +} +impl Drop for DeepLinkSocketGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// Bind the socket and spawn the listener thread. Returns `None` if a live +/// primary already holds it (or on error) — non-fatal. +pub fn bind_and_listen() -> Option { + let path = socket_path(); + let listener = match UnixListener::bind(&path) { + Ok(l) => l, + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => match UnixStream::connect(&path) { + Ok(_) => return None, // live primary already running + Err(_) => { + let _ = std::fs::remove_file(&path); // stale socket from a crash + UnixListener::bind(&path).ok()? + } + }, + Err(e) => { + log::warn!("[deep-link-ipc] failed to bind {}: {e}", path.display()); + return None; + } + }; + + std::thread::Builder::new() + .name("deep-link-ipc".into()) + .spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => handle_connection(stream), + Err(_) => break, + } + } + }) + .ok(); + Some(DeepLinkSocketGuard { path }) +} + +fn handle_connection(stream: UnixStream) { + stream.set_read_timeout(Some(Duration::from_secs(3))).ok(); + for line in BufReader::new(stream).lines() { + match line { + Ok(url) if is_deep_link(&url) => { + log::info!("[deep-link-ipc] received {}", redact_for_log(&url)); + dispatch_url(url); + } + Ok(_) => {} + Err(_) => break, + } + } +} + +// The event tauri-plugin-deep-link's onOpenUrl listens to. Kept out of the +// emit call as a const so tauri-typegen doesn't emit a binding for it (the +// `://` can't form a valid identifier). +const NEW_URL_EVENT: &str = "deep-link://new-url"; + +/// Install a live handler that emits `NEW_URL_EVENT` and drain anything queued +/// before now. Call from `setup()`. +pub fn drain_pending_urls(app: &tauri::AppHandle) { + use tauri::Emitter; + + let app_for_handler = app.clone(); + if let Ok(mut guard) = live_handler().lock() { + *guard = Some(Box::new(move |url: String| { + if let Err(e) = app_for_handler.emit(NEW_URL_EVENT, vec![url]) { + log::warn!("[deep-link-ipc] emit failed: {e}"); + } + })); + } + + let pending: Vec = pending_queue() + .lock() + .map(|mut q| std::mem::take(&mut *q)) + .unwrap_or_default(); + for url in pending { + let _ = app.emit(NEW_URL_EVENT, vec![url]); + } +} diff --git a/src-tauri/src/desktop/download.rs b/src-tauri/src/desktop/download.rs new file mode 100644 index 0000000000..275bfb39e5 --- /dev/null +++ b/src-tauri/src/desktop/download.rs @@ -0,0 +1,22 @@ +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; + +#[tauri::command] +pub async fn save_download( + app: AppHandle, + filename: String, + bytes: Vec, +) -> Result { + let Some(path) = app + .dialog() + .file() + .set_file_name(&filename) + .blocking_save_file() + else { + return Ok(false); + }; + + let path = path.into_path().map_err(|err| err.to_string())?; + std::fs::write(&path, &bytes).map_err(|err| err.to_string())?; + Ok(true) +} diff --git a/src-tauri/src/desktop/mod.rs b/src-tauri/src/desktop/mod.rs new file mode 100644 index 0000000000..95826b4b06 --- /dev/null +++ b/src-tauri/src/desktop/mod.rs @@ -0,0 +1,7 @@ +pub mod download; +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..23c097dfb7 --- /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 && cfg!(not(target_os = "macos")) { + 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/ios.rs b/src-tauri/src/ios.rs new file mode 100644 index 0000000000..26ab66cafe --- /dev/null +++ b/src-tauri/src/ios.rs @@ -0,0 +1,55 @@ +// iOS shows a form accessory bar (prev/next arrows + Done) above the keyboard +// for web inputs. WKWebView exposes no API to disable it, so swap the private +// WKContentView's class for a runtime subclass whose inputAccessoryView is nil, +// the same approach as Capacitor's hideFormAccessoryBar. + +use std::ffi::CString; + +use objc2::runtime::{AnyClass, AnyObject, ClassBuilder, Sel}; +use objc2::{msg_send, sel}; +use tauri::webview::WebviewWindow; + +extern "C-unwind" fn input_accessory_view_nil(_this: &AnyObject, _cmd: Sel) -> *mut AnyObject { + std::ptr::null_mut() +} + +// Edge-swipe back, matching Android's system back gesture. wry leaves +// allowsBackForwardNavigationGestures off; react-router entries are +// same-document navigations, which WKWebView tracks in its back-forward list. +pub fn enable_swipe_back_navigation(window: &WebviewWindow) { + let _ = window.with_webview(|webview| unsafe { + let webview: *mut AnyObject = webview.inner().cast(); + let _: () = msg_send![&*webview, setAllowsBackForwardNavigationGestures: true]; + }); +} + +pub fn hide_form_accessory_bar(window: &WebviewWindow) { + let _ = window.with_webview(|webview| unsafe { + let webview: *mut AnyObject = webview.inner().cast(); + let scroll_view: *mut AnyObject = msg_send![&*webview, scrollView]; + let subviews: *mut AnyObject = msg_send![&*scroll_view, subviews]; + let count: usize = msg_send![&*subviews, count]; + for index in 0..count { + let subview: *mut AnyObject = msg_send![&*subviews, objectAtIndex: index]; + let class = (*subview).class(); + if !class.name().to_bytes().starts_with(b"WKContent") { + continue; + } + let Ok(subclass_name) = + CString::new(format!("{}_NoAccessoryBar", class.name().to_string_lossy())) + else { + continue; + }; + let subclass = AnyClass::get(&subclass_name).unwrap_or_else(|| { + let mut builder = ClassBuilder::new(&subclass_name, class) + .expect("accessory bar subclass already registered"); + builder.add_method( + sel!(inputAccessoryView), + input_accessory_view_nil as extern "C-unwind" fn(_, _) -> _, + ); + builder.register() + }); + AnyObject::set_class(&*subview, subclass); + } + }); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000000..343b6c0a66 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,342 @@ +#[cfg(all(feature = "cef", target_os = "linux"))] +pub mod deep_link_ipc; +#[cfg(desktop)] +mod desktop; +#[cfg(target_os = "ios")] +mod ios; +#[cfg(target_os = "android")] +mod mobile; +mod network; + +use tauri::{AppHandle, Manager}; +#[cfg(desktop)] +use tauri_plugin_window_state::StateFlags; + +// CEF (Chromium) on Linux; wry everywhere else. +#[cfg(all(feature = "cef", target_os = "linux"))] +type BrowserEngine = tauri_runtime_cef::CefRuntime; +#[cfg(any( + all(not(feature = "cef"), feature = "wry"), + all(feature = "cef", not(target_os = "linux")) +))] +use tauri::Wry as BrowserEngine; + +pub const MAIN_WINDOW_LABEL: &str = "main"; + +#[cfg(all( + not(feature = "cef"), + 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(all( + not(feature = "cef"), + 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() + .use_https_scheme(true); + + #[cfg(desktop)] + let builder = builder + .title("Sable") + .resizable(true) + .fullscreen(false) + .inner_size(1280.0, 720.0) + .visible(false); + + #[cfg(target_os = "macos")] + let builder = builder.hidden_title(true); + + #[cfg(target_os = "windows")] + let builder = builder.decorations(false); + + let _webview_window = builder.build()?; + + // WebKitGTK only (wry). Under CEF, permissions go through set_permission_policy. + #[cfg(all( + not(feature = "cef"), + 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(any(mobile, desktop))] + let builder = builder.plugin(tauri_plugin_notifications::init()); + + #[cfg(desktop)] + let builder = builder + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_dialog::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()?; + } + + #[cfg(all(feature = "cef", target_os = "linux"))] + deep_link_ipc::drain_pending_urls(app.handle()); + + show_or_create_main_window(app.handle())?; + + #[cfg(target_os = "ios")] + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + ios::hide_form_accessory_bar(&window); + ios::enable_swipe_back_navigation(&window); + } + + #[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(target_os = "android")] + mobile::set_status_bar_color, + #[cfg(target_os = "android")] + mobile::set_navigation_bar_color, + #[cfg(desktop)] + desktop::download::save_download, + #[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..04a7609f02 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,151 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + // CEF (Chromium) runtime, Linux only. Must run before anything else — CEF + // re-execs this binary for its subprocesses. + #[cfg(all(feature = "cef", target_os = "linux"))] + { + tauri_runtime_cef::configure(tauri_runtime_cef::CefConfig { + identifier: "moe.sable.client".into(), + custom_schemes: vec![ + "tauri".into(), + "ipc".into(), + "asset".into(), + "sable-media".into(), + ], + deep_link_schemes: vec!["moe.sable.app".into(), "sable".into()], + command_line_args: vec![ + // NVIDIA GPU-sandbox denies GBM access → SIGSEGV; keeps HW accel. + ("disable-gpu-sandbox".into(), None), + ("disable-font-subpixel-positioning".into(), None), + ("enable-font-antialiasing".into(), None), + ], + ..Default::default() + }); + + // Subprocess — hand off to CEF and exit. + if std::env::args().any(|arg| arg.starts_with("--type=")) { + tauri_runtime_cef::run_cef_helper_process(); + return; + } + + // Deep-link relaunch: forward to the running primary and exit before + // CEF init (a second instance can't hold the CEF cache lock). + if let app_lib::deep_link_ipc::ForwardResult::Forwarded = + app_lib::deep_link_ipc::try_forward_deep_links() + { + return; + } + + // Allow call media capture (mic, camera, screen-share) for our webview. + tauri_runtime_cef::set_permission_policy(|request, responder| { + use tauri_runtime_cef::{DenyReason, PermissionKind, Verdict}; + if request.webview_label == "main" { + let verdicts = request + .kinds + .iter() + .map(|kind| match kind { + PermissionKind::Microphone + | PermissionKind::Camera + | PermissionKind::CameraPanTiltZoom + | PermissionKind::ScreenCapture + | PermissionKind::CapturedSurfaceControl => Verdict::Allow, + _ => Verdict::Deny, + }) + .collect(); + return responder.decide(verdicts); + } + responder.deny(DenyReason::NoPolicy) + }); + } + + // Force X11/XWayland: WebKitGTK needs it, and the CEF runtime's Wayland + // window path is unstable (crate verified on X11 only). + #[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()); + } + } + } + + // CEF doesn't init GTK, but the Linux tray menu needs it. + #[cfg(all(feature = "cef", target_os = "linux"))] + { + let _ = gtk::init(); + } + + // Deep-link primary: hold the forwarding socket for the process lifetime. + #[cfg(all(feature = "cef", target_os = "linux"))] + let _deep_link_guard = app_lib::deep_link_ipc::bind_and_listen(); + + app_lib::run(); +} diff --git a/src-tauri/src/mobile.rs b/src-tauri/src/mobile.rs new file mode 100644 index 0000000000..116499de88 --- /dev/null +++ b/src-tauri/src/mobile.rs @@ -0,0 +1,53 @@ +// Native status-bar tinting. The webview draws edge-to-edge under a transparent +// status bar, so only Window.setStatusBarColor can color that region. Rust can't +// reach the Activity directly (ndk-context isn't populated by Tauri), so +// MainActivity hands us the JavaVM on create and we call back into a static +// method that owns the window. + +use std::sync::OnceLock; + +use jni::objects::{JObject, JValue}; +use jni::{JNIEnv, JavaVM}; + +static JAVA_VM: OnceLock = OnceLock::new(); + +// Called by MainActivity.onCreate to cache the JavaVM for later callbacks. +#[no_mangle] +pub extern "system" fn Java_moe_sable_client_MainActivity_nativeInitStatusBar( + env: JNIEnv, + _this: JObject, +) { + if let Ok(vm) = env.get_java_vm() { + let _ = JAVA_VM.set(vm); + } +} + +/// `color` is a packed ARGB int (as produced by Android's `Color`). +#[tauri::command] +pub fn set_status_bar_color(color: u32) -> Result<(), String> { + call_bar_color("setStatusBarColorNative", color) +} + +/// `color` is a packed ARGB int (as produced by Android's `Color`). +#[tauri::command] +pub fn set_navigation_bar_color(color: u32) -> Result<(), String> { + call_bar_color("setNavigationBarColorNative", color) +} + +fn call_bar_color(method: &str, color: u32) -> Result<(), String> { + let vm = JAVA_VM.get().ok_or("java vm not initialized")?; + let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; + + let result = env.call_static_method( + "moe/sable/client/MainActivity", + method, + "(I)V", + &[JValue::Int(color as i32)], + ); + if result.is_err() { + let _ = env.exception_clear(); + } + result.map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/src-tauri/src/network/loopback_http.rs b/src-tauri/src/network/loopback_http.rs new file mode 100644 index 0000000000..29797c7502 --- /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 url = validate_loopback_url(&request.url)?; + let mut abort_receiver = register_abort_sender(&request_id); + + 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..800a6ad83b --- /dev/null +++ b/src-tauri/src/network/media_protocol.rs @@ -0,0 +1,238 @@ +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(&session.token, &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, "*") + // Authorization is enforced by the protocol handler. Do not let the + // webview reuse this response after the active Matrix session changes. + .header(header::CACHE_CONTROL, "private, no-store") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header( + header::CONTENT_SECURITY_POLICY, + "sandbox; default-src 'none'; script-src 'none'; object-src 'none'", + ) + .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(session_token: &str, url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(session_token.as_bytes()); + hasher.update([0]); + hasher.update(url.as_bytes()); + let digest = hasher.finalize(); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use tauri::http::header; + + use super::{cache_key, ok_response}; + + #[test] + fn cache_key_is_stable_and_hex() { + let url = "https://matrix.example.org/_matrix/client/v1/media/download/x/y"; + let key = cache_key("account-a-token", url); + assert_eq!(key.len(), 64); + assert!(key.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(key, cache_key("account-a-token", url)); + } + + #[test] + fn cache_key_is_scoped_to_the_session() { + let url = "https://matrix.example.org/_matrix/client/v1/media/download/x/y"; + assert_ne!( + cache_key("account-a-token", url), + cache_key("account-b-token", url) + ); + } + + #[test] + fn protocol_responses_are_not_cached_by_the_webview() { + let response = ok_response(Vec::new(), "image/png"); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "private, no-store" + ); + } +} 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..7915f9e40c --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,81 @@ +{ + "$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: 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", + "createUpdaterArtifacts": true, + "macOS": { + "infoPlist": "Info.plist" + }, + "iOS": { + "template": "src-tauri/ios-project.yml" + }, + "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": { + "updater": { + "endpoints": ["https://github.com/SableClient/Sable/releases/latest/download/latest.json"], + "pubkey": "REPLACE_WITH_SABLECLIENT_UPDATER_PUBLIC_KEY" + }, + "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/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index 1af535ab63..69ceb097d0 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -2,24 +2,9 @@ import type { ReactNode } from 'react'; import { useCallback } from 'react'; import { useSetAtom } from 'jotai'; import { matchPath, useLocation, useNavigate } from 'react-router-dom'; -import { - getDirectPath, - getExplorePath, - getHomePath, - getInboxPath, - getSpacePath, -} from '$pages/pathUtils'; -import { - DIRECT_PATH, - EXPLORE_PATH, - HOME_PATH, - INBOX_PATH, - SPACE_PATH, - HOME_ROOM_PATH, - DIRECT_ROOM_PATH, - SPACE_ROOM_PATH, -} from '$pages/paths'; -import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; +import { resolveSection } from '$pages/pathUtils'; +import { HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths'; +import { lastVisitedRoomAtom } from '$state/room/lastRoom'; type BackRouteHandlerProps = { children: (onBack: () => void) => ReactNode; @@ -27,88 +12,27 @@ type BackRouteHandlerProps = { export function BackRouteHandler({ children }: BackRouteHandlerProps) { const navigate = useNavigate(); const location = useLocation(); - const setLastRoomId = useSetAtom(lastVisitedRoomIdAtom); + const setLastRoom = useSetAtom(lastVisitedRoomAtom); const goBack = useCallback(() => { - const roomPaths = [HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH]; + const section = resolveSection(location.pathname); + if (!section) return; + const roomPaths = [HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH]; const roomMatch = roomPaths .map((path) => matchPath({ path, end: false }, location.pathname)) .find((match) => match !== null); const currentRoomIdOrAlias = roomMatch?.params.roomIdOrAlias; - if (currentRoomIdOrAlias) { - setLastRoomId(decodeURIComponent(currentRoomIdOrAlias)); - } - - if ( - matchPath( - { - path: HOME_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ) - ) { - navigate(getHomePath()); - return; + if (section.getRoomPath && currentRoomIdOrAlias) { + setLastRoom({ + section: section.key, + roomId: decodeURIComponent(currentRoomIdOrAlias), + }); } - if ( - matchPath( - { - path: DIRECT_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ) - ) { - navigate(getDirectPath()); - return; - } - const spaceMatch = matchPath( - { - path: SPACE_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ); - const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias; - const decodedSpaceIdOrAlias = - encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); - if (decodedSpaceIdOrAlias) { - navigate(getSpacePath(decodedSpaceIdOrAlias)); - return; - } - if ( - matchPath( - { - path: EXPLORE_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ) - ) { - navigate(getExplorePath()); - return; - } - if ( - matchPath( - { - path: INBOX_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ) - ) { - navigate(getInboxPath()); - } - }, [navigate, location, setLastRoomId]); + navigate(section.listPath); + }, [navigate, location, setLastRoom]); return children(goBack); } 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/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index cf4e21c41d..e9cf3fb3e0 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -2,7 +2,7 @@ import type { FormEventHandler } from 'react'; import { forwardRef, useCallback, useState } from 'react'; import { Dialog, Header, Box, Text, IconButton, config, Button, Chip, color, Spinner } from 'folds'; import { composerIcon, X } from '$components/icons/phosphor'; -import FileSaver from 'file-saver'; +import { saveFileToDevice } from '$utils/download'; import to from 'await-to-js'; import type { AuthDict, IAuthData, UIAuthCallback, UIAFlow } from '$types/matrix-sdk'; import { MatrixError } from '$types/matrix-sdk'; @@ -258,7 +258,7 @@ function RecoveryKeyDisplay({ recoveryKey }: Readonly) const blob = new Blob([recoveryKey], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'recovery-key.txt'); + void saveFileToDevice(blob, 'recovery-key.txt'); }; const safeToDisplayKey = show ? recoveryKey : recoveryKey.replaceAll(/[^\s]/g, '*'); diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index d3cddbae2c..7e5d64e96f 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -28,8 +28,7 @@ import { sizedIcon, } from '$components/icons/phosphor'; import FocusTrap from 'focus-trap-react'; -import FileSaver from 'file-saver'; -import { getDownloadFilename } from '$utils/download'; +import { getDownloadFilename, saveFileToDevice } from '$utils/download'; import { AsyncStatus } from '$hooks/useAsyncCallback'; import { useImageGestures } from '$hooks/useImageGestures'; import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '$plugins/pdfjs-dist'; @@ -62,8 +61,11 @@ export const PdfViewer = as<'div', PdfViewerProps>( ); const isLoading = pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading; + const [renderError, setRenderError] = useState(false); const isError = - pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error; + pdfJSState.status === AsyncStatus.Error || + docState.status === AsyncStatus.Error || + renderError; const [pageNo, setPageNo] = useState(1); const [jumpAnchor, setJumpAnchor] = useState(); @@ -77,21 +79,30 @@ export const PdfViewer = as<'div', PdfViewerProps>( }, [pdfJSState, loadPdfDocument]); useEffect(() => { - if (docState.status === AsyncStatus.Success) { - const doc = docState.data; - if (pageNo < 0 || pageNo > doc.numPages) return; - createPage(doc, pageNo, { scale: zoom }).then((canvas) => { + if (docState.status !== AsyncStatus.Success) return undefined; + const doc = docState.data; + if (pageNo < 0 || pageNo > doc.numPages) return undefined; + let cancelled = false; + setRenderError(false); + createPage(doc, pageNo, { scale: zoom }) + .then((canvas) => { + if (cancelled) return; const container = containerRef.current; if (!container) return; container.textContent = ''; container.append(canvas); canvas.style.touchAction = 'pan-x pan-y'; + }) + .catch(() => { + if (!cancelled) setRenderError(true); }); - } + return () => { + cancelled = true; + }; }, [docState, pageNo, zoom]); const handleDownload = () => { - FileSaver.saveAs(src, getDownloadFilename(name, undefined, 'document.pdf')); + void saveFileToDevice(src, getDownloadFilename(name, undefined, 'document.pdf')); }; const handleJumpSubmit: FormEventHandler = (evt) => { @@ -186,7 +197,7 @@ export const PdfViewer = as<'div', PdfViewerProps>( )} - {docState.status === AsyncStatus.Success && ( + {docState.status === AsyncStatus.Success && !renderError && ( void; onOpenMembers?: () => void; onReply?: () => void; } export function SwipeableChatWrapper({ children, - onOpenSidebar, onOpenMembers, onReply, }: SwipeableChatWrapperProps) { - const settings = useAtomValue(settingsAtom); + const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures'); + const [rightSwipeAction] = useSetting(settingsAtom, 'rightSwipeAction'); const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 400, damping: 40 }); const bind = useDrag( - ({ active, movement: [mx], velocity: [vx], direction: [dx], event: e }) => { + ({ first, active, offset: [ox], velocity: [vx], direction: [dx], event: e }) => { if (e && 'target' in e && e.target instanceof HTMLElement) { if (e.target.closest('[data-gestures="ignore"]')) { return; } } - if (!settings.mobileGestures || !mobileOrTablet()) return; + if (!mobileGestures || !mobileOrTablet()) return; - let val = mx; + let val = ox; - const canSwipeRight = !!onOpenSidebar; const canSwipeLeft = - settings.rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply; + rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply; - if (!canSwipeRight && val > 0) val = 0; + // The drawer owns the rightward reveal; this wrapper only handles leftward actions. + if (val > 0) val = 0; if (!canSwipeLeft && val < 0) val = 0; if (active) { + // Take over any settling spring; offset is seeded from the live position. + if (first) x.stop(); x.set(val); } else { const swipeThreshold = 120; const velocityThreshold = 0.5; - if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) { - onOpenSidebar?.(); - } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) { - if (settings.rightSwipeAction === RightSwipeAction.Members) { + if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) { + if (rightSwipeAction === RightSwipeAction.Members) { onOpenMembers?.(); } else { onReply?.(); } } - x.set(0); + animate(x, 0, { type: 'spring', stiffness: 400, damping: 40 }); } }, { @@ -64,10 +62,12 @@ export function SwipeableChatWrapper({ bounds: { left: -200, right: 200 }, rubberband: true, filterTaps: true, + pointer: { capture: false }, + from: () => [x.get(), 0], } ); - if (!settings.mobileGestures || !mobileOrTablet()) { + if (!mobileGestures || !mobileOrTablet()) { return (
{ - if (e && 'target' in e && e.target instanceof HTMLElement) { - if (e.target.closest('[data-gestures="ignore"]')) { + ({ first, active, offset: [ox], velocity: [vx], direction: [dx], event }) => { + if (event && 'target' in event && event.target instanceof HTMLElement) { + if (event.target.closest('[data-gestures="ignore"]')) { return; } } - if (!settings.mobileGestures || !mobileOrTablet()) return; + if (!mobileGestures || !mobileOrTablet()) return; event.stopPropagation(); - let val = mx; + let val = ox; if (direction === 'left' && val > 0) val = 0; if (direction === 'right' && val < 0) val = 0; if (active) { + // Take over any settling spring; offset is seeded from the live position. + if (first) x.stop(); x.set(val); } else { const swipeThreshold = 100; @@ -52,7 +53,7 @@ export function SwipeableOverlayWrapper({ onClose(); } - x.set(0); + animate(x, 0, { type: 'spring', stiffness: 400, damping: 40 }); } }, { @@ -61,10 +62,11 @@ export function SwipeableOverlayWrapper({ rubberband: true, filterTaps: true, pointer: { capture: true }, + from: () => [x.get(), 0], } ); - if (!settings.mobileGestures || !mobileOrTablet()) { + if (!mobileGestures || !mobileOrTablet()) { 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..837cb0fdf3 --- /dev/null +++ b/src/app/components/app-shell/AppShell.tsx @@ -0,0 +1,83 @@ +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 { Toast } from '$components/toast/Toast'; +import type { ScreenSize } from '$hooks/useScreenSize'; +import { 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.test.tsx b/src/app/components/app-shell/SystemBarShell.test.tsx new file mode 100644 index 0000000000..0e1d8e730f --- /dev/null +++ b/src/app/components/app-shell/SystemBarShell.test.tsx @@ -0,0 +1,39 @@ +import { render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SystemBarShell } from './SystemBarShell'; + +const { mockIsTauri, mockOsType } = vi.hoisted(() => ({ + mockIsTauri: vi.fn<() => boolean>(), + mockOsType: vi.fn<() => string>(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn<() => Promise>(), + isTauri: mockIsTauri, +})); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: mockOsType, +})); + +describe('SystemBarShell', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('does not observe document mutations outside mobile Tauri', () => { + mockIsTauri.mockReturnValue(false); + const mutationObserver = vi.fn<() => void>(); + vi.stubGlobal('MutationObserver', mutationObserver); + + render( + void>()}> +
Content
+
+ ); + + expect(mockOsType).not.toHaveBeenCalled(); + expect(mutationObserver).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/app-shell/SystemBarShell.tsx b/src/app/components/app-shell/SystemBarShell.tsx new file mode 100644 index 0000000000..034b468264 --- /dev/null +++ b/src/app/components/app-shell/SystemBarShell.tsx @@ -0,0 +1,186 @@ +import { type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'; +import { invoke, isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; +import { updateThemeColorMeta } from '../../theme/themeColorMeta'; + +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 iosBottomInset = `max(${safeAreaBottom}, var(--keyboard-height, 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))'; + +const TRANSPARENT = /^(transparent$|rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\))/; + +// First non-transparent background color painted at (x, y). +function readSurfaceColor(x: number, y: number): string | undefined { + let el = document.elementFromPoint(x, y); + while (el) { + const bg = getComputedStyle(el).backgroundColor; + if (bg && !TRANSPARENT.test(bg)) return bg; + el = el.parentElement; + } + return undefined; +} + +// "rgb(r, g, b)" -> packed ARGB int for the native Android status bar. +function rgbToArgb(color: string): number | undefined { + const parts = color.match(/\d+/g); + if (!parts || parts.length < 3) return undefined; + const [r, g, b] = parts.map((part) => parseInt(part, 10)); + if (r === undefined || g === undefined || b === undefined) return undefined; + return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0; +} + +// Color of the surface adjacent to a strip; on Android also tints the native system bar. +function useBarColor( + probeRef: RefObject, + edge: 'top' | 'bottom', + android: boolean, + enabled: boolean +): string | undefined { + const [color, setColor] = useState(); + const lastRef = useRef(); + + useEffect(() => { + if (!enabled) return undefined; + + let frame = 0; + const sample = () => { + frame = 0; + const rect = probeRef.current?.getBoundingClientRect(); + const x = Math.round(rect ? rect.left + rect.width / 2 : window.innerWidth / 2); + const y = + edge === 'top' + ? Math.round((rect?.bottom ?? 0) + 1) + : Math.round((rect?.top ?? window.innerHeight) - 1); + const next = readSurfaceColor(x, y); + if (next && next !== lastRef.current) { + lastRef.current = next; + setColor(next); + if (edge === 'top') updateThemeColorMeta(next); + if (android) { + const argb = rgbToArgb(next); + const command = edge === 'top' ? 'set_status_bar_color' : 'set_navigation_bar_color'; + if (argb !== undefined) invoke(command, { color: argb }).catch(() => {}); + } + } + }; + // Trailing edge: sample once mutations settle, so navigation recolors land. + const schedule = () => { + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(sample); + }; + + schedule(); + const observer = new MutationObserver(schedule); + // childList = navigation; class/style = theme swaps and per-view recolors. + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['class', 'style'], + }); + observer.observe(document.head, { childList: true }); // remote-theme css + window.addEventListener('resize', schedule); + + return () => { + if (frame) cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener('resize', schedule); + }; + }, [probeRef, edge, android, enabled]); + + return color; +} + +type SystemBarStripProps = { + size: string; + background: string; + stripRef?: RefObject; + transition?: string; +}; + +function SystemBarStrip({ size, background, stripRef, transition }: 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'; + + const topStripRef = useRef(null); + const bottomStripRef = useRef(null); + const topColor = useBarColor(topStripRef, 'top', tauriOs === 'android', enabled); + const bottomColor = useBarColor(bottomStripRef, 'bottom', tauriOs === 'android', enabled); + + 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/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 2aae96460e..897043eec6 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -22,6 +22,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useRecentEmoji } from '$hooks/useRecentEmoji'; import { isUserId, mxcUrlToHttp } from '$utils/matrix'; import { editableActiveElement, targetFromEvent } from '$utils/dom'; +import { fetch } from '$utils/fetch'; import type { UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; import { useAsyncSearch } from '$hooks/useAsyncSearch'; import { useDebounce } from '$hooks/useDebounce'; 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..e05ac0cea7 --- /dev/null +++ b/src/app/components/image-viewer/ImageViewer.test.tsx @@ -0,0 +1,56 @@ +import type { PointerEvent, SyntheticEvent, WheelEvent } from 'react'; +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<(src: string) => Promise>(); + +vi.mock('$hooks/useImageGestures', () => ({ + useImageGestures: () => ({ + transforms: { zoom: 1, pan: { x: 0, y: 0 } }, + cursor: 'grab', + fitRatio: 1, + imageRef: { current: null }, + containerRef: { current: null }, + handleWheel: vi.fn<(event: WheelEvent) => void>(), + onPointerDown: vi.fn<(event: PointerEvent) => void>(), + handleImageLoad: vi.fn<(event: SyntheticEvent) => void>(), + setZoom: vi.fn<(next: number) => void>(), + resetTransforms: vi.fn<() => void>(), + zoomIn: vi.fn<() => void>(), + zoomOut: vi.fn<() => void>(), + enableResizeWithWindow: vi.fn<() => void>(), + }), +})); + +vi.mock('$utils/matrix', () => ({ + downloadMedia: (...args: [string]) => downloadMedia(...args), +})); + +vi.mock('file-saver', () => ({ + default: { + saveAs: vi.fn<(data: Blob | string, filename?: string) => void>(), + }, +})); + +describe('ImageViewer', () => { + it('downloads media without passing a media token argument', async () => { + downloadMedia.mockResolvedValue(new Blob(['image'])); + + render( + void>()} + /> + ); + + 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/Reaction.tsx b/src/app/components/message/Reaction.tsx index 3b4c3cebb4..1888887384 100644 --- a/src/app/components/message/Reaction.tsx +++ b/src/app/components/message/Reaction.tsx @@ -46,7 +46,7 @@ export const Reaction = as< return ( {reaction} setImgError(true)} /> diff --git a/src/app/components/message/ReactionKeyInline.tsx b/src/app/components/message/ReactionKeyInline.tsx index 901992eeed..a22c7c709b 100644 --- a/src/app/components/message/ReactionKeyInline.tsx +++ b/src/app/components/message/ReactionKeyInline.tsx @@ -4,6 +4,7 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { menuIcon, Warning } from '$components/icons/phosphor'; import { scaleSystemEmoji } from '$plugins/react-custom-html-parser'; import { mxcUrlToHttp } from '$utils/matrix'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import * as css from './Reaction.css'; type ReactionKeyInlineProps = { @@ -20,6 +21,11 @@ export function ReactionKeyInline({ useAuthentication, }: ReactionKeyInlineProps) { const [imgError, setImgError] = useState(false); + const rawReactionUrl = + reactionKey && reactionKey.startsWith('mxc://') + ? (mxcUrlToHttp(mx, reactionKey, useAuthentication) ?? undefined) + : undefined; + const renderableReactionUrl = useRenderableMediaUrl(rawReactionUrl); if (!reactionKey) { if (shortcode) { @@ -44,7 +50,7 @@ export function ReactionKeyInline({ return ( {shortcode setImgError(true)} /> diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index eef1f7937a..596a43d49b 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -14,7 +14,6 @@ import { as, } from 'folds'; import { ArrowRight, Download, sizedIcon, Warning } from '$components/icons/phosphor'; -import FileSaver from 'file-saver'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import FocusTrap from 'focus-trap-react'; import type { IFileInfo } from '$types/matrix/common'; @@ -31,7 +30,7 @@ import { stopPropagation } from '$utils/keyboard'; import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { ModalWide } from '$styles/Modal.css'; -import { getDownloadFilename } from '$utils/download'; +import { getDownloadFilename, saveFileToDevice } from '$utils/download'; const renderErrorButton = (retry: () => void, text: string) => ( downloadState.status === AsyncStatus.Success - ? FileSaver.saveAs(downloadState.data, getDownloadFilename(body)) + ? void saveFileToDevice(downloadState.data, getDownloadFilename(body), mimeType) : download() } disabled={downloadState.status === AsyncStatus.Loading} diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 5ca40c3820..b19822cdbb 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Badge, Box, @@ -49,23 +49,7 @@ import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME, } from '../../../../unstable/prefixes'; import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; - -function thumbnailDimsForMaxEdge( - maxEdge: number, - w?: number, - h?: number -): { tw: number; th: number } { - const safeEdge = Math.max(1, Math.round(maxEdge)); - const iw = typeof w === 'number' && Number.isFinite(w) && w > 0 ? w : safeEdge; - const ih = typeof h === 'number' && Number.isFinite(h) && h > 0 ? h : safeEdge; - const longest = Math.max(iw, ih); - if (longest <= safeEdge) return { tw: Math.round(iw), th: Math.round(ih) }; - const scale = safeEdge / longest; - return { - tw: Math.max(1, Math.round(iw * scale)), - th: Math.max(1, Math.round(ih * scale)), - }; -} +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; export function checkIfGif(url: string, mimetype?: string, body?: string) { return ( @@ -160,26 +144,24 @@ export const ImageContent = as<'div', ImageContentProps>( const isGif = checkIfGif(url, info?.mimetype, body); - const [srcState, loadSrc] = useAsyncCallback( - useCallback(async () => { - if (url.startsWith('http')) return url; + const rawMediaUrl = useMemo(() => { + if (url.startsWith('http')) return url; + return mxcUrlToHttp(mx, url, useAuthentication) ?? undefined; + }, [mx, url, useAuthentication]); - if (typeof matrixThumbnailMaxEdge === 'number' && matrixThumbnailMaxEdge > 0 && !encInfo) { - const { tw, th } = thumbnailDimsForMaxEdge(matrixThumbnailMaxEdge, info?.w, info?.h); - const thumbUrl = mxcUrlToHttp(mx, url, useAuthentication, tw, th, 'scale', false); - if (thumbUrl) return thumbUrl; - } + const resolvedMediaUrl = useRenderableMediaUrl(encInfo ? undefined : rawMediaUrl); - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); - if (!mediaUrl) throw new Error('Invalid media URL'); + const [srcState, loadSrc] = useAsyncCallback( + useCallback(async () => { if (encInfo) { - const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => + if (!rawMediaUrl) throw new Error('Invalid media URL'); + const fileContent = await downloadEncryptedMedia(rawMediaUrl, (encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) ); return URL.createObjectURL(fileContent); } - return mediaUrl; - }, [mx, url, useAuthentication, mimeType, encInfo, matrixThumbnailMaxEdge, info?.w, info?.h]) + return resolvedMediaUrl ?? rawMediaUrl ?? url; + }, [rawMediaUrl, resolvedMediaUrl, url, mimeType, encInfo]) ); useEffect(() => { diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index 53e27002fe..1f037c65e8 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -1,10 +1,11 @@ import type { ReactNode } from 'react'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import type { IThumbnailContent } from '$types/matrix/common'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { FALLBACK_MIMETYPE } from '$utils/mimeTypes'; export type ThumbnailContentProps = { @@ -15,26 +16,31 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const encInfo = info.thumbnail_file; + const thumbMxcUrl = encInfo?.url ?? info.thumbnail_url; + + const rawMediaUrl = useMemo(() => { + if (typeof thumbMxcUrl !== 'string') return undefined; + return mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? undefined; + }, [mx, thumbMxcUrl, useAuthentication]); + + const resolvedMediaUrl = useRenderableMediaUrl(encInfo ? undefined : rawMediaUrl); + const [thumbSrcState, loadThumbSrc] = useAsyncCallback( useCallback(async () => { const thumbInfo = info.thumbnail_info; - const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url; - const encInfo = info.thumbnail_file; if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') { throw new Error('Failed to load thumbnail'); } - - const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication); - if (!mediaUrl) throw new Error('Invalid media URL'); if (encInfo) { - const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => + if (!rawMediaUrl) throw new Error('Invalid media URL'); + const fileContent = await downloadEncryptedMedia(rawMediaUrl, (encBuf) => decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo) ); return URL.createObjectURL(fileContent); } - - return mediaUrl; - }, [mx, info, useAuthentication]) + return resolvedMediaUrl ?? rawMediaUrl ?? thumbMxcUrl; + }, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, encInfo]) ); useEffect(() => { diff --git a/src/app/components/message/content/UploadedSableCssContent.tsx b/src/app/components/message/content/UploadedSableCssContent.tsx index c3486000e7..f77442b925 100644 --- a/src/app/components/message/content/UploadedSableCssContent.tsx +++ b/src/app/components/message/content/UploadedSableCssContent.tsx @@ -3,6 +3,7 @@ import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { Box, Button, IconButton, Spinner, Switch, Text, config, toRem } from 'folds'; import { Star, sizedIcon } from '$components/icons/phosphor'; +import { getCspNonce } from '$utils/cspNonce'; import { ThemePreviewCard } from '$components/theme/ThemePreviewCard'; import { CssViewerButton } from '$components/theme/CssViewerButton'; import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch'; @@ -161,7 +162,7 @@ function UploadedTweakCard({ data }: { data: Extract {styleBlock && ( <> - + ( }, [mx, url, useAuthentication, mimeType, encInfo]) ); + // When the source download succeeds, reset video-element error state so the + // Retry button doesn't flash before the
, + container + ); +} diff --git a/src/app/components/url-preview/ClientPreview.tsx b/src/app/components/url-preview/ClientPreview.tsx index ed9fb9edf8..694ca0d2e0 100644 --- a/src/app/components/url-preview/ClientPreview.tsx +++ b/src/app/components/url-preview/ClientPreview.tsx @@ -6,6 +6,7 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { encodeBlurHash } from '$utils/blurHash'; +import { fetch } from '$utils/fetch'; import { Attachment, AttachmentBox, AttachmentHeader } from '../message/attachment'; import { Image } from '../media'; import { UrlPreview } from './UrlPreview'; diff --git a/src/app/components/url-preview/ThemePreviewUrlCard.tsx b/src/app/components/url-preview/ThemePreviewUrlCard.tsx index 11441c73ec..f74fdbec67 100644 --- a/src/app/components/url-preview/ThemePreviewUrlCard.tsx +++ b/src/app/components/url-preview/ThemePreviewUrlCard.tsx @@ -20,6 +20,7 @@ import { ThemePreviewCard } from '../theme/ThemePreviewCard'; import { SableChatPreviewPlaceholder } from './SableChatPreviewPlaceholder'; import { ThemeThirdPartyBanner } from './ThemeThirdPartyBanner'; import { pruneThemeFavorites, themeUrlHostLabel } from '../../theme/themeLibrary'; +import { fetch } from '$utils/fetch'; function isHttps(url: string): boolean { return /^https:\/\//i.test(url); diff --git a/src/app/components/url-preview/TweakPreviewUrlCard.tsx b/src/app/components/url-preview/TweakPreviewUrlCard.tsx index c20058341d..614c4b08d5 100644 --- a/src/app/components/url-preview/TweakPreviewUrlCard.tsx +++ b/src/app/components/url-preview/TweakPreviewUrlCard.tsx @@ -15,6 +15,7 @@ import { Check, Link, Star, Warning, sizedIcon } from '$components/icons/phospho import { useClientConfig } from '$hooks/useClientConfig'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { getCspNonce } from '$utils/cspNonce'; import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom, type ThemeRemoteTweakFavorite } from '$state/settings'; @@ -28,6 +29,7 @@ import { SableChatPreviewPlaceholder } from './SableChatPreviewPlaceholder'; import { ThemeThirdPartyBanner } from './ThemeThirdPartyBanner'; import { CssViewerButton } from '../theme/CssViewerButton'; import { pruneThemeTweakFavorites, themeUrlHostLabel } from '../../theme/themeLibrary'; +import { fetch } from '$utils/fetch'; function basenameFromFullSableUrl(url: string): string { const tail = url.split('/').pop() ?? url; @@ -346,7 +348,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) { {styleBlock ? ( <> - + = (evt) => { export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) { const [error, setError] = useState(false); + const resolvedSrc = useRenderableMediaUrl(src); useEffect(() => { setError(false); @@ -38,8 +40,10 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User return ( setError(true)} onLoad={handleImageLoad} draggable={false} diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index ba79f3fed7..a39e6323f0 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -25,7 +25,7 @@ import { stopPropagation } from '$utils/keyboard'; import { useRoom } from '$hooks/useRoom'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { useNickname } from '$hooks/useNickname'; -import { useBlobCache } from '$hooks/useBlobCache'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { ImageViewer } from '$components/image-viewer'; import { AvatarPresence, PresenceBadge } from '$components/presence'; import { UserAvatar } from '$components/user-avatar'; @@ -66,8 +66,8 @@ export function UserHero({ const [viewAvatar, setViewAvatar] = useState(); const [isFullStatus, setIsFullStatus] = useState(false); - const cachedBannerUrl = useBlobCache(bannerUrl); - const cachedAvatarUrl = useBlobCache(avatarUrl); + const cachedBannerUrl = useRenderableMediaUrl(bannerUrl); + const cachedAvatarUrl = useRenderableMediaUrl(avatarUrl); const coverUrl = cachedBannerUrl || cachedAvatarUrl; const isFallbackCover = !cachedBannerUrl && !!cachedAvatarUrl; diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx index 532a3bda09..1305c1144e 100644 --- a/src/app/features/bug-report/BugReportModal.tsx +++ b/src/app/features/bug-report/BugReportModal.tsx @@ -23,6 +23,7 @@ import * as Sentry from '@sentry/react'; import { useCloseBugReportModal, useBugReportModalOpen } from '$state/hooks/bugReportModal'; import { stopPropagation } from '$utils/keyboard'; import { getDebugLogger } from '$utils/debugLogger'; +import { fetch } from '$utils/fetch'; type ReportType = 'bug' | 'feature'; diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index 2ce272d90d..8366188f6a 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -27,6 +27,7 @@ import { SettingTile } from '$components/setting-tile'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { usePowerLevels } from '$hooks/usePowerLevels'; import { suffixRename } from '$utils/common'; @@ -37,6 +38,11 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; import { CustomStateEvent } from '$types/matrix/room'; +function PackAvatarImage({ url }: { url: string }) { + const resolved = useRenderableMediaUrl(url); + return ; +} + type CreatePackTileProps = { packs: ImagePack[]; roomId: string; @@ -233,7 +239,7 @@ export function RoomPacks({ onViewPack }: Readonly) { ))} {avatarUrl ? ( - + ) : ( {composerIcon(Sticker, { weight: 'fill' })} )} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 5c487edf7d..68e6c9882a 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -1,5 +1,5 @@ import type { MouseEventHandler, MouseEvent } from 'react'; -import { forwardRef, useState, useEffect } from 'react'; +import { forwardRef, startTransition, useState, useEffect } from 'react'; import type { Room } from '$types/matrix-sdk'; import { RoomEvent as RoomEventEnum } from '$types/matrix-sdk'; import type { RectCords } from 'folds'; @@ -359,7 +359,8 @@ export function RoomNavItem({ navigateRoom(room.roomId); } } else { - navigate(linkPath); + // Render the room off the urgent path so the tap doesn't freeze the UI on mount. + startTransition(() => navigate(linkPath)); } }; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 6a170c996d..1a62e622e7 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -86,7 +86,7 @@ import type { UploadBoardImperativeHandlers } from '$components/upload-board'; import { UploadBoard, UploadBoardContent, UploadBoardHeader } from '$components/upload-board'; import type { Upload, UploadSuccess } from '$state/upload'; import { UploadStatus, createUploadFamilyObserverAtom } from '$state/upload'; -import { getImageUrlBlob, loadImageElement } from '$utils/dom'; +import { loadImageElementFromMediaUrl } from '$utils/dom'; import { safeFile } from '$utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '$utils/common'; import { useSetting } from '$state/hooks/settings'; @@ -1345,10 +1345,8 @@ export const RoomInput = forwardRef( const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication); if (!stickerUrl) return; - const info = getImageInfo( - await loadImageElement(stickerUrl), - await getImageUrlBlob(stickerUrl) - ); + const { blob, image } = await loadImageElementFromMediaUrl(stickerUrl); + const info = getImageInfo(image, blob); const content: StickerEventContent & ReplyEventContent & IContent & IGenericMSC4459 = { body: label, diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 0115bc7cce..c70ff4a0e1 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -35,6 +35,7 @@ export const TimelineFloat = recipe({ export type TimelineFloatVariants = RecipeVariants; export const messageList = style({ overflowY: 'scroll', + touchAction: 'pan-y', scrollbarGutter: 'stable', overflowAnchor: 'none', diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 4234986154..eb9070673f 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -19,7 +19,6 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { SwipeableChatWrapper } from '$components/SwipeableChatWrapper'; -import { BackRouteHandler } from '$components/BackRouteHandler'; import { useOpenRoomSettings } from '$state/hooks/roomSettings'; import { useSpaceOptionally } from '$hooks/useSpace'; import { RoomSettingsPage } from '$state/roomSettings'; @@ -139,70 +138,66 @@ export function RoomView({ eventId }: { eventId?: string }) { const showCallView = !room.isCallRoom() && (callMembers.length > 0 || isJoinedInThisRoom); return ( - - {(onBack) => ( - - - - {showCallView && ( - - - - )} - -
- -
- + + + + {showCallView && ( + + - - {canMessage && delayedEventsSupported && ( - - )} -
- {tombstoneEvent ? ( - +
+ +
+ + + + {canMessage && delayedEventsSupported && ( + + )} +
+ {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + editLastMessageRef.current?.()} /> - ) : ( - <> - {canMessage && ( - editLastMessageRef.current?.()} - /> - )} - {!canMessage && ( - - You do not have permission to post in this room - - )} - )} - {hideReads ? : } -
-
- - - )} - + {!canMessage && ( + + You do not have permission to post in this room + + )} + + )} + {hideReads ? : } +
+
+
+
); } 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 031cb6c971..7b7b3d146a 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -50,7 +50,6 @@ import { mobileOrTablet } from '$utils/user-agent'; import { useUserProfile } from '$hooks/useUserProfile'; import { useRoomMemberHydration } from '$hooks/useRoomMemberHydration'; 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'; @@ -60,6 +59,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; @@ -474,7 +474,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/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 5a991494dc..986d6e0b0c 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -617,7 +617,9 @@ describe('SettingsRoute', () => { getSettingsPath('notifications') ) ); - expect(screen.getByRole('heading', { name: 'Notifications section' })).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: 'Notifications section' }) + ).toBeInTheDocument(); }); it('does not push history when the active section is reselected', async () => { diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx index b410dc04e0..baf195a5d9 100644 --- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx @@ -1,6 +1,7 @@ import { type ChangeEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; import { copyToClipboard, downloadTextFile } from '$utils/dom'; +import { fetch } from '$utils/fetch'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Box, 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..6cc6fe763d --- /dev/null +++ b/src/app/features/settings/desktop/Desktop.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type * as Folds from 'folds'; +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') => readonly [boolean, () => void] + >((key) => { + if (key === 'closeToBackgroundOnClose') return [true, vi.fn<() => void>()] as const; + return [true, vi.fn<() => void>()] as const; + }), + mockUseDesktopSettingsReady: vi.fn<() => boolean>(() => true), + mockUseDesktopSettingsSyncing: vi.fn<() => boolean>(() => false), + mockUseDesktopRuntimeState: vi.fn<() => { trayAvailable: boolean }>(() => ({ + trayAvailable: false, + })), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => true, +})); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: vi.fn<() => string>(() => 'linux'), +})); + +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(normalizeErrorMessage(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; + gatewayUrl?: string; + distributor?: string; + }, + distributorOverride?: string + ): UnifiedPushState => ({ + endpoint: registration.endpoint, + appId: effectivePushTransport.unifiedPushAppID?.trim() ?? DEFAULT_UNIFIED_PUSH_APP_ID, + gatewayUrl: + registration.gatewayUrl ?? effectivePushTransport.unifiedPushGatewayUrl?.trim() ?? undefined, + status: 'registered', + distributor: distributorOverride ?? registration.distributor, + permissionState: 'granted', + }); + + 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(normalizeErrorMessage(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(normalizeErrorMessage(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(normalizeErrorMessage(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 +987,6 @@ export function SystemNotification() { settingsAtom, 'isNotificationSounds' ); - const [backgroundNotificationSounds, setBackgroundNotificationSounds] = useSetting( - settingsAtom, - 'backgroundNotificationSounds' - ); const [showMessageContent, setShowMessageContent] = useSetting( settingsAtom, 'showMessageContentInNotifications' @@ -247,17 +1055,7 @@ export function SystemNotification() { after={} /> - {mobileOrTablet() && ( - - - - )} - {!mobileOrTablet() && ( + {(!mobileOrTablet() || isIosTauri()) && ( - } - /> + - } + title="In-App Notification Sound" + focusId="in-app-notification-sound" + description="Play a sound inside the app when a new message arrives." + after={} /> Promise | void; +}; + +export type TauriNotificationsApi = { + Importance: { + readonly None: 0; + readonly Min: 1; + readonly Low: 2; + readonly Default: 3; + readonly High: 4; + }; + createChannel: (channel: { + id: string; + name: string; + description?: string; + importance?: number; + vibration?: boolean; + }) => Promise; + isPermissionGranted: () => Promise; + requestPermission: () => Promise; + sendNotification: (payload: Record) => Promise; + removeActive: (payload: Array<{ id: number; tag?: string }>) => Promise; + onNotificationReceived: ( + listener: (notification: Record) => void + ) => Promise; + onNotificationClicked: ( + listener: (data: { id: number; data?: Record }) => void + ) => Promise; +}; + +let notificationsApiPromise: Promise | null = null; + +export async function getTauriNotificationsApi(): Promise { + if (!notificationsApiPromise) { + notificationsApiPromise = + import('@choochmeque/tauri-plugin-notifications-api') as unknown as Promise; + } + + return notificationsApiPromise; +} + +let permissionPromise: Promise | null = null; + +export async function ensureTauriNotificationPermission(): Promise { + if (!permissionPromise) { + permissionPromise = (async () => { + const api = await getTauriNotificationsApi(); + if (await api.isPermissionGranted()) return true; + return (await api.requestPermission()) === 'granted'; + })(); + permissionPromise.then( + (granted) => { + if (!granted) permissionPromise = null; + }, + () => { + permissionPromise = null; + } + ); + } + + return permissionPromise; +} + +// Desktop webviews can't show web notifications (WKWebView lacks the API; the +// Linux CEF runtime never grants it), so desktop routes through the native plugin. +const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); +export const isDesktopTauri = (): boolean => isTauri() && DESKTOP_TAURI_OS.has(osType()); +export const isIosTauri = (): boolean => isTauri() && osType() === 'ios'; +// Platforms where OS notifications go through the native plugin instead of web APIs. +export const isNativeNotificationTauri = (): boolean => isDesktopTauri() || isIosTauri(); + +let nativeNotificationSeq = 1; +const nextNativeNotificationId = (): number => { + const id = nativeNotificationSeq; + nativeNotificationSeq = nativeNotificationSeq >= 2_000_000_000 ? 1 : nativeNotificationSeq + 1; + return id; +}; + +export type NativeTauriNotification = { + title: string; + body?: string; + silent?: boolean; + /** Attached to the notification and handed back by onNotificationClicked. */ + extra?: Record; +}; + +export async function sendNativeTauriNotification({ + title, + body, + silent, + extra, +}: NativeTauriNotification): Promise { + if (!(await ensureTauriNotificationPermission())) return; + const api = await getTauriNotificationsApi(); + await api.sendNotification({ + id: nextNativeNotificationId(), + title, + body, + silent: silent ?? false, + extra, + }); +} 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..5594169b20 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushMessageListener.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createUnifiedPushMessageListener, + parseUnifiedPushMessage, +} from './UnifiedPushMessageListener'; + +describe('createUnifiedPushMessageListener', () => { + it('catches rejected payload handlers instead of leaking unhandled rejections', async () => { + const onError = vi.fn<(error: unknown) => void>(); + const listener = createUnifiedPushMessageListener(async () => { + throw new Error('boom'); + }, onError); + + expect(listener({})).toBeUndefined(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + }); +}); + +describe('parseUnifiedPushMessage', () => { + it('unwraps the Matrix notification from the message JSON string', () => { + const raw = { + message: JSON.stringify({ + notification: { type: 'm.room.message', room_id: '!r:server', event_id: '$e' }, + }), + }; + + expect(parseUnifiedPushMessage(raw)).toEqual({ + type: 'm.room.message', + room_id: '!r:server', + event_id: '$e', + }); + }); + + it('returns the payload as-is when there is no notification wrapper', () => { + const raw = { message: JSON.stringify({ event_id: '$e', room_id: '!r:server' }) }; + + expect(parseUnifiedPushMessage(raw)).toEqual({ event_id: '$e', room_id: '!r:server' }); + }); + + it('returns null for a missing or non-JSON message', () => { + expect(parseUnifiedPushMessage({})).toBeNull(); + expect(parseUnifiedPushMessage({ message: 'not json' })).toBeNull(); + }); +}); diff --git a/src/app/features/settings/notifications/UnifiedPushMessageListener.ts b/src/app/features/settings/notifications/UnifiedPushMessageListener.ts new file mode 100644 index 0000000000..3cc5d2431f --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushMessageListener.ts @@ -0,0 +1,30 @@ +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); + }; +} + +export function parseUnifiedPushMessage(raw: unknown): Record | null { + const message = (raw as { message?: unknown })?.message; + if (typeof message !== 'string') return null; + + let payload: unknown; + try { + payload = JSON.parse(message); + } catch { + return null; + } + if (!payload || typeof payload !== 'object') return null; + + const notification = (payload as { notification?: unknown }).notification; + if (notification && typeof notification === 'object') { + return notification as Record; + } + return payload as Record; +} 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..f28ee751a6 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_UNIFIED_PUSH_APP_ID, + disableUnifiedPush, + tryEnableUnifiedPush, +} from './UnifiedPushNotifications'; + +const notificationsApi = vi.hoisted(() => ({ + sendNotification: vi.fn<() => void>(), + removeActive: vi.fn<() => void>(), + createChannel: vi.fn<() => void>(), + Importance: { + Default: 3, + }, +})); + +const unifiedPushTransport = vi.hoisted(() => ({ + getUnifiedPushDistributor: vi.fn<() => void>(), + getUnifiedPushDistributors: vi.fn<() => void>(), + registerUnifiedPushTransport: vi.fn<() => Promise>(), + saveUnifiedPushDistributor: vi.fn<() => void>(), + unregisterUnifiedPushTransport: vi.fn<() => Promise>(), +})); + +const getTauriNotificationsApi = vi.hoisted(() => + vi.fn<() => Promise>().mockResolvedValue(notificationsApi) +); + +const matrixClient = vi.hoisted(() => ({ + setPusher: vi.fn<() => Promise>().mockResolvedValue(undefined), + getDeviceId: vi.fn<() => string>(() => 'DEVICE'), + getDevice: vi + .fn<() => Promise<{ display_name: string }>>() + .mockResolvedValue({ display_name: 'Pixel' }), + getPushers: vi + .fn<() => Promise<{ pushers: Array }>>() + .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', + 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', + }); + + 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..3df707d019 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -0,0 +1,605 @@ +import type { IPusherRequest, MatrixClient } from '$types/matrix-sdk'; +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 { + getUnifiedPushDistributor, + getUnifiedPushDistributors, + registerUnifiedPushTransport, + saveUnifiedPushDistributor, + type UnifiedPushRegistrationResult, + unregisterUnifiedPushTransport, +} from './UnifiedPushTransport'; +import { + createUnifiedPushMessageListener, + parseUnifiedPushMessage, +} from './UnifiedPushMessageListener'; +import { addPluginListener } from '@tauri-apps/api/core'; +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'); + +/** + * Shape of a UnifiedPush payload delivered to the message listener. + * Fields are optional because both rich (full event) and minimal + * (event_id + counts) payloads arrive through the same entry point. + */ +type UnifiedPushPayload = { + type?: string; + content?: Record; + room_id?: string; + room_name?: string; + sender_display_name?: string; + sender?: string; + event_id?: string; + user_id?: string; + counts?: { unread?: number }; + notification?: unknown; + [key: string]: unknown; +}; + +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; + gatewayUrl: string; + distributor: 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 } = registration; + const resolvedConfig = resolveUnifiedPushPusherConfig(config); + const gatewayUrl = resolvedConfig.gatewayUrl ?? UP_PUBLIC_GATEWAY; + + const pusherData: Record = { + url: gatewayUrl, + }; + + 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 unknown as IPusherRequest); + + return { + status: 'registered', + endpoint, + gatewayUrl, + distributor: registration.distributor, + }; +} + +export async function enableUnifiedPush( + mx: MatrixClient, + config?: UnifiedPushTransportConfigInput +): Promise<{ endpoint: 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, + 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; +}; + +const NOTIF_GROUP_KEY = 'matrix_messages'; +const MAX_MESSAGES = 10; + +type NotifPerson = { + name: string; + key?: string; + iconUrl?: string; +}; + +type NotifMessage = { + text: string; + timestamp: number; + sender?: NotifPerson; +}; + +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'); + +type RoomNotifCache = { + roomName: string; + messages: NotifMessage[]; + seenEventIds: Set; + isGroupConversation: boolean; + latestEventId?: string; +}; + +const roomNotifCaches = new Map(); + +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 + } + } +} + +async function postRoomNotification( + roomId: string, + cache: RoomNotifCache, + isSilent: boolean, + extra: Record +) { + const notificationsApi = await getTauriNotificationsApi(); + const { messages, roomName } = cache; + const latestMsg = messages[messages.length - 1]; + const latestBody = latestMsg ? `${latestMsg.sender?.name ?? 'You'}: ${latestMsg.text}` : ''; + + const inboxLines = messages.slice(-5).map((m) => `${m.sender?.name ?? 'You'}: ${m.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, + inboxLines: inboxLines.length > 1 ? inboxLines : undefined, + }); + + 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 summaryLines: string[] = []; + Array.from(roomNotifCaches.values()).forEach((c) => { + const latest = c.messages[c.messages.length - 1]; + if (latest) { + summaryLines.push(`${c.roomName}: ${latest.sender?.name ?? 'You'}: ${latest.text}`); + } + }); + await notificationsApi.sendNotification({ + id: SUMMARY_NOTIF_ID, + title: summaryText, + body: '', + summary: summaryText, + inboxLines: summaryLines.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: UnifiedPushPayload, 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; + + 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: NotifPerson | undefined = senderName + ? { + name: senderName, + key: senderId, + iconUrl: senderId ? resolveAvatarUrl(settings.mx, roomId, senderId) : undefined, + } + : undefined; + + const message: NotifMessage = { + 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, isSilent, { + room_id: roomId, + event_id: pushData?.event_id, + user_id: pushData?.user_id, + }); + 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: UnifiedPushPayload, + 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 sender: NotifPerson | undefined = senderName + ? { + name: senderName, + key: senderId, + iconUrl: senderId && roomId ? resolveAvatarUrl(settings.mx, roomId, senderId) : undefined, + } + : undefined; + + const message: NotifMessage = { + 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, !settings.notificationSoundEnabled, { + room_id: roomId, + event_id: eventId, + }); +} + +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; + } + + const pushData = (raw.extra ?? raw) as UnifiedPushPayload; + const eventType = pushData?.type as EventType | undefined; + + if (eventType) { + await handleRichPushPayload(pushData, settings); + } else { + await handleMinimalPushPayload(pushData, settings); + } +} + +export function listenForUnifiedPushMessages(getSettings: () => NotificationSettings) { + const dispatch = createUnifiedPushMessageListener( + (notification) => handleUnifiedPushPayload(notification, getSettings), + (error) => { + unifiedPushLog.error( + 'notification', + 'UnifiedPush payload handling failed', + error instanceof Error ? error : new Error(String(error)) + ); + } + ); + + return addPluginListener('unifiedpush', 'push-message', (data: unknown) => { + const notification = parseUnifiedPushMessage(data); + if (notification) dispatch(notification); + }); +} 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..872efe7a4e --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + classifyUnifiedPushFailure, + ensureUnifiedPushDistributorSelection, + loadUnifiedPushDistributorState, + registerUnifiedPushTransport, + switchUnifiedPushDistributorSelection, + setUnifiedPushDistributorSelection, +} from './UnifiedPushTransport'; + +const unifiedPushApi = vi.hoisted(() => ({ + isPermissionGranted: vi.fn<() => Promise>(), + requestPermission: vi.fn<() => Promise>(), + registerForPushNotifications: vi.fn<() => Promise>(), + unregisterForPushNotifications: vi.fn<() => Promise>(), + listDistributors: vi.fn<() => Promise>(), + setDistributor: vi.fn<(name: string) => Promise>(), + setToken: vi.fn<(token: string) => Promise>(), +})); + +vi.mock('./UnifiedPushTransportApiClient', () => ({ + getUnifiedPushTransportApi: vi + .fn<() => Promise>() + .mockResolvedValue(unifiedPushApi), +})); + +afterEach(() => { + vi.clearAllMocks(); + localStorage.clear(); +}); + +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'); + localStorage.removeItem('unifiedpush_distributor'); + unifiedPushApi.listDistributors.mockResolvedValue(['org.unifiedpush.distributor.ntfy']); + unifiedPushApi.registerForPushNotifications.mockResolvedValue('https://up.example/endpoint'); + + await expect(registerUnifiedPushTransport()).resolves.toEqual({ + status: 'registered', + permissionState: 'granted', + endpoint: 'https://up.example/endpoint', + distributor: 'org.unifiedpush.distributor.ntfy', + }); + expect(unifiedPushApi.requestPermission).toHaveBeenCalledOnce(); + expect(unifiedPushApi.setDistributor).toHaveBeenCalledOnce(); + expect(unifiedPushApi.registerForPushNotifications).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.registerForPushNotifications).not.toHaveBeenCalled(); + }); + + it('returns missing-distributor without registering when none are available', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + localStorage.removeItem('unifiedpush_distributor'); + unifiedPushApi.listDistributors.mockResolvedValue([]); + + await expect(registerUnifiedPushTransport()).resolves.toEqual({ + status: 'missing-distributor', + permissionState: 'granted', + distributors: [], + error: 'No UnifiedPush distributor installed', + }); + expect(unifiedPushApi.registerForPushNotifications).not.toHaveBeenCalled(); + }); + + it('classifies temporary-unavailable registration failures distinctly', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + localStorage.setItem('unifiedpush_distributor', 'org.example.up'); + unifiedPushApi.listDistributors.mockResolvedValue(['org.example.up']); + unifiedPushApi.registerForPushNotifications.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); + localStorage.setItem('unifiedpush_distributor', 'org.example.up'); + unifiedPushApi.listDistributors.mockResolvedValue(['org.example.up']); + unifiedPushApi.registerForPushNotifications.mockResolvedValue(''); + + await expect(registerUnifiedPushTransport()).resolves.toMatchObject({ + status: 'hard-failure', + error: 'UnifiedPush registration returned an invalid endpoint', + distributor: 'org.example.up', + }); + }); + + it('treats a blank-only endpoint as a hard failure', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + localStorage.setItem('unifiedpush_distributor', 'org.example.up'); + unifiedPushApi.listDistributors.mockResolvedValue(['org.example.up']); + unifiedPushApi.registerForPushNotifications.mockResolvedValue(' '); + + await expect(registerUnifiedPushTransport()).resolves.toMatchObject({ + status: 'hard-failure', + error: 'UnifiedPush registration returned an invalid endpoint', + distributor: 'org.example.up', + }); + }); +}); + +describe('UnifiedPush distributor state helpers', () => { + it('loads distributor state and auto-saves the sole available distributor', async () => { + localStorage.removeItem('unifiedpush_distributor'); + unifiedPushApi.listDistributors.mockResolvedValue(['org.unifiedpush.distributor.ntfy']); + + await expect(loadUnifiedPushDistributorState()).resolves.toEqual({ + distributors: ['org.unifiedpush.distributor.ntfy'], + selectedDistributor: 'org.unifiedpush.distributor.ntfy', + }); + expect(unifiedPushApi.setDistributor).toHaveBeenCalledOnce(); + }); + + it('drops a stale saved distributor that is no longer installed', async () => { + localStorage.setItem('unifiedpush_distributor', 'org.unifiedpush.distributor.removed'); + unifiedPushApi.listDistributors.mockResolvedValue(['org.unifiedpush.distributor.ntfy']); + + await expect(loadUnifiedPushDistributorState()).resolves.toEqual({ + distributors: ['org.unifiedpush.distributor.ntfy'], + selectedDistributor: 'org.unifiedpush.distributor.ntfy', + }); + expect(unifiedPushApi.setDistributor).toHaveBeenCalledWith('org.unifiedpush.distributor.ntfy'); + }); + + it('ensures a distributor selection by auto-saving the first available distributor', async () => { + unifiedPushApi.setDistributor.mockResolvedValue(undefined); + await expect( + ensureUnifiedPushDistributorSelection( + ['org.unifiedpush.distributor.ntfy', 'org.unifiedpush.distributor.nextpush'], + '' + ) + ).resolves.toBe('org.unifiedpush.distributor.ntfy'); + expect(unifiedPushApi.setDistributor).toHaveBeenCalledOnce(); + }); + + it('replaces a stale selected distributor with the first available one', async () => { + unifiedPushApi.setDistributor.mockResolvedValue(undefined); + await expect( + ensureUnifiedPushDistributorSelection( + ['org.unifiedpush.distributor.ntfy', 'org.unifiedpush.distributor.nextpush'], + 'org.unifiedpush.distributor.removed' + ) + ).resolves.toBe('org.unifiedpush.distributor.ntfy'); + expect(unifiedPushApi.setDistributor).toHaveBeenCalledWith('org.unifiedpush.distributor.ntfy'); + }); + + it('persists a selected distributor through the transport helper', async () => { + unifiedPushApi.setDistributor.mockResolvedValue(undefined); + await expect( + setUnifiedPushDistributorSelection('org.unifiedpush.distributor.nextpush') + ).resolves.toBeUndefined(); + expect(unifiedPushApi.setDistributor).toHaveBeenCalledWith( + 'org.unifiedpush.distributor.nextpush' + ); + }); + + it('returns empty distributors when the backend is unavailable', async () => { + localStorage.removeItem('unifiedpush_distributor'); + unifiedPushApi.listDistributors.mockRejectedValue(new Error('backend unavailable')); + + const result = await loadUnifiedPushDistributorState(); + expect(result.distributors).toEqual([]); + expect(result.selectedDistributor).toBe(''); + }); + + it('restores the previous distributor when a switch registration fails', async () => { + unifiedPushApi.setDistributor.mockResolvedValue(undefined); + const register = vi + .fn<() => Promise>() + .mockRejectedValue(new Error('registration failed')); + + await expect( + switchUnifiedPushDistributorSelection( + 'org.unifiedpush.distributor.ntfy', + 'org.unifiedpush.distributor.nextpush', + register + ) + ).rejects.toThrow('registration failed'); + + expect(unifiedPushApi.setDistributor).toHaveBeenNthCalledWith( + 1, + 'org.unifiedpush.distributor.ntfy' + ); + expect(unifiedPushApi.setDistributor).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..a07261ac39 --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushTransport.ts @@ -0,0 +1,257 @@ +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; + distributor: string; + } + | { + status: 'temp-unavailable'; + permissionState: UnifiedPushPermissionState; + distributor?: string; + error: string; + } + | { + status: 'hard-failure'; + permissionState: UnifiedPushPermissionState; + distributor?: string; + error: string; + } + | { + status: 'missing-distributor'; + permissionState: UnifiedPushPermissionState; + distributors: string[]; + error: string; + distributor?: string; + } + | { + status: 'denied'; + permissionState: Exclude; + error: string; + }; + +const DISTRIBUTUTOR_STORAGE_KEY = 'unifiedpush_distributor'; + +export 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.listDistributors(); +} + +export async function getUnifiedPushDistributor(): Promise<{ distributor: string }> { + const distributor = localStorage.getItem(DISTRIBUTUTOR_STORAGE_KEY) ?? ''; + return { distributor }; +} + +export async function saveUnifiedPushDistributor(distributor: string): Promise { + localStorage.setItem(DISTRIBUTUTOR_STORAGE_KEY, distributor); + const api = await getUnifiedPushTransportApi(); + await api.setDistributor(distributor); +} + +export async function loadUnifiedPushDistributorState(): Promise { + const [distributorResult, distributorsResult] = await Promise.allSettled([ + getUnifiedPushDistributor(), + getUnifiedPushDistributors(), + ]); + + const savedDistributor = + distributorsResult.status === 'fulfilled' + ? distributorResult.status === 'fulfilled' + ? distributorResult.value.distributor + : '' + : ''; + const distributors = distributorsResult.status === 'fulfilled' ? distributorsResult.value : []; + + 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); +} + +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 endpoint = await api.registerForPushNotifications(); + if (!endpoint || !endpoint.trim()) { + return { + status: 'hard-failure', + permissionState: 'granted', + error: 'UnifiedPush registration returned an invalid endpoint', + ...(selectedDistributor ? { distributor: selectedDistributor } : {}), + }; + } + + return { + status: 'registered', + permissionState: 'granted', + endpoint, + distributor, + }; + } catch (error) { + const failureStatus = classifyUnifiedPushFailure(error); + return { + status: failureStatus, + permissionState, + error: normalizeErrorMessage(error), + ...(selectedDistributor ? { distributor: selectedDistributor } : {}), + } as UnifiedPushRegistrationResult; + } +} + +export async function unregisterUnifiedPushTransport(): Promise { + const api = await getUnifiedPushTransportApi(); + await api.unregisterForPushNotifications(); +} diff --git a/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts b/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts new file mode 100644 index 0000000000..9c2b9b739c --- /dev/null +++ b/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts @@ -0,0 +1,22 @@ +export type UnifiedPushTransportApi = { + isPermissionGranted: () => Promise; + requestPermission: () => Promise; + registerForPushNotifications: () => Promise; + unregisterForPushNotifications: () => Promise; + listDistributors: () => Promise; + setDistributor: (name: string) => Promise; + setToken: (token: string) => Promise; +}; + +export async function getUnifiedPushTransportApi(): Promise { + const notificationsApi = await import('@choochmeque/tauri-plugin-notifications-api'); + return { + isPermissionGranted: notificationsApi.isPermissionGranted, + requestPermission: notificationsApi.requestPermission, + registerForPushNotifications: notificationsApi.registerForPushNotifications, + unregisterForPushNotifications: notificationsApi.unregisterForPushNotifications, + listDistributors: notificationsApi.listDistributors, + setDistributor: notificationsApi.setDistributor, + setToken: notificationsApi.setToken, + }; +} 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 saveDownload(params: types.SaveDownloadParams): Promise { + return invoke('save_download', params); +} + +export async function setMediaSession(params: types.SetMediaSessionParams): Promise { + return invoke('set_media_session', params); +} + +export async function setNavigationBarColor(params: types.SetNavigationBarColorParams): Promise { + return invoke('set_navigation_bar_color', params); +} + +export async function setStatusBarColor(params: types.SetStatusBarColorParams): Promise { + return invoke('set_status_bar_color', 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..b0640f018c --- /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..98fb9dcdf8 --- /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..32f5d277bf --- /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-18T21:46:21.964597+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..1f749f5510 --- /dev/null +++ b/src/app/generated/tauri/types.ts @@ -0,0 +1,81 @@ +/** + * Auto-generated TypeScript bindings for Tauri commands + * Generated by tauri-typegen v0.5.0 + * Generated at: 2026-07-18T21:46:21.963724+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 SaveDownloadParams { + filename: string; + bytes: number[]; + [key: string]: unknown; +} + +export interface SetMediaSessionParams { + baseUrl: string; + token: string; + [key: string]: unknown; +} + +export interface SetNavigationBarColorParams { + color: number; + [key: string]: unknown; +} + +export interface SetStatusBarColorParams { + color: number; + [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..fb7a162637 --- /dev/null +++ b/src/app/hooks/useRenderableMediaUrl.test.tsx @@ -0,0 +1,241 @@ +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<() => boolean>(), + hasControllingServiceWorker: vi.fn<() => boolean>(), +})); + +const mediaTransport = vi.hoisted(() => ({ + fetchMediaBlob: vi.fn<(url: string) => Promise>(), + getCurrentMediaSessionScope: vi.fn<() => string>(() => 'anonymous'), +})); + +const tauriApi = vi.hoisted(() => ({ + isTauri: vi.fn<() => boolean>(), + convertFileSrc: vi.fn<(url: string, protocol: string) => string>( + (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<(...args: unknown[]) => void>(), + removeEventListener: vi.fn<(...args: unknown[]) => void>(), + }, + }); + }); + + 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}&__sable_media_cache=2`); + 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}?__sable_media_cache=2`); + 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..07d445fe34 --- /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); + }; + }, [tauri]); + + 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, tauri, 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/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 8e4abb172c..e3f274d8ee 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { startTransition, useCallback } from 'react'; import type { NavigateOptions } from 'react-router-dom'; import { useNavigate } from 'react-router-dom'; import { useAtomValue } from 'jotai'; @@ -28,7 +28,8 @@ export const useRoomNavigate = () => { const navigateSpace = useCallback( (roomId: string) => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); - navigate(getSpacePath(roomIdOrAlias)); + // Render the (heavy) destination off the urgent path so the tap doesn't freeze the UI. + startTransition(() => navigate(getSpacePath(roomIdOrAlias))); }, [mx, navigate] ); @@ -49,19 +50,21 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts + startTransition(() => + navigate( + getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), + opts + ) ); return; } if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + startTransition(() => navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts)); return; } - navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); + startTransition(() => navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts)); }, [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] ); diff --git a/src/app/hooks/useSessionProfiles.test.tsx b/src/app/hooks/useSessionProfiles.test.tsx new file mode 100644 index 0000000000..21b7747151 --- /dev/null +++ b/src/app/hooks/useSessionProfiles.test.tsx @@ -0,0 +1,174 @@ +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<(url: string) => Promise>(), +})); + +vi.mock('$utils/mediaTransport', () => mediaTransport); + +describe('useSessionProfiles', () => { + beforeEach(() => { + vi.resetModules(); + mediaTransport.fetchMediaBlob.mockReset(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + 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..4d0682a4b0 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,7 +1,6 @@ import { lazy, Suspense, useCallback, useMemo, useRef } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { createStore } from 'jotai/vanilla'; -import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; @@ -11,13 +10,14 @@ import type { ClientConfig } from '$hooks/useClientConfig'; import { ClientConfigProvider } from '$hooks/useClientConfig'; import { setMatrixToBase } from '$plugins/matrix-to'; import type { ScreenSize } from '$hooks/useScreenSize'; -import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize'; +import { useScreenSize } from '$hooks/useScreenSize'; import { useCompositionEndTracking } from '$hooks/useComposingCheck'; import { ErrorPage } from '$components/DefaultErrorPage'; 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 +66,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 +81,11 @@ function App() { return ( - - - - - - {renderConfiguredApp} - - - - - + + + {renderConfiguredApp} + + ); } diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index b791bf9066..772e3513b3 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; import { DIRECT_PATH, EXPLORE_PATH, @@ -38,13 +40,20 @@ export function MobileFriendlySidebarNav({ children }: MobileFriendlyClientNavPr export function MobileFriendlyBottomNav({ children }: MobileFriendlyClientNavProps) { const screenSize = useScreenSizeContext(); + const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures'); const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); + const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: false }); + const navigateMatch = useMatch({ path: NAVIGATE_PATH, caseSensitive: true, end: false }); + const profileMatch = useMatch({ path: PROFILE_PATH, caseSensitive: true, end: false }); const settingsMatch = useMatch({ path: '/settings/', caseSensitive: true, end: true }); + const onBarDestination = + homeMatch || directMatch || spaceMatch || inboxMatch || navigateMatch || profileMatch; if ( screenSize !== ScreenSize.Mobile || - (!homeMatch && !directMatch && !spaceMatch) || + (mobileGestures && !inboxMatch && !navigateMatch && !profileMatch) || + !onBarDestination || settingsMatch ) { return null; @@ -58,13 +67,16 @@ type MobileFriendlyPageNavProps = { }; export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavProps) { const screenSize = useScreenSizeContext(); + const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures'); const exactPath = useMatch({ path, caseSensitive: true, end: true, }); - if (screenSize === ScreenSize.Mobile && !exactPath) { + // With mobile gestures on, the list stays mounted so MobileNavDrawer can reveal it as a + // co-present panel. Without gestures, fall back to the route-based single-view behavior. + if (screenSize === ScreenSize.Mobile && !mobileGestures && !exactPath) { return null; } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index fa1444ce2b..4686929ca8 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')} > <> + @@ -247,6 +249,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={HOME_PATH} element={ } + bottomNav={} nav={ @@ -274,6 +278,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={DIRECT_PATH} element={ } + bottomNav={} nav={ @@ -300,6 +306,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={ } + bottomNav={} nav={ @@ -342,6 +350,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={EXPLORE_PATH} element={ } nav={ diff --git a/src/app/pages/TauriDeepLinkBridge.test.tsx b/src/app/pages/TauriDeepLinkBridge.test.tsx new file mode 100644 index 0000000000..bacec3cf22 --- /dev/null +++ b/src/app/pages/TauriDeepLinkBridge.test.tsx @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { rememberTauriSsoNonce } from '$pages/auth/SSOTauri'; +import { mapDeepLinkToLoginPath } from './TauriDeepLinkBridge'; + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => true, +})); + +beforeEach(() => { + localStorage.clear(); +}); + +describe('mapDeepLinkToLoginPath', () => { + it('accepts an SSO callback whose nonce matches the stored one', () => { + rememberTauriSsoNonce('nonce-1'); + const path = mapDeepLinkToLoginPath( + 'sable://login/lp/sso-callback?server=https%3A%2F%2Fhs.example&sso_nonce=nonce-1&loginToken=tok' + ); + expect(path).toContain('loginToken=tok'); + }); + + it('rejects an SSO callback with a mismatched nonce', () => { + rememberTauriSsoNonce('nonce-1'); + expect( + mapDeepLinkToLoginPath('sable://login/lp/sso-callback?sso_nonce=attacker&loginToken=tok') + ).toBeUndefined(); + }); + + it('rejects an SSO callback when no nonce was stored', () => { + expect( + mapDeepLinkToLoginPath('sable://login/lp/sso-callback?sso_nonce=x&loginToken=tok') + ).toBeUndefined(); + }); + + it('passes an OIDC callback through unchanged', () => { + const path = mapDeepLinkToLoginPath('moe.sable.app:/login?code=c1&state=s1'); + expect(path).toContain('code=c1'); + expect(path).toContain('state=s1'); + }); + + it('returns undefined for an unrelated url', () => { + expect(mapDeepLinkToLoginPath('https://example.com/whatever')).toBeUndefined(); + }); +}); diff --git a/src/app/pages/TauriDeepLinkBridge.tsx b/src/app/pages/TauriDeepLinkBridge.tsx new file mode 100644 index 0000000000..e73852b13a --- /dev/null +++ b/src/app/pages/TauriDeepLinkBridge.tsx @@ -0,0 +1,83 @@ +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, + takeTauriSsoNonce, +} from '$pages/auth/SSOTauri'; +import { getLoginPath, withSearchParam } from './pathUtils'; + +const log = createLogger('TauriDeepLinkBridge'); + +export const mapDeepLinkToLoginPath = (rawUrl: string): string | undefined => { + const ssoCallback = parseTauriSsoCallback(rawUrl); + if (ssoCallback) { + const expectedNonce = takeTauriSsoNonce(); + if (!expectedNonce || ssoCallback.nonce !== expectedNonce) { + log.warn('Rejected SSO deep-link callback: missing or mismatched nonce'); + return undefined; + } + 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/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 69e34282f2..ecafc6820d 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -14,8 +14,10 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useOptionalClientConfig } from '$hooks/useClientConfig'; import { getCachedThemeCss, putCachedThemeCss, subscribeThemeCacheUpdates } from '../theme/cache'; +import { applyCspNonce } from '$utils/cspNonce'; import { isLocalImportBundledUrl } from '../theme/localImportUrls'; import { themeCatalogListingBaseUrl } from '../theme/catalogDefaults'; +import { fetch } from '$utils/fetch'; import { THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS, updateInstalledCatalogPackages, @@ -179,6 +181,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { if (!node) { node = document.createElement('style'); node.id = REMOTE_STYLE_ID; + applyCspNonce(node); document.head.appendChild(node); } node.textContent = text; @@ -217,6 +220,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { if (!node) { node = document.createElement('style'); node.id = REMOTE_TWEAKS_STYLE_ID; + applyCspNonce(node); document.head.appendChild(node); } node.textContent = chunks.join('\n\n'); 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/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.test.ts b/src/app/pages/auth/SSOTauri.test.ts new file mode 100644 index 0000000000..377f4680e9 --- /dev/null +++ b/src/app/pages/auth/SSOTauri.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildTauriSsoRedirectUrl, + parseTauriOidcCallback, + parseTauriSsoCallback, + rememberTauriSsoNonce, + takeTauriSsoNonce, +} from './SSOTauri'; + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: vi.fn<() => string>(() => 'android'), +})); + +beforeEach(() => { + localStorage.clear(); +}); + +describe('buildTauriSsoRedirectUrl', () => { + it('embeds the server and stores the same nonce it puts in the url', () => { + const url = new URL(buildTauriSsoRedirectUrl('https://hs.example')); + expect(url.searchParams.get('server')).toBe('https://hs.example'); + const nonce = url.searchParams.get('sso_nonce'); + expect(nonce).toBeTruthy(); + expect(takeTauriSsoNonce()).toBe(nonce); + }); + + it('omits the server when none is provided but still sets a nonce', () => { + const url = new URL(buildTauriSsoRedirectUrl()); + expect(url.searchParams.get('server')).toBeNull(); + expect(url.searchParams.get('sso_nonce')).toBeTruthy(); + }); +}); + +describe('takeTauriSsoNonce', () => { + it('returns the stored nonce once, then undefined', () => { + rememberTauriSsoNonce('abc'); + expect(takeTauriSsoNonce()).toBe('abc'); + expect(takeTauriSsoNonce()).toBeUndefined(); + }); +}); + +describe('parseTauriSsoCallback', () => { + it('round-trips loginToken, server and nonce from a built redirect', () => { + const redirect = buildTauriSsoRedirectUrl('https://hs.example'); + const nonce = takeTauriSsoNonce(); + expect(parseTauriSsoCallback(`${redirect}&loginToken=tok_123`)).toEqual({ + loginToken: 'tok_123', + server: 'https://hs.example', + nonce, + }); + }); + + it('rejects the wrong protocol', () => { + expect(parseTauriSsoCallback('https://login/lp/sso-callback?loginToken=x')).toBeUndefined(); + }); + + it('rejects a missing loginToken', () => { + expect(parseTauriSsoCallback('sable://login/lp/sso-callback?server=x')).toBeUndefined(); + }); +}); + +describe('parseTauriOidcCallback', () => { + it('parses code and state', () => { + expect(parseTauriOidcCallback('moe.sable.app:/login?code=c1&state=s1')).toEqual({ + code: 'c1', + state: 's1', + }); + }); + + it('rejects the wrong path', () => { + expect(parseTauriOidcCallback('moe.sable.app:/other?code=c1&state=s1')).toBeUndefined(); + }); +}); diff --git a/src/app/pages/auth/SSOTauri.ts b/src/app/pages/auth/SSOTauri.ts new file mode 100644 index 0000000000..2a21e4b86e --- /dev/null +++ b/src/app/pages/auth/SSOTauri.ts @@ -0,0 +1,124 @@ +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; + nonce?: string; +}; + +const TAURI_SSO_NONCE_KEY = 'sable:tauri-sso:nonce'; + +export const rememberTauriSsoNonce = (nonce: string): void => { + try { + localStorage.setItem(TAURI_SSO_NONCE_KEY, nonce); + } catch { + // ignore storage failures + } +}; + +export const takeTauriSsoNonce = (): string | undefined => { + try { + const nonce = localStorage.getItem(TAURI_SSO_NONCE_KEY) ?? undefined; + localStorage.removeItem(TAURI_SSO_NONCE_KEY); + return nonce; + } catch { + return undefined; + } +}; + +export const buildTauriSsoRedirectUrl = (server?: string): string => { + const redirectUrl = new URL(SSO_CALLBACK_PATH, getAppBaseUrl()); + + if (server) { + redirectUrl.searchParams.set('server', server); + } + + const nonce = crypto.randomUUID(); + rememberTauriSsoNonce(nonce); + redirectUrl.searchParams.set('sso_nonce', nonce); + + 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, + nonce: callbackUrl.searchParams.get('sso_nonce') ?? 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/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 62d261742e..c9918b3cee 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -45,6 +45,8 @@ import * as Sentry from '@sentry/react'; import { startClient, stopClient } from '$client/initMatrix'; import { SessionOidcTokenRefresher } from '$client/oidcTokenRefresher'; import { mobileOrTablet } from '$utils/user-agent'; +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; const log = createLogger('BackgroundNotifications'); const debugLog = createDebugLogger('BackgroundNotifications'); @@ -52,6 +54,18 @@ const debugLog = createDebugLogger('BackgroundNotifications'); const BACKGROUND_SYNC_POLL_TIMEOUT_MS = 60_000; const BACKGROUND_STAGGER_DELAY_MS = 5_000; +// Desktop webviews can't show web notifications (WKWebView lacks the API; the +// Linux CEF runtime never grants it), so desktop routes through the native plugin. +const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); +const isDesktopTauri = (): boolean => isTauri() && DESKTOP_TAURI_OS.has(osType()); + +let desktopNotificationSeq = 1; +const nextDesktopNotificationId = (): number => { + const id = desktopNotificationSeq; + desktopNotificationSeq = desktopNotificationSeq >= 2_000_000_000 ? 1 : desktopNotificationSeq + 1; + return id; +}; + const isClientReadyForNotifications = (state: SyncState | string | null): boolean => state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup; @@ -201,6 +215,23 @@ export function BackgroundNotifications() { const activeIds = new Set(inactiveSessions.map((s) => s.userId)); async function sendNotification(opts: NotifyOptions): Promise { + if (isDesktopTauri()) { + try { + const { getTauriNotificationsApi } = + await import('$features/settings/notifications/TauriNotificationsApiClient'); + const api = await getTauriNotificationsApi(); + await api.sendNotification({ + id: nextDesktopNotificationId(), + title: opts.title, + body: opts.body, + silent: opts.silent ?? false, + }); + return; + } catch (err) { + log.error('failed to show native desktop notification', err); + // Fall through to the web Notification path as a best-effort fallback. + } + } // Prefer ServiceWorkerRegistration.showNotification so that taps are handled // by the SW notificationclick event. This routes through HandleNotificationClick // (postMessage path) which does the account switch + deep link reliably on all diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 7c0db4677c..b2b0fc6605 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,7 +1,9 @@ import type { ReactNode } from 'react'; import { Box } from 'folds'; import { matchPath, useLocation } from 'react-router-dom'; -import { useScreenSizeContext } from '$hooks/useScreenSize'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; import { SETTINGS_PATH } from '../paths'; import { isShallowSettingsRoute } from './ClientRouteOutlet'; @@ -12,13 +14,16 @@ type ClientLayoutProps = { export function ClientLayout({ nav, children }: ClientLayoutProps) { const location = useLocation(); const screenSize = useScreenSizeContext(); + const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures'); const fullPageSettings = Boolean(matchPath(SETTINGS_PATH, location.pathname)) && !isShallowSettingsRoute(location.pathname, location.state, screenSize); + const railInDrawer = screenSize === ScreenSize.Mobile && mobileGestures; + return ( - {!fullPageSettings && {nav}} + {!fullPageSettings && !railInDrawer && {nav}} {children} ); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index c181d7b067..8f2ab9fbac 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -24,6 +24,13 @@ import LogoHighlightSVG from '$public/res/svg/highlight.svg'; import NotificationSound from '$public/sound/notification.ogg'; import InviteSound from '$public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '$utils/dom'; +import { + getTauriNotificationsApi, + isDesktopTauri, + isIosTauri, + isNativeNotificationTauri, + sendNativeTauriNotification, +} from '$features/settings/notifications/TauriNotificationsApiClient'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { IconSizesProvider } from '$components/icons/phosphor'; @@ -58,15 +65,17 @@ 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 { lastVisitedRoomAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { resolveIncomingCallFromNotificationData } from '$features/call/callNotificationBridge'; import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; import { incomingCallAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; +import { DesktopUpdater } from './DesktopUpdater'; +import { NotificationTransportRuntimeFeature } from '$features/settings/notifications/NotificationTransportRuntimeFeature'; import { UnverifiedNoticeBanner } from '$components/unverified-notice'; +import { getRenderableMediaUrlStats } from '$hooks/useRenderableMediaUrl'; const pushRelayLog = createDebugLogger('push-relay'); @@ -129,14 +138,16 @@ function getUnreadTotals(roomToUnread: RoomToUnread) { return { total, highlightTotal, notification, highlight }; } -// Show the mention count in the tab title. +// Updates document.title with an unread count. function PageTitleUpdater() { const roomToUnread = useAtomValue(roomToUnreadAtom); + const [faviconForMentionsOnly] = useSetting(settingsAtom, 'faviconForMentionsOnly'); useEffect(() => { - const { highlightTotal } = getUnreadTotals(roomToUnread); - document.title = highlightTotal > 0 ? `(${highlightTotal}) Sable Client` : 'Sable Client'; - }, [roomToUnread]); + const { total, highlightTotal } = getUnreadTotals(roomToUnread); + const count = faviconForMentionsOnly ? highlightTotal : total; + document.title = count > 0 ? `(${count}) Sable Client` : 'Sable Client'; + }, [roomToUnread, faviconForMentionsOnly]); return null; } @@ -160,7 +171,13 @@ function FaviconUpdater() { try { // Only badge with highlight (mention) counts — total unread is too noisy // for an OS-level app badge. - if (highlightTotal > 0) { + if (isNativeNotificationTauri()) { + import('@tauri-apps/api/window') + .then(({ getCurrentWindow }) => + getCurrentWindow().setBadgeCount(highlightTotal > 0 ? highlightTotal : undefined) + ) + .catch(() => {}); + } else if (highlightTotal > 0) { navigator.setAppBadge(highlightTotal); } else { navigator.clearAppBadge(); @@ -205,10 +222,20 @@ function InviteNotifications() { const notify = useCallback( (count: number) => { + const body = `You have ${count} new invitation request.`; + if (isNativeNotificationTauri()) { + sendNativeTauriNotification({ + title: 'Invitation', + body, + silent: true, + extra: { type: 'invite' }, + }).catch(() => {}); + return; + } const noti = new window.Notification('Invitation', { icon: LogoSVG, badge: LogoSVG, - body: `You have ${count} new invitation request.`, + body, silent: true, }); @@ -232,8 +259,12 @@ function InviteNotifications() { // SW push (via Sygnal) handles invite notifications when the app is backgrounded. if (document.visibilityState !== 'visible' && usePushNotifications) return; - // OS notification for invites — desktop only. - if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { + // OS notification for invites — desktop, plus iOS while foregrounded (testing). + if ( + (!mobileOrTablet() || isIosTauri()) && + showSystemNotifications && + (isNativeNotificationTauri() || notificationPermission('granted')) + ) { try { notify(invites.length - perviousInviteLen); } catch { @@ -439,7 +470,11 @@ function MessageNotifications() { // in sandboxed environments, browsers with DnD active, or Electron — and // an uncaught exception here would abort the handler before setInAppBanner // is reached, causing in-app notifications to silently vanish too. - if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { + if ( + (!mobileOrTablet() || isIosTauri()) && + showSystemNotifications && + (isNativeNotificationTauri() || notificationPermission('granted')) + ) { try { const isEncryptedRoom = !!getStateEvent(room, EventType.RoomEncryption); const avatarMxc = @@ -463,17 +498,30 @@ function MessageNotifications() { silent: !notificationSound || !isLoud, eventId, }); - const noti = new window.Notification(osPayload.title, osPayload.options); - const { roomId } = room; - noti.addEventListener('click', () => { - window.focus(); - setPending({ - roomId, - eventId, - targetSessionId: mx.getUserId() ?? undefined, + if (isNativeNotificationTauri()) { + const extra: Record = { type: mEvent.getType(), room_id: room.roomId }; + if (eventId) extra.event_id = eventId; + const userId = mx.getUserId(); + if (userId) extra.user_id = userId; + sendNativeTauriNotification({ + title: osPayload.title, + body: osPayload.options.body, + silent: osPayload.options.silent ?? false, + extra, + }).catch(() => {}); + } else { + const noti = new window.Notification(osPayload.title, osPayload.options); + const { roomId } = room; + noti.addEventListener('click', () => { + window.focus(); + setPending({ + roomId, + eventId, + targetSessionId: mx.getUserId() ?? undefined, + }); + noti.close(); }); - noti.close(); - }); + } } catch { // window.Notification unavailable or blocked (sandboxed context, DnD, etc.) } @@ -611,7 +659,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); @@ -755,7 +803,7 @@ function SlidingSyncActiveRoomSubscriber() { function SentryRoomContextFeature() { const mx = useMatrixClient(); const mDirect = useAtomValue(mDirectAtom); - const roomId = useAtomValue(lastVisitedRoomIdAtom); + const roomId = useAtomValue(lastVisitedRoomAtom)?.roomId; useEffect(() => { if (!roomId) { @@ -904,9 +952,7 @@ function PresenceFeature() { // Passing undefined restores the default (online); Offline suppresses broadcasting. const syncPresence = sendPresence ? undefined : SetPresence.Offline; mx.setSyncPresence(syncPresence); - const presenceManager = getPresenceSyncManager(mx); - presenceManager?.setPresence(syncPresence); - presenceManager?.setPresenceEnabled(sendPresence); + getPresenceSyncManager(mx)?.setPresenceEnabled(sendPresence); }, [mx, sendPresence]); return null; @@ -917,6 +963,54 @@ function SettingsSyncFeature() { return null; } +// Routes taps on native plugin notifications (desktop + iOS) using the `extra` +// payload attached in sendNativeTauriNotification. +function NativeNotificationClickRouting() { + const setPending = useSetAtom(pendingNotificationAtom); + const navigate = useNavigate(); + + useEffect(() => { + if (!isNativeNotificationTauri()) return undefined; + + let unregister: (() => Promise | void) | undefined; + let disposed = false; + getTauriNotificationsApi() + .then((api) => + api.onNotificationClicked(({ data }) => { + if (isDesktopTauri()) { + import('@tauri-apps/api/window') + .then(({ getCurrentWindow }) => getCurrentWindow().setFocus()) + .catch(() => {}); + } + if (!data) return; + if (data.type === 'invite') { + navigate(getInboxInvitesPath()); + return; + } + if (data.room_id) { + setPending({ + roomId: data.room_id, + eventId: data.event_id, + targetSessionId: data.user_id, + }); + } + }) + ) + .then((listener) => { + if (disposed) listener.unregister(); + else unregister = listener.unregister; + }) + .catch(() => {}); + + return () => { + disposed = true; + unregister?.(); + }; + }, [setPending, navigate]); + + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useIncomingCallSignaling(); return ( @@ -929,7 +1023,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + + + diff --git a/src/app/pages/client/DesktopUpdater.tsx b/src/app/pages/client/DesktopUpdater.tsx new file mode 100644 index 0000000000..fcaa17adb4 --- /dev/null +++ b/src/app/pages/client/DesktopUpdater.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; +import { createLogger } from '$utils/debug'; + +const log = createLogger('DesktopUpdater'); + +const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); +const isDesktopTauri = (): boolean => isTauri() && DESKTOP_TAURI_OS.has(osType()); + +async function checkForDesktopUpdate(): Promise { + const { check } = await import('@tauri-apps/plugin-updater'); + const update = await check(); + if (!update) return; + + log.log(`update ${update.version} available`); + const { ask } = await import('@tauri-apps/plugin-dialog'); + + const shouldInstall = await ask(`Sable ${update.version} is available. Install it now?`, { + title: 'Update available', + kind: 'info', + okLabel: 'Install', + cancelLabel: 'Later', + }); + if (!shouldInstall) return; + + await update.downloadAndInstall(); + + const { relaunch } = await import('@tauri-apps/plugin-process'); + const shouldRestart = await ask('Update installed. Restart Sable to finish?', { + title: 'Restart required', + kind: 'info', + okLabel: 'Restart', + cancelLabel: 'Later', + }); + if (shouldRestart) await relaunch(); +} + +export function DesktopUpdater() { + useEffect(() => { + if (!isDesktopTauri()) return; + checkForDesktopUpdate().catch((err) => log.error('update check failed', err)); + }, []); + + return null; +} diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 65b9b2cd96..b616e0a1ff 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,11 +1,13 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { SyncState } from '$types/matrix-sdk'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Box, config, Line, Text } from 'folds'; import * as Sentry from '@sentry/react'; import { useSyncState } from '$hooks/useSyncState'; import { ContainerColor } from '$styles/ContainerColor.css'; +const DISCONNECTED_GRACE_MS = 2000; + type StateData = { current: SyncState | null; previous: SyncState | null | undefined; @@ -33,6 +35,19 @@ export function SyncStatus({ mx }: SyncStatusProps) { previous: undefined, showConnecting: false, }); + const [showDisconnected, setShowDisconnected] = useState(false); + + const isDisconnected = + stateData.current === SyncState.Reconnecting || stateData.current === SyncState.Error; + + useEffect(() => { + if (!isDisconnected) { + setShowDisconnected(false); + return undefined; + } + const timeoutId = setTimeout(() => setShowDisconnected(true), DISCONNECTED_GRACE_MS); + return () => clearTimeout(timeoutId); + }, [isDisconnected]); useSyncState( mx, @@ -81,7 +96,7 @@ export function SyncStatus({ mx }: SyncStatusProps) { ); } - if (stateData.current === SyncState.Reconnecting) { + if (showDisconnected && stateData.current === SyncState.Reconnecting) { return ( @@ -198,12 +198,13 @@ export function ProfileMobile() { - + {isSettingsOpen && ( - + {menuItems.map((item) => { const IconComponent = item.icon; diff --git a/src/app/pages/client/sessionRefresh.test.ts b/src/app/pages/client/sessionRefresh.test.ts new file mode 100644 index 0000000000..c2beb924c8 --- /dev/null +++ b/src/app/pages/client/sessionRefresh.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SessionsAction } from '$state/sessions'; +import { createSessionRefreshHandler } from './sessionRefresh'; + +describe('createSessionRefreshHandler', () => { + it('updates the session that created the client, not whichever account is active later', () => { + const setSessions = vi.fn<(action: SessionsAction) => void>(); + const pushSession = vi.fn<(baseUrl?: string, accessToken?: string, userId?: string) => void>(); + + 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<(action: SessionsAction) => void>(); + const pushSession = vi.fn<(baseUrl?: string, accessToken?: string, userId?: string) => void>(); + + 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/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index 7bb62c3228..0de6c91a08 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -274,11 +274,11 @@ export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; is after={isOpen && isMobile ? menuIcon(CaretDownIcon) : menuIcon(CaretRightIcon)} style={{ position: 'relative', - background: isMobile - ? color.Background.Container - : isOpen - ? color.Surface.ContainerHover - : color.Surface.Container, + background: isOpen + ? isMobile + ? color.Surface.Container + : color.Surface.ContainerHover + : color.Background.Container, }} onClick={() => isMobile && setIsOpen(!isOpen)} {...hoverProps} @@ -498,11 +498,11 @@ export function PresenceMenuOption({ after={isOpen && isMobile ? menuIcon(CaretDownIcon) : menuIcon(CaretRightIcon)} style={{ position: 'relative', - background: isMobile - ? color.Background.Container - : isOpen - ? color.Surface.ContainerHover - : color.Surface.Container, + background: isOpen + ? isMobile + ? color.Surface.Container + : color.Surface.ContainerHover + : color.Background.Container, }} onClick={() => isMobile && setIsOpen(!isOpen)} {...hoverProps} diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index a7f0f63234..1999b76afa 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -24,7 +24,6 @@ import { import type { VirtualItem } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; import type { MatrixClient, Room, RoomJoinRulesEventContent } from '$types/matrix-sdk'; import { JoinRule, EventType, KnownMembership } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -92,9 +91,6 @@ import { ContainerColor } from '$styles/ContainerColor.css'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { BreakWord } from '$styles/Text.css'; import { InviteUserPrompt } from '$components/invite-user-prompt'; -import { mobileOrTablet } from '$utils/user-agent'; -import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; -import { SwipeableOverlayWrapper } from '$components/SwipeableOverlayWrapper'; import { useCallEmbed } from '$hooks/useCallEmbed'; import { createDebugLogger } from '$utils/debugLogger'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; @@ -111,6 +107,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 +283,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 = useRenderableMediaUrl(rawBannerURI || undefined); const hasBanner = !!(bannerURI && !hideText && showBanners); const [bannerViewerOpen, setBannerViewerOpen] = useState(false); @@ -854,16 +852,6 @@ export function Space() { const getToLink = (roomId: string) => getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); - const navigate = useNavigate(); - const lastRoomId = useAtomValue(lastVisitedRoomIdAtom); - - const handleSwipeToRoom = useCallback(() => { - if (mobileOrTablet() && lastRoomId) { - const roomAliasOrId = getCanonicalAliasOrRoomId(mx, lastRoomId); - navigate(getSpaceRoomPath(spaceIdOrAlias, roomAliasOrId)); - } - }, [lastRoomId, spaceIdOrAlias, mx, navigate]); - const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; @@ -878,146 +866,79 @@ export function Space() { }} > - - - - - {tombstoneEvent && ( - - )} - - - - - - - {menuIcon(Flag, { weight: lobbySelected ? 'fill' : 'regular' })} - - {!hideText && ( - - - Lobby - - - )} - - - - - - - - + + + {tombstoneEvent && ( + + )} + + + + + + - - {menuIcon(MagnifyingGlass, { - weight: searchSelected ? 'fill' : 'regular', - })} - + {menuIcon(Flag, { weight: lobbySelected ? 'fill' : 'regular' })} + + {!hideText && ( - {!hideText && ( - - Message Search - - )} + + Lobby + - - - - - - - {virtualizedItems.map((vItem) => { - const hierarchyItem = hierarchy[vItem.index]; - if (!hierarchyItem) return null; - const { roomId, depth: itemDepth } = hierarchyItem; - const depth = itemDepth ?? 0; - const room = mx.getRoom(roomId); - const renderDepth = room?.isSpaceRoom() ? depth - 2 : depth - 1; - if (!room) return null; - if (depth === subspaceHierarchyLimit && room.isSpaceRoom()) { - return ( - + + + + + + + + -
- -
- - ); - } - - const paddingTop = getCategoryPadding(depth); - const paddingLeft = `calc(${renderDepth} * ${config.space.S400})`; - - if (room.isSpaceRoom()) { - const categoryId = makeNavCategoryId(space.roomId, roomId); - const closedViaCategory = getInClosedCategories(space.roomId, roomId); - - return ( - -
- - - {!hideText && (roomId === space.roomId ? 'Rooms' : room?.name)} - - -
-
- ); - } - + {menuIcon(MagnifyingGlass, { + weight: searchSelected ? 'fill' : 'regular', + })} +
+ + {!hideText && ( + + Message Search + + )} + +
+
+
+
+
+ + {virtualizedItems.map((vItem) => { + const hierarchyItem = hierarchy[vItem.index]; + if (!hierarchyItem) return null; + const { roomId, depth: itemDepth } = hierarchyItem; + const depth = itemDepth ?? 0; + const room = mx.getRoom(roomId); + const renderDepth = room?.isSpaceRoom() ? depth - 2 : depth - 1; + if (!room) return null; + if (depth === subspaceHierarchyLimit && room.isSpaceRoom()) { return ( -
); - })} - {getConnectorSVG(hierarchy, virtualizedItems)} - - {!isMobile &&
} - - - + } + + const paddingTop = getCategoryPadding(depth); + const paddingLeft = `calc(${renderDepth} * ${config.space.S400})`; + + if (room.isSpaceRoom()) { + const categoryId = makeNavCategoryId(space.roomId, roomId); + const closedViaCategory = getInClosedCategories(space.roomId, roomId); + + return ( + +
+ + + {!hideText && (roomId === space.roomId ? 'Rooms' : room?.name)} + + +
+
+ ); + } + + return ( + +
+ +
+
+ ); + })} + {getConnectorSVG(hierarchy, virtualizedItems)} + + {!isMobile &&
} + + {!isMobile && ( 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}`; @@ -165,6 +166,54 @@ export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; export const getInboxBookmarksPath = (): string => INBOX_BOOKMARKS_PATH; +export type SectionNav = { + /** Stable key identifying the section, used to scope the last-visited room. */ + key: string; + /** Path to the section's bare list route. */ + listPath: string; + /** Builds the path to a room within this section, or null when the section has no rooms. */ + getRoomPath: ((roomIdOrAlias: string) => string) | null; +}; + +/** + * Resolves the navigable section for a pathname. `SPACE_PATH` is a catch-all first + * segment, so the literal sections (home, direct, explore, inbox) must be matched + * before falling back to a space. Returns null for unmatched paths. + */ +export const resolveSection = (pathname: string): SectionNav | null => { + if (matchPath({ path: HOME_PATH, end: false }, pathname)) { + return { + key: 'home', + listPath: getHomePath(), + getRoomPath: getHomeRoomPath, + }; + } + if (matchPath({ path: DIRECT_PATH, end: false }, pathname)) { + return { + key: 'direct', + listPath: getDirectPath(), + getRoomPath: getDirectRoomPath, + }; + } + if (matchPath({ path: EXPLORE_PATH, end: false }, pathname)) { + return { key: 'explore', listPath: getExplorePath(), getRoomPath: null }; + } + if (matchPath({ path: INBOX_PATH, end: false }, pathname)) { + return { key: 'inbox', listPath: getInboxPath(), getRoomPath: null }; + } + const spaceMatch = matchPath({ path: SPACE_PATH, end: false }, pathname); + const encodedSpaceId = spaceMatch?.params.spaceIdOrAlias; + if (encodedSpaceId) { + const spaceId = decodeURIComponent(encodedSpaceId); + return { + key: `space:${spaceId}`, + listPath: getSpacePath(spaceId), + getRoomPath: (roomIdOrAlias) => getSpaceRoomPath(spaceId, roomIdOrAlias), + }; + } + return null; +}; + export const getSettingsPath = (section?: string, focus?: string): string => { const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null })); if (!focus) return path; 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/pdfjs-dist.ts b/src/app/plugins/pdfjs-dist.ts index 9a67be484c..fee53536d3 100644 --- a/src/app/plugins/pdfjs-dist.ts +++ b/src/app/plugins/pdfjs-dist.ts @@ -41,11 +41,11 @@ export const createPage = async ( canvas.width = pageViewport.width; canvas.height = pageViewport.height; - page.render({ + await page.render({ canvas, canvasContext: context, viewport: pageViewport, - }); + }).promise; return canvas; }; 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..cc22198e6f 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 = ( @@ -877,6 +880,12 @@ export const getReactCustomHtmlParser = ( ); } + // Unresolved non-mxc src (e.g. javascript: URLs that bypassed the + // sanitiser) must never render as an ; fall back to the label. + if (!src.startsWith('mxc://')) { + return {fallbackLabel}; + } + if ('data-mx-emoticon' in props) { // When the mxc URL can't be resolved (e.g. federation unavailable), // fall back to rendering the shortcode text so the message stays readable. diff --git a/src/app/state/desktopSettings.test.ts b/src/app/state/desktopSettings.test.ts new file mode 100644 index 0000000000..424218eac0 --- /dev/null +++ b/src/app/state/desktopSettings.test.ts @@ -0,0 +1,245 @@ +import { createStore } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DesktopRuntimeState, SyncDesktopSettingsParams } from '$generated/tauri/types'; +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<() => Promise>(), + mockEntries: vi.fn<() => Promise>>(), + mockGetDesktopRuntimeState: vi + .fn<() => Promise>() + .mockResolvedValue({ trayAvailable: true }), + mockSet: vi.fn<(key: string, value: unknown) => Promise>(), + mockSyncDesktopSettings: vi + .fn<(params: SyncDesktopSettingsParams) => Promise>() + .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..ca2f86f94e --- /dev/null +++ b/src/app/state/hooks/desktopSettings.test.tsx @@ -0,0 +1,134 @@ +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 type { DesktopRuntimeState, SyncDesktopSettingsParams } from '$generated/tauri/types'; +import { desktopSettingsReadyAtom } from '../desktopSettings'; +import { useDesktopSetting } from './desktopSettings'; + +const { mockEntries, mockGetDesktopRuntimeState, mockSet, mockSyncDesktopSettings } = vi.hoisted( + () => ({ + mockEntries: vi.fn<() => Promise>>(), + mockGetDesktopRuntimeState: vi + .fn<() => Promise>() + .mockResolvedValue({ trayAvailable: true }), + mockSet: vi.fn<(key: string, value: unknown) => Promise>(), + mockSyncDesktopSettings: vi + .fn<(params: SyncDesktopSettingsParams) => Promise>() + .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/room/lastRoom.ts b/src/app/state/room/lastRoom.ts index e3f7d1c616..6f5470422e 100644 --- a/src/app/state/room/lastRoom.ts +++ b/src/app/state/room/lastRoom.ts @@ -1,6 +1,12 @@ import { atom } from 'jotai'; +export type LastVisitedRoom = { + /** Section key the room was open under (see `resolveSection`). */ + section: string; + roomId: string; +}; + // This is only used for mobile swipe gestures // It is not particularly accurate and shouldn't be used for much else // unless you plan major refractors -export const lastVisitedRoomIdAtom = atom(undefined); +export const lastVisitedRoomAtom = atom(undefined); 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/toast.ts b/src/app/state/toast.ts new file mode 100644 index 0000000000..612316c74a --- /dev/null +++ b/src/app/state/toast.ts @@ -0,0 +1,37 @@ +import { useSyncExternalStore } from 'react'; + +export type ToastMessage = { id: number; text: string }; + +let current: ToastMessage | null = null; +let counter = 0; +let timer: ReturnType | undefined; +const listeners = new Set<() => void>(); + +const notify = (): void => { + listeners.forEach((listener) => listener()); +}; + +export const showToast = (text: string, durationMs = 3000): void => { + counter += 1; + current = { id: counter, text }; + notify(); + + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + current = null; + timer = undefined; + notify(); + }, durationMs); +}; + +const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const getSnapshot = (): ToastMessage | null => current; + +export const useToastMessage = (): ToastMessage | null => + useSyncExternalStore(subscribe, getSnapshot, getSnapshot); 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..f11bf43773 --- /dev/null +++ b/src/app/styles/edgeToEdgeInsets.test.ts @@ -0,0 +1,91 @@ +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(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/theme/cache.ts b/src/app/theme/cache.ts index 59b4519bfa..c827049ccc 100644 --- a/src/app/theme/cache.ts +++ b/src/app/theme/cache.ts @@ -1,4 +1,5 @@ const DB_NAME = 'sable-theme-cache'; +import { fetch } from '$utils/fetch'; const DB_VERSION = 1; const STORE = 'themes'; const UPDATE_CHANNEL = 'sable-theme-cache-updates'; diff --git a/src/app/theme/catalog.ts b/src/app/theme/catalog.ts index 05bc743766..1af77f96b8 100644 --- a/src/app/theme/catalog.ts +++ b/src/app/theme/catalog.ts @@ -1,4 +1,5 @@ import { parseGithubRawBaseUrl, rawFileUrl, type GithubRawParts } from './githubRaw'; +import { fetch } from '$utils/fetch'; export type GithubContentItem = { name: string; diff --git a/src/app/theme/migrateLegacyThemes.ts b/src/app/theme/migrateLegacyThemes.ts index e6bd33b37b..123b5feeee 100644 --- a/src/app/theme/migrateLegacyThemes.ts +++ b/src/app/theme/migrateLegacyThemes.ts @@ -1,6 +1,7 @@ import { ThemeKind } from '$hooks/useTheme'; import type { Settings, ThemeRemoteFavorite } from '$state/settings'; import { trimTrailingSlash } from '$utils/common'; +import { fetch } from '$utils/fetch'; import { putCachedThemeCss } from './cache'; import { DEFAULT_THEME_CATALOG_BASE } from './catalogDefaults'; diff --git a/src/app/theme/processThemeImport.ts b/src/app/theme/processThemeImport.ts index effe96770e..3dab60aa4a 100644 --- a/src/app/theme/processThemeImport.ts +++ b/src/app/theme/processThemeImport.ts @@ -1,4 +1,5 @@ import { ThemeKind } from '$hooks/useTheme'; +import { fetch } from '$utils/fetch'; import { putCachedThemeCss } from './cache'; import { diff --git a/src/app/theme/themeColorMeta.test.ts b/src/app/theme/themeColorMeta.test.ts new file mode 100644 index 0000000000..e6683e386f --- /dev/null +++ b/src/app/theme/themeColorMeta.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { updateThemeColorMeta } from './themeColorMeta'; + +const themeColorMetas = () => + Array.from(document.head.querySelectorAll('meta[name="theme-color"]')); + +const onlyMeta = (): HTMLMetaElement => { + const metas = themeColorMetas(); + expect(metas).toHaveLength(1); + return metas[0]!; +}; + +afterEach(() => { + themeColorMetas().forEach((node) => node.remove()); +}); + +describe('updateThemeColorMeta', () => { + it('replaces static theme-color tags with a single dynamic tag', () => { + const staticLight = document.createElement('meta'); + staticLight.setAttribute('name', 'theme-color'); + staticLight.setAttribute('media', '(prefers-color-scheme: light)'); + staticLight.setAttribute('content', '#ffffff'); + document.head.appendChild(staticLight); + + updateThemeColorMeta('rgb(18, 52, 86)'); + + const meta = onlyMeta(); + expect(meta.hasAttribute('media')).toBe(false); + expect(meta.getAttribute('content')).toBe('rgb(18, 52, 86)'); + }); + + it('reuses the dynamic tag on later changes', () => { + updateThemeColorMeta('#111111'); + updateThemeColorMeta('#222222'); + + expect(onlyMeta().getAttribute('content')).toBe('#222222'); + }); + + it('leaves existing tags untouched when no color is given', () => { + const staticDark = document.createElement('meta'); + staticDark.setAttribute('name', 'theme-color'); + staticDark.setAttribute('content', '#1b1a21'); + document.head.appendChild(staticDark); + + updateThemeColorMeta(undefined); + + expect(onlyMeta().getAttribute('content')).toBe('#1b1a21'); + }); +}); diff --git a/src/app/theme/themeColorMeta.ts b/src/app/theme/themeColorMeta.ts new file mode 100644 index 0000000000..e9e1b81b46 --- /dev/null +++ b/src/app/theme/themeColorMeta.ts @@ -0,0 +1,21 @@ +const DYNAMIC_ATTR = 'data-sable-dynamic'; + +// Mirror the current top-bar color into a runtime theme-color meta. +export function updateThemeColorMeta(color: string | undefined): void { + if (!color) return; + + document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { + if (!node.hasAttribute(DYNAMIC_ATTR)) node.remove(); + }); + + let meta = document.head.querySelector( + `meta[name="theme-color"][${DYNAMIC_ATTR}]` + ); + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + meta.setAttribute(DYNAMIC_ATTR, ''); + document.head.appendChild(meta); + } + meta.setAttribute('content', color); +} diff --git a/src/app/utils/cspNonce.ts b/src/app/utils/cspNonce.ts new file mode 100644 index 0000000000..b3af90761d --- /dev/null +++ b/src/app/utils/cspNonce.ts @@ -0,0 +1,15 @@ +// Tauri adds a nonce to style-src, which makes strict CSP engines (Android WebView) +// drop runtime-injected