diff --git a/.cargo/config.toml b/.cargo/config.toml index 36120f0..d1ef850 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,6 +2,10 @@ # one disposable build tree. Cargo resolves this relative path to /target. [build] target-dir = "target" +# Compiling Slint's generated library and test harness together can exhaust +# memory on a 16 GiB development host, including editor background checks. +# Larger builders can opt into more parallelism with Cargo's -j option. +jobs = 1 # Slint's desktop guide recommends matching Linux's larger main-thread stack on # Windows. This prevents stack overflows in debug builds with deep UI trees. @@ -10,3 +14,11 @@ rustflags = ["-C", "link-arg=/STACK:8000000"] [target.aarch64-pc-windows-msvc] rustflags = ["-C", "link-arg=/STACK:8000000"] + +# NDK r27 and newer devices must agree on 16 KiB ELF load alignment. This also +# remains compatible with 4 KiB Android devices and matches bundled PDFium. +[target.aarch64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.x86_64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml index 19738ae..d193a1b 100644 --- a/.github/workflows/native-platforms.yml +++ b/.github/workflows/native-platforms.yml @@ -27,6 +27,15 @@ on: description: Also build an experimental unsigned iOS archive type: boolean default: false + pull_request: + paths: + - ".github/workflows/native-platforms.yml" + - "Cargo.lock" + - "Cargo.toml" + - "build.rs" + - "packaging/**" + - "resources/**" + - "scripts/**" push: tags: - "v*.*.*" @@ -36,7 +45,7 @@ permissions: concurrency: group: native-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always @@ -84,7 +93,7 @@ jobs: linux: needs: metadata - if: github.event_name == 'push' || inputs.include_linux + if: github.event_name == 'push' || github.event_name == 'pull_request' || inputs.include_linux name: Linux packages (x64) runs-on: ubuntu-22.04 env: @@ -133,19 +142,22 @@ jobs: # broken URLs. Flathub performs its own online validation on submit. appstreamcli validate --no-net resources/com.flectar.mail.metainfo.xml - - name: Build AppImage + - name: Build release executable env: - APPIMAGE_EXTRACT_AND_RUN: "1" FLECTAR_GOOGLE_DESKTOP_CLIENT_ID: ${{ secrets.FLECTAR_GOOGLE_DESKTOP_CLIENT_ID }} FLECTAR_GOOGLE_DESKTOP_CLIENT_SECRET: ${{ secrets.FLECTAR_GOOGLE_DESKTOP_CLIENT_SECRET }} FLECTAR_MICROSOFT_DESKTOP_CLIENT_ID: ${{ secrets.FLECTAR_MICROSOFT_DESKTOP_CLIENT_ID }} + run: cargo build --locked --release --no-default-features --features remote-content --bin flectar-mail + + - name: Build AppImage + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + FLECTAR_SKIP_BUILD: "1" run: ./scripts/build-appimage.sh - name: Build Debian package env: - FLECTAR_GOOGLE_DESKTOP_CLIENT_ID: ${{ secrets.FLECTAR_GOOGLE_DESKTOP_CLIENT_ID }} - FLECTAR_GOOGLE_DESKTOP_CLIENT_SECRET: ${{ secrets.FLECTAR_GOOGLE_DESKTOP_CLIENT_SECRET }} - FLECTAR_MICROSOFT_DESKTOP_CLIENT_ID: ${{ secrets.FLECTAR_MICROSOFT_DESKTOP_CLIENT_ID }} + FLECTAR_SKIP_BUILD: "1" run: ./scripts/build-deb.sh - name: Benchmark startup and idle resources @@ -165,6 +177,137 @@ jobs: target/startup-benchmark/*.json if-no-files-found: error + rpm: + needs: metadata + if: github.event_name == 'push' || github.event_name == 'pull_request' || inputs.include_linux + name: Fedora RPM preview (x64) + runs-on: ubuntu-24.04 + container: fedora:44 + env: + SOURCE_DATE_EPOCH: ${{ needs.metadata.outputs.source_date_epoch }} + steps: + - name: Install Fedora build dependencies + run: | + dnf install -y \ + appstream \ + dbus-daemon \ + dbus-devel \ + desktop-file-utils \ + findutils \ + fontconfig-devel \ + gcc \ + gcc-c++ \ + git \ + libX11-devel \ + libXcursor-devel \ + libxcb-devel \ + libxkbcommon-devel \ + libxkbcommon-x11-devel \ + make \ + pkgconf-pkg-config \ + python3 \ + rpm-build \ + tar \ + wayland-devel \ + xorg-x11-server-Xvfb + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + cache-on-failure: true + key: native-fedora-rpm-x64 + - name: Build Fedora release executable + env: + FLECTAR_GOOGLE_DESKTOP_CLIENT_ID: ${{ secrets.FLECTAR_GOOGLE_DESKTOP_CLIENT_ID }} + FLECTAR_GOOGLE_DESKTOP_CLIENT_SECRET: ${{ secrets.FLECTAR_GOOGLE_DESKTOP_CLIENT_SECRET }} + FLECTAR_MICROSOFT_DESKTOP_CLIENT_ID: ${{ secrets.FLECTAR_MICROSOFT_DESKTOP_CLIENT_ID }} + run: cargo build --locked --release --no-default-features --features remote-content --bin flectar-mail + - name: Build and inspect RPM + env: + FLECTAR_SKIP_BUILD: "1" + run: | + ./scripts/build-rpm.sh + rpm -qip target/rpm/flectar-mail.rpm + rpm -qlp target/rpm/flectar-mail.rpm + - name: Test RPM installation and removal + run: | + dnf install -y target/rpm/flectar-mail.rpm + test -x /usr/bin/flectar-mail + test -f "$(rpm --eval '%{_libdir}')/flectar-mail/libpdfium.so" + desktop-file-validate /usr/share/applications/com.flectar.mail.desktop + appstreamcli validate --no-net /usr/share/metainfo/com.flectar.mail.metainfo.xml + set +e + timeout 15s dbus-run-session -- xvfb-run -a /usr/bin/flectar-mail \ + > target/rpm/launch.log 2>&1 + status=$? + set -e + if [[ "$status" -ne 124 ]]; then + cat target/rpm/launch.log + echo "Installed RPM launch smoke test exited unexpectedly with status $status" >&2 + exit 1 + fi + dnf remove -y flectar-mail + test ! -e /usr/bin/flectar-mail + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: flectar-mail-rpm-x64 + path: target/rpm/flectar-mail.rpm + if-no-files-found: error + + flatpak: + needs: metadata + if: github.event_name == 'push' || github.event_name == 'pull_request' || inputs.include_linux + name: Flatpak preview bundle (x64) + runs-on: ubuntu-24.04 + env: + SOURCE_DATE_EPOCH: ${{ needs.metadata.outputs.source_date_epoch }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Install Flatpak build tools + run: | + sudo apt-get update + sudo apt-get install -y appstream dbus-x11 desktop-file-utils flatpak flatpak-builder xvfb + flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + flatpak install --user --noninteractive -y flathub \ + org.freedesktop.Platform//25.08 \ + org.freedesktop.Sdk//25.08 \ + org.freedesktop.Sdk.Extension.rust-stable//25.08 + - name: Build Flatpak bundle from locked sources + run: ./scripts/build-flatpak.sh + - name: Test Flatpak installation and packaged runtime + env: + EXPECTED_VERSION: ${{ needs.metadata.outputs.version }} + run: | + flatpak install --user --noninteractive -y target/flatpak/flectar-mail.flatpak + flatpak run --command=sh --env=EXPECTED_VERSION="$EXPECTED_VERSION" com.flectar.mail -c ' + test -x /app/bin/flectar-mail + test -f /app/lib/flectar-mail/libpdfium.so + test -x /usr/bin/gpg + test -f /app/share/applications/com.flectar.mail.desktop + test -f /app/share/metainfo/com.flectar.mail.metainfo.xml + ! grep -q X-AppImage /app/share/applications/com.flectar.mail.desktop + grep -q "version=\"$EXPECTED_VERSION\"" /app/share/metainfo/com.flectar.mail.metainfo.xml + ' + flatpak info --user --show-permissions com.flectar.mail + set +e + timeout 15s dbus-run-session -- xvfb-run -a flatpak run com.flectar.mail \ + > target/flatpak/launch.log 2>&1 + status=$? + set -e + if [[ "$status" -ne 124 ]]; then + cat target/flatpak/launch.log + echo "Flatpak launch smoke test exited unexpectedly with status $status" >&2 + exit 1 + fi + flatpak uninstall --user --noninteractive -y com.flectar.mail + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: flectar-mail-flatpak-x64 + path: target/flatpak/flectar-mail.flatpak + if-no-files-found: error + windows: needs: metadata if: github.event_name == 'push' || inputs.include_windows @@ -210,6 +353,8 @@ jobs: New-Item -ItemType Directory -Force target/package/windows | Out-Null New-Item -ItemType Directory -Force "target/package/windows/Licenses/Google Sans Flex" | Out-Null New-Item -ItemType Directory -Force "target/package/windows/Licenses/Noto Emoji" | Out-Null + python scripts/stage-pdfium.py win-x64 target/package/windows + if ($LASTEXITCODE -ne 0) { throw "PDFium staging failed" } Copy-Item target/release/flectar-mail.exe target/package/windows/ Copy-Item README.md target/package/windows/ Copy-Item LICENSE target/package/windows/ @@ -219,6 +364,8 @@ jobs: Copy-Item resources/fonts/google-sans-flex/README.md "target/package/windows/Licenses/Google Sans Flex/" Copy-Item resources/fonts/noto-emoji/OFL.txt "target/package/windows/Licenses/Noto Emoji/" Copy-Item resources/fonts/noto-emoji/README.md "target/package/windows/Licenses/Noto Emoji/" + python scripts/test-pdf-preview.py target/package/windows/flectar-mail.exe + if ($LASTEXITCODE -ne 0) { throw "Packaged PDF preview test failed" } Compress-Archive -Path target/package/windows/* -DestinationPath target/flectar-mail-windows-x64.zip -Force - name: Install pinned Inno Setup compiler @@ -431,15 +578,19 @@ jobs: if: >- !cancelled() && github.event_name == 'push' && github.ref_type == 'tag' && needs.metadata.result == 'success' && needs.quality.result == 'success' && - needs.linux.result == 'success' && needs.windows.result == 'success' && + needs.linux.result == 'success' && needs.rpm.result == 'success' && + needs.flatpak.result == 'success' && needs.windows.result == 'success' && needs.macos.result == 'success' && (needs.android.result == 'success' || (needs.metadata.outputs.prerelease == 'false' && needs.android.result == 'skipped')) - needs: [metadata, quality, linux, windows, macos, android] + needs: [metadata, quality, linux, rpm, flatpak, windows, macos, android] runs-on: ubuntu-24.04 permissions: actions: read + artifact-metadata: write + attestations: write contents: write + id-token: write steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 @@ -455,6 +606,18 @@ jobs: name: flectar-mail-windows-x64 path: release-assets/windows + - name: Download RPM package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: flectar-mail-rpm-x64 + path: release-assets/rpm + + - name: Download Flatpak bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: flectar-mail-flatpak-x64 + path: release-assets/flatpak + - name: Download macOS package uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -473,6 +636,11 @@ jobs: RELEASE_VERSION: ${{ needs.metadata.outputs.version }} run: python3 scripts/prepare-release.py + - name: Attest release downloads + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: dist/* + - name: Publish tagged release env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 46442af..f865de6 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -19,7 +19,9 @@ concurrency: env: CARGO_TERM_COLOR: always - CARGO_BUILD_JOBS: "2" + # Slint's generated application is large; simultaneous lib/test rustc jobs + # can exhaust runner memory before any tests start. + CARGO_BUILD_JOBS: "1" jobs: quality: @@ -34,6 +36,7 @@ jobs: sudo apt-get install -y \ build-essential \ gettext \ + gnupg \ libdbus-1-dev \ libfontconfig1-dev \ libx11-dev \ @@ -77,7 +80,9 @@ jobs: - name: Verify the Blitz patch queue run: bash scripts/blitz-patches.sh verify - name: Test release validation and publication - run: python3 scripts/test-release.py + run: | + python3 scripts/test-release.py + python3 scripts/test-pdf-packaging.py - name: Reject unused direct dependencies run: | cargo install cargo-machete --locked --version 0.9.2 @@ -86,6 +91,8 @@ jobs: run: cargo check --workspace --all-targets --locked - name: Check the capability-minimal application run: cargo check -p flectar-mail --no-default-features --locked + - name: Check the Flatpak application profile + run: cargo check -p flectar-mail --no-default-features --features remote-content,flatpak --locked - name: Check the opt-in GPU renderer run: cargo check -p flectar-mail --no-default-features --features remote-content,gpu-renderer --locked - name: Lint without warnings @@ -103,6 +110,13 @@ jobs: test -z "$(msgattrib --only-obsolete lang/es/LC_MESSAGES/flectar-mail.po)" - name: Run the default test suite run: cargo test --workspace --all-targets --locked + - name: Exercise the mobile PDF renderer with bundled PDFium on the host + run: | + python3 scripts/stage-pdfium.py linux-x64 target/pdfium-mobile-test + python3 scripts/test-pdf-preview.py --fixture "$RUNNER_TEMP/mobile-preview.pdf" + FLECTAR_TEST_PDFIUM="$PWD/target/pdfium-mobile-test/libpdfium.so" \ + FLECTAR_TEST_PDF="$RUNNER_TEMP/mobile-preview.pdf" \ + cargo test -p flectar-mail --lib --locked mobile_native_pages_and_rejection -- --ignored optional-embeddings: if: github.event_name != 'schedule' @@ -113,7 +127,7 @@ jobs: - name: Install native core dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential libdbus-1-dev pkg-config + sudo apt-get install -y build-essential libdbus-1-dev pkg-config gnupg - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: stable @@ -134,6 +148,10 @@ jobs: - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + cache-targets: false + key: quality-rustsec - name: Install pinned cargo-audit run: cargo install cargo-audit --locked --version 0.22.2 - name: Audit the locked dependency graph @@ -141,8 +159,10 @@ jobs: # Slint/Blitz typography dependencies. Deny every new warning while the # upstream replacements are tracked explicitly here. run: | - cargo audit --file Cargo.lock --deny warnings \ - --ignore RUSTSEC-2025-0141 \ - --ignore RUSTSEC-2024-0436 \ - --ignore RUSTSEC-2026-0206 \ - --ignore RUSTSEC-2026-0192 + for lockfile in Cargo.lock platform/android/Cargo.lock; do + cargo audit --file "$lockfile" --deny warnings \ + --ignore RUSTSEC-2025-0141 \ + --ignore RUSTSEC-2024-0436 \ + --ignore RUSTSEC-2026-0206 \ + --ignore RUSTSEC-2026-0192 + done diff --git a/.gitignore b/.gitignore index be2ebca..597ea05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Rust build output and locally packaged artifacts /target/ +/.flatpak-builder/ /libs/ /.artifacts/ /tmp/ @@ -7,6 +8,7 @@ *.AppImage /platform/android/gradle/.gradle/ /platform/android/gradle/app/build/ +/platform/android/gradle/build/ /platform/ios/*.xcodeproj/ # Local OAuth application registrations (see .env.example) diff --git a/Cargo.lock b/Cargo.lock index bdc0edb..5cf64e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1285,6 +1285,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if 1.0.4", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1" +dependencies = [ + "log", + "web-sys", +] + [[package]] name = "const-field-offset" version = "0.2.1" @@ -2270,17 +2290,19 @@ dependencies = [ [[package]] name = "flectar-mail" -version = "0.1.0-alpha.4" +version = "0.1.0-alpha.5" dependencies = [ "anyrender", "anyrender_vello", "anyrender_vello_cpu", "arboard", + "ashpd", "base64 0.22.1", "blitz-dom", "blitz-html", "blitz-paint", "blitz-traits", + "cc", "chrono", "cosmic-text", "cssparser", @@ -2292,11 +2314,15 @@ dependencies = [ "html5ever", "image", "keyboard-types", + "landlock", + "libc", "parley 0.11.1", + "pdfium-render", "percent-encoding", "pulldown-cmark", "reqwest", "rfd", + "seccompiler", "serde", "serde_json", "sha2 0.10.9", @@ -2311,6 +2337,7 @@ dependencies = [ "url", "vello", "webbrowser", + "windows-sys 0.61.2", "winresource", ] @@ -2338,12 +2365,14 @@ dependencies = [ "mail-builder", "mail-parser", "once_cell", + "percent-encoding", "quick-xml 0.42.0", "rand 0.9.5", "regex", "reqwest", "ring", "rusqlite", + "rustix 1.1.4", "rustls", "serde", "serde_json", @@ -4341,6 +4370,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.20", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -4681,6 +4721,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -5647,6 +5693,31 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pdfium-render" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8948a803616a9e936b15a6637af2cd48c5fb8ae0fcdeb3c32eac3a540e255a19" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "bytes", + "chrono", + "console_error_panic_hook", + "console_log", + "itertools 0.14.0", + "js-sys", + "libloading", + "log", + "maybe-owned", + "once_cell", + "utf16string", + "vecmath", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "peniko" version = "0.6.1" @@ -5785,6 +5856,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "piston-float" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" + [[package]] name = "pkg-config" version = "0.3.34" @@ -6870,6 +6947,15 @@ dependencies = [ "tiny-skia 0.11.4", ] +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -8785,6 +8871,15 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" +dependencies = [ + "byteorder", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -8826,6 +8921,15 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vecmath" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +dependencies = [ + "piston-float", +] + [[package]] name = "vello" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 03e7d11..554a6dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ blitz-paint = { path = "crates/blitz-paint" } [package] name = "flectar-mail" -version = "0.1.0-alpha.4" +version = "0.1.0-alpha.5" description = "A native email client built with Rust, Slint, and Blitz" license = "AGPL-3.0-only" repository = "https://github.com/flectar/mail" @@ -51,11 +51,15 @@ gpu-renderer = [ # runtime setting remains off by default. `--no-default-features` omits all # remote body-image loading code for a capability-free minimal build. remote-content = [] +# The Flatpak sandbox omits Slint's legacy StatusNotifierItem integration. +# Native packages retain the system tray and close-to-tray behavior. +flatpak = [] # Slint's documented iOS backend uses Skia. Keep it opt-in so desktop builds # retain the smaller software-rendered dependency set. ios = ["slint/renderer-skia"] [dependencies] +pdfium-render = { version = "=0.9.4", default-features = false, features = ["pdfium_7881", "thread_safe"] } anyrender = "0.13" anyrender_vello = { version = "0.14", optional = true } anyrender_vello_cpu = "0.17" @@ -105,13 +109,22 @@ slint = { version = "1.17", default-features = false, features = [ "unstable-fontique-010", "compat-1-2", ] } -tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "io-util", "fs"] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "io-util", "fs", "process"] } tracing.workspace = true unicode-properties = { version = "0.1", default-features = false, features = ["emoji"] } url = "2" webbrowser = "1.2" +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(target_os = "linux")'.dependencies] +ashpd = { version = "0.11", default-features = false, features = ["tokio"] } +landlock = "0.4.7" +seccompiler = "0.5" slint = { version = "1.17", default-features = false, features = ["backend-winit"] } [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "ios"))'.dependencies] @@ -138,13 +151,14 @@ auto_link_exclude_list = [ # The Fontique aliases deliberately align a feature across Slint's and Blitz's # transitive font stacks. -# `slint-build` and the Windows-only `winresource` are consumed by build.rs. +# `slint-build`, iOS-only `cc`, and Windows-only `winresource` are consumed by build.rs. # These do not appear as normal Rust paths on every host, so dependency # scanners need the explicit exceptions. [package.metadata.cargo-machete] -ignored = ["fontique_0_10", "fontique_0_11", "slint-build", "winresource"] +ignored = ["fontique_0_10", "fontique_0_11", "slint-build", "winresource", "cc"] [build-dependencies] +cc = "1" slint-build = "1.17" winresource = "0.1" diff --git a/README.md b/README.md index 7453804..c8aa63a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,21 @@ fraction of the memory of a typical web-based mail client. - **Native and open source.** Built from the ground up with Rust. It is not a browser wrapped in a window, and it is released under the AGPLv3. +## Files and attachments + +Browse JMAP/WebDAV storage, search mail attachments, keep files offline, and +preview PDFs, images and text. + +## Account signatures and OpenPGP + +Settings → Accounts → Signatures & OpenPGP provides named signatures, separate +new-message and reply defaults, and a composer signature selector. Desktop +OpenPGP/MIME signing and encryption use installed GnuPG 2.x with pinentry for +private-key passphrases. Required encryption blocks delivery when recipient keys +are missing or invalid; protected drafts remain local until Send. + +S/MIME and mobile OpenPGP are not currently supported. + ## Experimental HTML rendering > [!WARNING] @@ -108,7 +123,7 @@ inbox more room when you need it. | Calendar | Contacts | Files (WebDAV/JMAP) | | --- | --- | --- | -| ![Flectar Mail calendar](resources/screenshots/desktop-calendar-light.png) | ![Flectar Mail contacts](resources/screenshots/desktop-contacts-light.png) | **Coming soon** | +| ![Flectar Mail calendar](resources/screenshots/desktop-calendar-light.png) | ![Flectar Mail contacts](resources/screenshots/desktop-contacts-light.png) | ![Flectar Mail files](resources/screenshots/desktop-files-light.png) | ### Made for smaller screens @@ -140,7 +155,8 @@ When preview builds are published, download them from [GitHub Releases](https://github.com/flectar/mail/releases) and look for the **Pre-release** badge: -- **Linux x64:** AppImage or Debian/Ubuntu `.deb` package +- **Linux x64:** AppImage, Debian/Ubuntu `.deb`, Fedora `.rpm`, or a + sideloaded Flatpak preview bundle - **Windows x64:** Setup `.exe` or portable ZIP - **macOS Apple silicon (macOS 14+):** DMG or application ZIP - **Android arm64 (Android 8.0+):** Experimental test APK in prereleases @@ -149,6 +165,20 @@ Windows previews are unsigned; macOS previews are ad-hoc signed and not notarized, so operating-system security prompts are expected. Install updates manually. +The Flatpak preview is provided as a standalone test bundle. It is not yet a +Flathub package and therefore does not receive automatic Flathub updates. Its +close-to-tray option is disabled until the tray backend can use a sandbox-safe +D-Bus name. + +Install the downloaded Linux package with either +`sudo dnf install ./flectar-mail--linux-x64.rpm` or +`flatpak install --user ./flectar-mail--linux-x64.flatpak`. Launch the +Flatpak with `flatpak run com.flectar.mail`. + +Each GitHub release includes `SHA256SUMS` and signed build-provenance +attestations. With GitHub CLI installed, verify a download with +`gh attestation verify --repo flectar/mail`. + Android APK updates require the same signing key; builds without a persistent test key may require uninstalling the previous app, which deletes local app data. @@ -159,6 +189,19 @@ You can also build Flectar Mail from source with the cargo run --bin flectar-mail ``` +Linux desktop OAuth uses the system browser through the desktop portal and +stores refresh credentials through the freedesktop Secret Service. A normal +desktop session therefore needs an `xdg-desktop-portal` backend and a Secret +Service provider such as GNOME Keyring. Release builds stop before opening the +OAuth page when secure credential storage is unavailable, so an authorization +grant can never be completed without a safe place to persist it. + +Podman and Docker development shells commonly have neither the host session +D-Bus nor the host browser's loopback network. Debug builds support that setup +with a clearly marked, owner-only development credential file and an accordion +for pasting the final loopback callback URL. The file backend is excluded from +release builds. + ## Open source, for everyone Flectar Mail is one open-source application. There is no separate community diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1724bb2..816823c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -36,6 +36,23 @@ notices and license texts; the Apache-2.0 license is also included in | percent-encoding | 2.3.2 | [servo/rust-url](https://github.com/servo/rust-url/) | Fragment and mailto decoding | | base64 | 0.22.1 | [marshallpierce/rust-base64](https://github.com/marshallpierce/rust-base64) | Export CSP hash encoding | +## PDF preview + +Desktop, Android, and iOS packages include a non-V8 PDFium runtime from +[bblanchon/pdfium-binaries](https://github.com/bblanchon/pdfium-binaries), built +from [PDFium](https://pdfium.googlesource.com/pdfium/). The selected release and +per-platform SHA-256 digests are pinned in `scripts/stage-pdfium.py`. Every +package retains the archive's `LICENSE`, complete `licenses/` directory, and +build provenance under `pdfium-licenses` beside the private desktop library, +Android assets at `licenses/PDFium`, and iOS bundle resources at `Licenses/PDFium`. +PDFium's third-party components retain their respective licenses. + +The Rust interface is [pdfium-render](https://github.com/ajrcarey/pdfium-render) +0.9.4, used under its Apache-2.0 option. Linux process restrictions use +[Landlock](https://github.com/landlock-lsm/rust-landlock) and +[seccompiler](https://github.com/rust-vmm/seccompiler), under their Apache-2.0 +options. Rust package versions and source checksums are recorded in `Cargo.lock`. + ## Phosphor Icons The bundled SVG icons in `ui/icons/phosphor` are sourced from diff --git a/build.rs b/build.rs index 397c9c1..ed042ba 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,15 @@ fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("ios") { + cc::Build::new() + .file("platform/ios/Documents.m") + .file("platform/ios/PdfPreview.m") + .flag("-fobjc-arc") + .compile("flectar_documents"); + println!("cargo:rustc-link-lib=framework=UIKit"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rerun-if-changed=platform/ios/Documents.m"); + println!("cargo:rerun-if-changed=platform/ios/PdfPreview.m"); + } if std::env::var_os("CARGO_CFG_TARGET_OS").as_deref() == Some(std::ffi::OsStr::new("windows")) { winresource::WindowsResource::new() .set_icon("resources/app-icon/flectar-mail.ico") diff --git a/crates/flectar-mail-core/Cargo.toml b/crates/flectar-mail-core/Cargo.toml index 661f491..14809b1 100644 --- a/crates/flectar-mail-core/Cargo.toml +++ b/crates/flectar-mail-core/Cargo.toml @@ -21,7 +21,7 @@ local-embeddings = [ ] [dependencies] -tokio.workspace = true +tokio = { workspace = true, features = ["process"] } serde.workspace = true serde_json.workspace = true thiserror.workspace = true @@ -63,6 +63,7 @@ keyring = { version = "3", features = ["sync-secret-service", "apple-native", "w base64 = "0.23" sha2 = "0.10" url = "2" +percent-encoding = "2" # CalDAV (HTTP + multistatus XML); rustls keeps the no-native-TLS property reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2", "form", "json", "stream"] } @@ -81,3 +82,6 @@ ring = "0.17" [dev-dependencies] tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "ios"))'.dependencies] +rustix = { version = "1", features = ["fs"] } diff --git a/crates/flectar-mail-core/src/accounts/credentials.rs b/crates/flectar-mail-core/src/accounts/credentials.rs index e1c09c9..e57a0a5 100644 --- a/crates/flectar-mail-core/src/accounts/credentials.rs +++ b/crates/flectar-mail-core/src/accounts/credentials.rs @@ -1,12 +1,16 @@ -//! Secrets live in the OS keyring (Secret Service on Linux), never in SQLite. -//! Keyring entries are keyed "flectar-mail::". +//! Secrets live in the platform credential service and never in SQLite. +//! Debug builds may explicitly inject a plaintext file backend for isolated +//! development containers; release builds never enable that backend. use crate::error::{CoreError, Result}; use std::sync::Arc; #[cfg(not(any(target_os = "android", target_os = "ios")))] const SERVICE: &str = "flectar-mail"; -#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + any(test, debug_assertions) +))] const MAX_INSECURE_CREDENTIAL_FILE_BYTES: u64 = 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -19,6 +23,10 @@ pub enum Slot { /// invalidating accounts connected with the bundled registration. OAuthClientId, OAuthClientSecret, + /// Versioned, atomically replaced OAuth refresh token and application + /// registration. The older individual OAuth slots remain readable for + /// migration and are removed when an account is deleted. + OAuthBundle, /// App-level AI API key (stored under account id 0). AiApiKey, /// Generic-CalDAV app password (Google CalDAV reuses the OAuth tokens). @@ -33,6 +41,7 @@ impl Slot { Slot::AccessToken => "access_token", Slot::OAuthClientId => "oauth_client_id", Slot::OAuthClientSecret => "oauth_client_secret", + Slot::OAuthBundle => "oauth_bundle", Slot::AiApiKey => "ai_api_key", Slot::CaldavPassword => "caldav_password", } @@ -44,7 +53,40 @@ impl Slot { pub trait CredentialStore: Send + Sync { fn store(&self, account_id: i64, slot: Slot, secret: &str) -> Result<()>; fn load(&self, account_id: i64, slot: Slot) -> Result; + fn delete(&self, account_id: i64, slot: Slot) -> Result<()>; fn delete_all(&self, account_id: i64) -> Result<()>; + + /// Verify that this backend can complete a write/read/delete cycle before + /// an external OAuth grant or a remote account check begins. + fn check_available(&self) -> Result<()> { + static PROBE_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + let _guard = PROBE_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let account_id = i64::MIN; + let secret = "flectar-credential-probe-v1"; + self.store(account_id, Slot::Password, secret) + .map_err(as_unavailable)?; + let loaded = self.load(account_id, Slot::Password); + let deleted = self.delete(account_id, Slot::Password); + + let loaded = loaded.map_err(as_unavailable)?; + deleted.map_err(as_unavailable)?; + if loaded != secret { + return Err(CoreError::CredentialStoreUnavailable( + "credential verification returned different data".into(), + )); + } + Ok(()) + } +} + +fn as_unavailable(error: CoreError) -> CoreError { + match error { + CoreError::CredentialStoreUnavailable(_) => error, + other => CoreError::CredentialStoreUnavailable(other.to_string()), + } } pub type CredentialStoreHandle = Arc; @@ -52,6 +94,28 @@ pub type CredentialStoreHandle = Arc; #[derive(Debug, Default)] pub struct SystemCredentialStore; +/// Plaintext credential storage for isolated development containers. It is +/// compiled only for tests and debug builds and must never be used for a +/// distributable application. +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + any(test, debug_assertions) +))] +#[derive(Debug, Clone)] +pub struct DevelopmentFileCredentialStore { + path: std::path::PathBuf, +} + +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + any(test, debug_assertions) +))] +impl DevelopmentFileCredentialStore { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } +} + #[cfg(not(any(target_os = "android", target_os = "ios")))] fn entry(account_id: i64, slot: &Slot) -> Result { let user = format!("{account_id}:{}", slot.as_str()); @@ -76,10 +140,12 @@ const MAX_SECRET_UTF16: usize = usize::MAX; // the primary entry it means the value is split across `:c0..cN` entries. #[cfg(not(any(target_os = "android", target_os = "ios")))] const CHUNK_MARKER: &str = "\u{0}flectar-mail-chunks:"; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +const GENERATION_CHUNK_MARKER: &str = "\u{0}flectar-mail-chunks:v2:"; // Chunk sanity cap; MAX_SECRET_UTF16 * this is far beyond any real secret. #[cfg(not(any(target_os = "android", target_os = "ios")))] -const MAX_CHUNKS: usize = 256; +const MAX_CHUNKS: usize = 512; #[cfg(not(any(target_os = "android", target_os = "ios")))] fn chunk_entry(account_id: i64, slot: &Slot, index: usize) -> Result { @@ -87,6 +153,37 @@ fn chunk_entry(account_id: i64, slot: &Slot, index: usize) -> Result Result { + let user = format!("{account_id}:{}:g{generation}:c{index}", slot.as_str()); + keyring::Entry::new(SERVICE, &user).map_err(Into::into) +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn parse_generation_chunk_header(head: &str) -> Result> { + let Some(header) = head.strip_prefix(GENERATION_CHUNK_MARKER) else { + return Ok(None); + }; + let (generation, count) = header + .split_once(':') + .ok_or_else(|| CoreError::Other("corrupt keyring chunk header".into()))?; + let generation: u8 = generation + .parse() + .map_err(|_| CoreError::Other("corrupt keyring chunk header".into()))?; + let count: usize = count + .parse() + .map_err(|_| CoreError::Other("corrupt keyring chunk header".into()))?; + if generation > 1 || count == 0 || count > MAX_CHUNKS { + return Err(CoreError::Other("corrupt keyring chunk header".into())); + } + Ok(Some((generation, count))) +} + /// Split on char boundaries so no chunk exceeds `max_units` UTF-16 code units /// (what the Windows blob limit counts). Returns a single chunk when it fits. #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -108,25 +205,44 @@ fn split_utf16_chunks(secret: &str, max_units: usize) -> Vec { } /// Delete `:c{index}` entries starting at `start`. Chunks are written -/// contiguously, so stop at the first missing (or unreadable) index. +/// contiguously, so stop at the first missing index and surface storage errors. #[cfg(not(any(target_os = "android", target_os = "ios")))] -fn delete_chunks_from(account_id: i64, slot: &Slot, start: usize) { +fn delete_chunks_from(account_id: i64, slot: &Slot, start: usize) -> Result<()> { for index in start..start + MAX_CHUNKS { - match chunk_entry(account_id, slot, index) { - Ok(e) => match e.delete_credential() { - Ok(()) => {} - _ => break, - }, - Err(_) => break, + match chunk_entry(account_id, slot, index)?.delete_credential() { + Ok(()) => {} + Err(keyring::Error::NoEntry) => break, + Err(error) => return Err(error.into()), } } + Ok(()) } -/// Fallback for machines without a Secret Service (headless boxes, tests): +#[cfg(target_os = "windows")] +fn delete_generation_chunks_from( + account_id: i64, + slot: &Slot, + generation: u8, + start: usize, +) -> Result<()> { + for index in start..start + MAX_CHUNKS { + match generation_chunk_entry(account_id, slot, generation, index)?.delete_credential() { + Ok(()) => {} + Err(keyring::Error::NoEntry) => break, + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +/// Explicit test/debug fallback for machines without a Secret Service: /// FLECTAR_MAIL_CREDENTIALS_INSECURE_FILE= stores secrets as plaintext /// JSON. -/// The OS keyring is always preferred; never set this on a desktop. -#[cfg(not(any(target_os = "android", target_os = "ios")))] +/// Release builds ignore this variable. +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + any(test, debug_assertions) +))] fn insecure_file() -> Option { std::env::var("FLECTAR_MAIL_CREDENTIALS_INSECURE_FILE") .ok() @@ -134,11 +250,17 @@ fn insecure_file() -> Option { .map(std::path::PathBuf::from) } -#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + any(test, debug_assertions) +))] static INSECURE_FILE_LOCK: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(|| std::sync::Mutex::new(())); -#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + any(test, debug_assertions) +))] fn file_map(path: &std::path::Path) -> Result> { use std::io::Read; @@ -159,7 +281,10 @@ fn file_map(path: &std::path::Path) -> Result, @@ -191,23 +316,69 @@ fn file_save( Ok(()) } -#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + any(test, debug_assertions) +))] fn slot_key(account_id: i64, slot: &Slot) -> String { format!("{account_id}:{}", slot.as_str()) } +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + any(test, debug_assertions) +))] +impl CredentialStore for DevelopmentFileCredentialStore { + fn store(&self, account_id: i64, slot: Slot, secret: &str) -> Result<()> { + let _guard = INSECURE_FILE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut map = file_map(&self.path)?; + map.insert(slot_key(account_id, &slot), secret.to_string()); + file_save(&self.path, &map) + } + + fn load(&self, account_id: i64, slot: Slot) -> Result { + let _guard = INSECURE_FILE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + file_map(&self.path)? + .remove(&slot_key(account_id, &slot)) + .ok_or_else(|| CoreError::Auth("no stored credential".into())) + } + + fn delete(&self, account_id: i64, slot: Slot) -> Result<()> { + let _guard = INSECURE_FILE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut map = file_map(&self.path)?; + map.remove(&slot_key(account_id, &slot)); + file_save(&self.path, &map) + } + + fn delete_all(&self, account_id: i64) -> Result<()> { + let _guard = INSECURE_FILE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut map = file_map(&self.path)?; + map.retain(|key, _| !key.starts_with(&format!("{account_id}:"))); + file_save(&self.path, &map) + } +} + /// Keyring calls are blocking (D-Bus on Linux); call from spawn_blocking in async paths. fn system_store(account_id: i64, slot: Slot, secret: &str) -> Result<()> { #[cfg(any(target_os = "android", target_os = "ios"))] { let _ = (account_id, slot, secret); - return Err(CoreError::Keyring( + return Err(CoreError::CredentialStoreUnavailable( "the mobile host did not inject secure credential storage".into(), )); } #[cfg(not(any(target_os = "android", target_os = "ios")))] { + #[cfg(any(test, debug_assertions))] if let Some(path) = insecure_file() { let _guard = INSECURE_FILE_LOCK .lock() @@ -217,25 +388,84 @@ fn system_store(account_id: i64, slot: Slot, secret: &str) -> Result<()> { return file_save(&path, &map); } let chunks = split_utf16_chunks(secret, MAX_SECRET_UTF16); + if chunks.len() > MAX_CHUNKS { + return Err(CoreError::Keyring( + "credential exceeds the platform storage limit".into(), + )); + } if chunks.len() <= 1 { - // Fits in one entry (always the case off Windows). On Windows, clear any - // stale chunks left by a previous oversized value, then store whole. - #[cfg(target_os = "windows")] - delete_chunks_from(account_id, &slot, 0); - return entry(account_id, &slot)? + // Replacing the primary entry commits the new value before stale + // Windows chunks are cleaned up. + let result = entry(account_id, &slot)? .set_password(secret) .map_err(Into::into); + #[cfg(target_os = "windows")] + if result.is_ok() { + for cleanup in [ + delete_chunks_from(account_id, &slot, 0), + delete_generation_chunks_from(account_id, &slot, 0, 0), + delete_generation_chunks_from(account_id, &slot, 1, 0), + ] { + if let Err(error) = cleanup { + tracing::warn!(slot = slot.as_str(), %error, "could not remove stale credential chunks"); + } + } + } + return result; } - // Write data chunks first, header last: a partial write never reassembles. - for (index, part) in chunks.iter().enumerate() { - chunk_entry(account_id, &slot, index)? - .set_password(part) + + #[cfg(target_os = "windows")] + { + // Alternate between two chunk generations. The primary header is + // the commit point, so a crash while writing the inactive + // generation leaves the previously committed value intact. + let current_generation = match entry(account_id, &slot)?.get_password() { + Ok(head) => parse_generation_chunk_header(&head) + .ok() + .flatten() + .map(|(generation, _)| generation), + Err(keyring::Error::NoEntry) => None, + Err(error) => return Err(error.into()), + }; + let target_generation = current_generation.map_or(0, |generation| generation ^ 1); + for (index, part) in chunks.iter().enumerate() { + generation_chunk_entry(account_id, &slot, target_generation, index)? + .set_password(part) + .map_err(CoreError::from)?; + } + delete_generation_chunks_from(account_id, &slot, target_generation, chunks.len())?; + entry(account_id, &slot)? + .set_password(&format!( + "{GENERATION_CHUNK_MARKER}{target_generation}:{}", + chunks.len() + )) .map_err(CoreError::from)?; + + for cleanup in [ + delete_chunks_from(account_id, &slot, 0), + delete_generation_chunks_from(account_id, &slot, target_generation ^ 1, 0), + ] { + if let Err(error) = cleanup { + tracing::warn!(slot = slot.as_str(), %error, "could not remove stale credential chunks"); + } + } + return Ok(()); + } + + #[cfg(not(target_os = "windows"))] + { + // MAX_SECRET_UTF16 is unlimited on this platform, so this is only + // a defensive fallback for an impossible oversized branch. + for (index, part) in chunks.iter().enumerate() { + chunk_entry(account_id, &slot, index)? + .set_password(part) + .map_err(CoreError::from)?; + } + delete_chunks_from(account_id, &slot, chunks.len())?; + entry(account_id, &slot)? + .set_password(&format!("{CHUNK_MARKER}{}", chunks.len())) + .map_err(Into::into) } - delete_chunks_from(account_id, &slot, chunks.len()); - entry(account_id, &slot)? - .set_password(&format!("{CHUNK_MARKER}{}", chunks.len())) - .map_err(Into::into) } } @@ -243,13 +473,14 @@ fn system_load(account_id: i64, slot: Slot) -> Result { #[cfg(any(target_os = "android", target_os = "ios"))] { let _ = (account_id, slot); - return Err(CoreError::Keyring( + return Err(CoreError::CredentialStoreUnavailable( "the mobile host did not inject secure credential storage".into(), )); } #[cfg(not(any(target_os = "android", target_os = "ios")))] { + #[cfg(any(test, debug_assertions))] if let Some(path) = insecure_file() { let _guard = INSECURE_FILE_LOCK .lock() @@ -270,6 +501,29 @@ fn load_current_secret(account_id: i64, slot: &Slot) -> Result> { Err(keyring::Error::NoEntry) => return Ok(None), Err(error) => return Err(error.into()), }; + if let Some((generation, count)) = parse_generation_chunk_header(&head)? { + #[cfg(target_os = "windows")] + { + let mut out = String::new(); + for index in 0..count { + match generation_chunk_entry(account_id, slot, generation, index)?.get_password() { + Ok(secret) => out.push_str(&secret), + Err(keyring::Error::NoEntry) => { + return Err(CoreError::Auth("no stored credential".into())); + } + Err(error) => return Err(error.into()), + } + } + return Ok(Some(out)); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (generation, count); + return Err(CoreError::Other( + "Windows keyring chunks cannot be read on this platform".into(), + )); + } + } let count = head.strip_prefix(CHUNK_MARKER); let Some(count) = count else { return Ok(Some(head)); @@ -277,6 +531,9 @@ fn load_current_secret(account_id: i64, slot: &Slot) -> Result> { let count: usize = count .parse() .map_err(|_| CoreError::Other("corrupt keyring chunk header".into()))?; + if count == 0 || count > MAX_CHUNKS { + return Err(CoreError::Other("corrupt keyring chunk header".into())); + } let mut out = String::new(); for index in 0..count { let chunk = chunk_entry(account_id, slot, index)?; @@ -295,13 +552,14 @@ fn system_delete_all(account_id: i64) -> Result<()> { #[cfg(any(target_os = "android", target_os = "ios"))] { let _ = account_id; - return Err(CoreError::Keyring( + return Err(CoreError::CredentialStoreUnavailable( "the mobile host did not inject secure credential storage".into(), )); } #[cfg(not(any(target_os = "android", target_os = "ios")))] { + #[cfg(any(test, debug_assertions))] if let Some(path) = insecure_file() { let _guard = INSECURE_FILE_LOCK .lock() @@ -316,12 +574,45 @@ fn system_delete_all(account_id: i64) -> Result<()> { Slot::AccessToken, Slot::OAuthClientId, Slot::OAuthClientSecret, + Slot::OAuthBundle, + Slot::AiApiKey, Slot::CaldavPassword, ] { - if let Ok(e) = entry(account_id, &slot) { - let _ = e.delete_credential(); - } - delete_chunks_from(account_id, &slot, 0); + system_delete(account_id, slot)?; + } + Ok(()) + } +} + +fn system_delete(account_id: i64, slot: Slot) -> Result<()> { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = (account_id, slot); + return Err(CoreError::CredentialStoreUnavailable( + "the mobile host did not inject secure credential storage".into(), + )); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + #[cfg(any(test, debug_assertions))] + if let Some(path) = insecure_file() { + let _guard = INSECURE_FILE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut map = file_map(&path)?; + map.remove(&slot_key(account_id, &slot)); + return file_save(&path, &map); + } + match entry(account_id, &slot)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(error) => return Err(error.into()), + } + #[cfg(target_os = "windows")] + { + delete_chunks_from(account_id, &slot, 0)?; + delete_generation_chunks_from(account_id, &slot, 0, 0)?; + delete_generation_chunks_from(account_id, &slot, 1, 0)?; } Ok(()) } @@ -336,6 +627,10 @@ impl CredentialStore for SystemCredentialStore { system_load(account_id, slot) } + fn delete(&self, account_id: i64, slot: Slot) -> Result<()> { + system_delete(account_id, slot) + } + fn delete_all(&self, account_id: i64) -> Result<()> { system_delete_all(account_id) } @@ -368,6 +663,18 @@ pub async fn delete_all_async(store: CredentialStoreHandle, account_id: i64) -> .map_err(|e| CoreError::Other(e.to_string()))? } +pub async fn delete_async(store: CredentialStoreHandle, account_id: i64, slot: Slot) -> Result<()> { + tokio::task::spawn_blocking(move || store.delete(account_id, slot)) + .await + .map_err(|e| CoreError::Other(e.to_string()))? +} + +pub async fn check_available_async(store: CredentialStoreHandle) -> Result<()> { + tokio::task::spawn_blocking(move || store.check_available()) + .await + .map_err(|e| CoreError::Other(e.to_string()))? +} + #[cfg(test)] mod tests { use super::*; @@ -396,6 +703,23 @@ mod tests { assert_eq!(chunks.concat(), secret); } + #[test] + fn generation_chunk_headers_are_strictly_bounded() { + assert_eq!(parse_generation_chunk_header("ordinary").unwrap(), None); + assert_eq!( + parse_generation_chunk_header(&format!("{GENERATION_CHUNK_MARKER}1:3")).unwrap(), + Some((1, 3)) + ); + for header in [ + format!("{GENERATION_CHUNK_MARKER}2:3"), + format!("{GENERATION_CHUNK_MARKER}0:0"), + format!("{GENERATION_CHUNK_MARKER}0:{}", MAX_CHUNKS + 1), + format!("{GENERATION_CHUNK_MARKER}broken"), + ] { + assert!(parse_generation_chunk_header(&header).is_err()); + } + } + #[test] fn insecure_file_is_bounded_strict_and_atomically_replaceable() { let directory = tempfile::tempdir().unwrap(); @@ -422,6 +746,29 @@ mod tests { } } + #[test] + fn development_file_store_round_trips_and_deletes_one_account() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("credentials.json"); + let store = DevelopmentFileCredentialStore::new(&path); + store.store(7, Slot::RefreshToken, "refresh-token").unwrap(); + store.store(8, Slot::Password, "other-account").unwrap(); + assert_eq!(store.load(7, Slot::RefreshToken).unwrap(), "refresh-token"); + + store.delete_all(7).unwrap(); + assert!(store.load(7, Slot::RefreshToken).is_err()); + assert_eq!(store.load(8, Slot::Password).unwrap(), "other-account"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + #[derive(Default)] struct MemoryStore(std::sync::Mutex>); @@ -443,6 +790,11 @@ mod tests { .ok_or_else(|| CoreError::Auth("no stored credential".into())) } + fn delete(&self, account_id: i64, slot: Slot) -> Result<()> { + self.0.lock().unwrap().remove(&(account_id, slot)); + Ok(()) + } + fn delete_all(&self, account_id: i64) -> Result<()> { self.0 .lock() @@ -454,7 +806,10 @@ mod tests { #[tokio::test] async fn injected_store_contract_round_trips_and_deletes_an_account() { - let store: CredentialStoreHandle = Arc::new(MemoryStore::default()); + let memory = Arc::new(MemoryStore::default()); + let store: CredentialStoreHandle = memory.clone(); + check_available_async(store.clone()).await.unwrap(); + assert!(memory.0.lock().unwrap().is_empty()); store_async(store.clone(), 9, Slot::Password, "secret".into()) .await .unwrap(); diff --git a/crates/flectar-mail-core/src/config.rs b/crates/flectar-mail-core/src/config.rs index d13c604..d8b6feb 100644 --- a/crates/flectar-mail-core/src/config.rs +++ b/crates/flectar-mail-core/src/config.rs @@ -34,7 +34,17 @@ impl Paths { )) } - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(target_os = "ios")] + pub fn default_dirs() -> Result { + let root = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| { + crate::error::CoreError::Other("iOS did not supply the application sandbox.".into()) + })?; + Ok(Self::new( + root.join("Library/Application Support/Flectar"), + root.join("Library/Caches/Flectar"), + )) + } + #[cfg(target_os = "android")] pub fn default_dirs() -> Result { Err(crate::error::CoreError::Other( "mobile application storage must be supplied by the platform host".into(), @@ -63,6 +73,18 @@ impl Paths { self.data_dir.join("flectar-calendar.db") } + pub fn files_db_file(&self) -> PathBuf { + self.data_dir.join("flectar-files.db") + } + pub fn files_cache_dir(&self, account_id: i64) -> PathBuf { + self.cache_dir.join("files").join(account_id.to_string()) + } + pub fn files_staging_dir(&self, account_id: i64) -> PathBuf { + self.data_dir + .join("file_transfers") + .join(account_id.to_string()) + } + /// Small, reconstructable projection of the last visible mailbox page. /// The native shell may paint this before the databases finish opening; /// SQLite remains authoritative and replaces it during normal startup. diff --git a/crates/flectar-mail-core/src/db/files_migrations.rs b/crates/flectar-mail-core/src/db/files_migrations.rs new file mode 100644 index 0000000..6595b75 --- /dev/null +++ b/crates/flectar-mail-core/src/db/files_migrations.rs @@ -0,0 +1,24 @@ +//! Independent, versioned metadata store for remote files. Mail attachments +//! remain owned by the mail database and are never copied into this schema. +use crate::error::{CoreError, Result}; +use rusqlite::Connection; +const MIGRATIONS: &[&str] = &[ + include_str!("migrations/files_001.sql"), + include_str!("migrations/files_002.sql"), +]; +pub const LATEST_VERSION: i64 = MIGRATIONS.len() as i64; +pub fn run(conn: &mut Connection) -> Result<()> { + let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; + if version > LATEST_VERSION { + return Err(CoreError::Other( + "Files database was created by a newer application.".into(), + )); + } + for (index, sql) in MIGRATIONS.iter().enumerate().skip(version as usize) { + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.pragma_update(None, "user_version", (index + 1) as i64)?; + tx.commit()?; + } + Ok(()) +} diff --git a/crates/flectar-mail-core/src/db/migrations.rs b/crates/flectar-mail-core/src/db/migrations.rs index a783fda..f6addbe 100644 --- a/crates/flectar-mail-core/src/db/migrations.rs +++ b/crates/flectar-mail-core/src/db/migrations.rs @@ -1,9 +1,12 @@ use crate::error::{CoreError, Result}; use rusqlite::Connection; -// The application is pre-release, so the development schema stays canonical -// instead of carrying forward migrations for profiles that can be recreated. -const MIGRATIONS: &[&str] = &[include_str!("migrations/001_init.sql")]; +// File metadata search is additive and preserves existing mail profiles. +const MIGRATIONS: &[&str] = &[ + include_str!("migrations/001_init.sql"), + include_str!("migrations/002_attachment_files.sql"), + include_str!("migrations/003_attachment_content.sql"), +]; pub const LATEST_VERSION: i64 = MIGRATIONS.len() as i64; pub fn run(conn: &mut Connection) -> Result<()> { @@ -11,7 +14,7 @@ pub fn run(conn: &mut Connection) -> Result<()> { let latest = LATEST_VERSION; if version > latest { return Err(CoreError::Other(format!( - "unsupported pre-release mail database version {version}; recreate the development profile" + "mail database schema {version} requires a newer application; open this profile with a compatible version. Existing data has not been changed." ))); } @@ -45,6 +48,8 @@ mod tests { WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'messages_fts_%' + AND name NOT LIKE 'attachment_files_fts_%' + AND name NOT LIKE 'attachment_text_fts_%' ORDER BY name", ) .unwrap() @@ -94,6 +99,8 @@ mod tests { "accounts", "ai_usage_events", "app_settings", + "attachment_files_fts", + "attachment_text_fts", "attachments", "contact_accounts", "contacts", @@ -128,7 +135,7 @@ mod tests { assert_eq!( conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) .unwrap(), - 1 + LATEST_VERSION ); for forbidden in ["calendar_events", "calendars", "caldav_config"] { @@ -207,11 +214,105 @@ mod tests { } #[test] - fn pre_release_profiles_are_rejected_without_mutation() { + fn attachment_upgrade_preserves_mail_and_reconciles_only_once() { + let mut conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + conn.execute_batch(MIGRATIONS[0]).unwrap(); + conn.pragma_update(None, "user_version", 1).unwrap(); + seed_mail_graph(&conn); + conn.execute_batch( + "INSERT INTO attachments(id,message_id,part_id,filename,mime_type,size,file_path) + VALUES(42,1,'2','Café-budget.txt','text/plain',17,'/private/cached-copy'); + INSERT INTO jmap_sync_state(account_id,mailbox_state,email_state,identity_state) + VALUES(1,'mailboxes-before','emails-before','identities-before');", + ) + .unwrap(); + + run(&mut conn).unwrap(); + let attachment: (i64, String, String, Option) = conn + .query_row( + "SELECT message_id,part_id,file_path,jmap_blob_id FROM attachments WHERE id=42", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .unwrap(); + assert_eq!( + attachment, + (1, "2".into(), "/private/cached-copy".into(), None) + ); + let metadata_matches = |term: &str| { + conn.query_row( + "SELECT count(*) FROM attachment_files_fts WHERE attachment_files_fts MATCH ?1", + [term], + |r| r.get::<_, i64>(0), + ) + .unwrap() + }; + assert_eq!(metadata_matches("cafe"), 1); + assert_eq!(metadata_matches("schema"), 1); + let state: (String, Option, String) = conn.query_row( + "SELECT mailbox_state,email_state,identity_state FROM jmap_sync_state WHERE account_id=1", + [], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?)), + ).unwrap(); + assert_eq!( + state, + ("mailboxes-before".into(), None, "identities-before".into()) + ); + conn.execute_batch( + "UPDATE jmap_sync_state SET email_state='emails-reconciled' WHERE account_id=1; + UPDATE attachments SET jmap_blob_id='blob-1' WHERE id=42; + INSERT INTO attachment_text_fts(rowid,content) VALUES(42,'indexed-content');", + ) + .unwrap(); + // Opening an already-upgraded profile neither resets its cursor nor + // rebuilds/drops downloaded attachment identities or their text index. + run(&mut conn).unwrap(); + assert_eq!( + conn.query_row( + "SELECT email_state FROM jmap_sync_state WHERE account_id=1", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + "emails-reconciled" + ); + assert_eq!( + conn.query_row("SELECT count(*) FROM attachment_text_fts", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + conn.execute( + "UPDATE attachments SET jmap_blob_id='blob-2' WHERE id=42", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row("SELECT count(*) FROM attachment_text_fts", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + conn.execute("DELETE FROM messages WHERE id=1", []).unwrap(); + assert_eq!( + conn.query_row("SELECT count(*) FROM attachment_files_fts", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + assert_eq!( + conn.query_row("PRAGMA integrity_check", [], |r| r.get::<_, String>(0)) + .unwrap(), + "ok" + ); + } + + #[test] + fn newer_profiles_are_rejected_without_mutation() { let mut conn = Connection::open_in_memory().unwrap(); conn.pragma_update(None, "user_version", 28).unwrap(); let error = run(&mut conn).unwrap_err().to_string(); - assert!(error.contains("recreate the development profile")); + assert!(error.contains("requires a newer application")); assert_eq!( conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) .unwrap(), diff --git a/crates/flectar-mail-core/src/db/migrations/002_attachment_files.sql b/crates/flectar-mail-core/src/db/migrations/002_attachment_files.sql new file mode 100644 index 0000000..7344d08 --- /dev/null +++ b/crates/flectar-mail-core/src/db/migrations/002_attachment_files.sql @@ -0,0 +1,30 @@ +-- Search metadata only. Keep the file library independent of message bodies. +CREATE VIRTUAL TABLE attachment_files_fts USING fts5( + filename, media_type, sender, subject, + tokenize = 'unicode61 remove_diacritics 2', prefix = '2 3 4' +); +INSERT INTO attachment_files_fts(rowid, filename, media_type, sender, subject) +SELECT a.id, a.filename, a.mime_type, + COALESCE(m.from_name,'') || ' ' || COALESCE(m.from_addr,''), m.subject +FROM attachments a JOIN messages m ON m.id = a.message_id; +CREATE TRIGGER attachment_files_insert AFTER INSERT ON attachments BEGIN + INSERT INTO attachment_files_fts(rowid, filename, media_type, sender, subject) + SELECT new.id, new.filename, new.mime_type, + COALESCE(m.from_name,'') || ' ' || COALESCE(m.from_addr,''), m.subject + FROM messages m WHERE m.id = new.message_id; +END; +CREATE TRIGGER attachment_files_delete AFTER DELETE ON attachments BEGIN + DELETE FROM attachment_files_fts WHERE rowid = old.id; +END; +CREATE TRIGGER attachment_files_update AFTER UPDATE OF filename,mime_type,message_id ON attachments BEGIN + DELETE FROM attachment_files_fts WHERE rowid = old.id; + INSERT INTO attachment_files_fts(rowid, filename, media_type, sender, subject) + SELECT new.id, new.filename, new.mime_type, + COALESCE(m.from_name,'') || ' ' || COALESCE(m.from_addr,''), m.subject + FROM messages m WHERE m.id = new.message_id; +END; +CREATE TRIGGER attachment_files_message_update AFTER UPDATE OF from_name,from_addr,subject ON messages BEGIN + UPDATE attachment_files_fts + SET sender = COALESCE(new.from_name,'') || ' ' || COALESCE(new.from_addr,''), subject = new.subject + WHERE rowid IN (SELECT id FROM attachments WHERE message_id = new.id); +END; diff --git a/crates/flectar-mail-core/src/db/migrations/003_attachment_content.sql b/crates/flectar-mail-core/src/db/migrations/003_attachment_content.sql new file mode 100644 index 0000000..7219f5f --- /dev/null +++ b/crates/flectar-mail-core/src/db/migrations/003_attachment_content.sql @@ -0,0 +1,13 @@ +-- Attachment identities and text stay in the mail store. +ALTER TABLE attachments ADD COLUMN jmap_blob_id TEXT; +CREATE VIRTUAL TABLE attachment_text_fts USING fts5( + content, tokenize='unicode61 remove_diacritics 2',prefix='2 3 4' +); +CREATE TRIGGER attachment_text_delete AFTER DELETE ON attachments BEGIN + DELETE FROM attachment_text_fts WHERE rowid=old.id; +END; +CREATE TRIGGER attachment_text_invalidate AFTER UPDATE OF jmap_blob_id,part_id,imap_section ON attachments BEGIN + DELETE FROM attachment_text_fts WHERE rowid=old.id; +END; +-- Existing accounts need one header reconciliation to populate part metadata. +UPDATE jmap_sync_state SET email_state=NULL; diff --git a/crates/flectar-mail-core/src/db/migrations/files_001.sql b/crates/flectar-mail-core/src/db/migrations/files_001.sql new file mode 100644 index 0000000..c942699 --- /dev/null +++ b/crates/flectar-mail-core/src/db/migrations/files_001.sql @@ -0,0 +1,75 @@ +CREATE TABLE connections ( + account_id INTEGER PRIMARY KEY, + settings_json TEXT NOT NULL, + selected_space TEXT, + updated_at INTEGER NOT NULL +); +-- One connection selects one authoritative protocol. Switching protocols +-- invalidates its projection; DAV hrefs and JMAP IDs are never guessed equal. +CREATE TABLE spaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES connections(account_id) ON DELETE CASCADE, + remote_id TEXT NOT NULL, + name TEXT NOT NULL, + protocol TEXT NOT NULL CHECK(protocol IN ('jmap','dav')), + state TEXT, + last_synced_at INTEGER, + UNIQUE(account_id, remote_id) +); +CREATE TABLE nodes ( + id INTEGER PRIMARY KEY, + space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + remote_id TEXT NOT NULL, + parent_id TEXT, + name TEXT NOT NULL, + media_type TEXT, + node_json TEXT NOT NULL, + UNIQUE(space_id, remote_id) +); +CREATE INDEX nodes_parent ON nodes(space_id,parent_id,name); +CREATE VIRTUAL TABLE nodes_fts USING fts5(name,media_type,tokenize='unicode61 remove_diacritics 2',prefix='2 3 4'); +CREATE TRIGGER nodes_insert AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts(rowid,name,media_type) VALUES(new.id,new.name,new.media_type); +END; +CREATE TRIGGER nodes_update AFTER UPDATE ON nodes BEGIN + DELETE FROM nodes_fts WHERE rowid=old.id; + INSERT INTO nodes_fts(rowid,name,media_type) VALUES(new.id,new.name,new.media_type); +END; +CREATE TRIGGER nodes_delete AFTER DELETE ON nodes BEGIN + DELETE FROM nodes_fts WHERE rowid=old.id; +END; +CREATE TABLE collections ( + space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + parent_id TEXT NOT NULL, + sync_token TEXT, + query_state TEXT, + complete INTEGER NOT NULL DEFAULT 0, + synced_at INTEGER NOT NULL, + PRIMARY KEY(space_id,parent_id) +); +CREATE TABLE operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES connections(account_id) ON DELETE CASCADE, + space_id INTEGER REFERENCES spaces(id) ON DELETE CASCADE, + action TEXT NOT NULL, + payload_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('queued','running','completed','failed','uncertain','cancelled')), + progress_bytes INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX operations_pending ON operations(account_id,state,id); +CREATE TABLE content_cache ( + space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + remote_id TEXT NOT NULL, + validator TEXT NOT NULL, + relative_path TEXT NOT NULL, + byte_size INTEGER NOT NULL, + accessed_at INTEGER NOT NULL, + PRIMARY KEY(space_id,remote_id) +); + +CREATE TABLE scan_nodes(space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, remote_id TEXT NOT NULL, node_json TEXT NOT NULL, PRIMARY KEY(space_id,remote_id)); + +CREATE TABLE scan_collections(space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,parent_id TEXT NOT NULL,sync_token TEXT,PRIMARY KEY(space_id,parent_id)); diff --git a/crates/flectar-mail-core/src/db/migrations/files_002.sql b/crates/flectar-mail-core/src/db/migrations/files_002.sql new file mode 100644 index 0000000..d5fe092 --- /dev/null +++ b/crates/flectar-mail-core/src/db/migrations/files_002.sql @@ -0,0 +1,19 @@ +ALTER TABLE content_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0,1)); +ALTER TABLE operations RENAME TO operations_old; +DROP INDEX operations_pending; +CREATE TABLE operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES connections(account_id) ON DELETE CASCADE, + space_id INTEGER REFERENCES spaces(id) ON DELETE SET NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('preparing','queued','running','completed','failed','uncertain','cancelled')), + progress_bytes INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +INSERT INTO operations SELECT * FROM operations_old; +DROP TABLE operations_old; +CREATE INDEX operations_pending ON operations(account_id,state,id); +CREATE TABLE IF NOT EXISTS scan_collections(space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,parent_id TEXT NOT NULL,sync_token TEXT,PRIMARY KEY(space_id,parent_id)); diff --git a/crates/flectar-mail-core/src/db/mod.rs b/crates/flectar-mail-core/src/db/mod.rs index 51ae197..a13b81b 100644 --- a/crates/flectar-mail-core/src/db/mod.rs +++ b/crates/flectar-mail-core/src/db/mod.rs @@ -4,6 +4,7 @@ //! plain synchronous rusqlite. pub mod calendar_migrations; +pub mod files_migrations; pub mod migrations; pub mod repo; pub mod snapshot; @@ -26,6 +27,7 @@ const JOB_QUEUE_CAPACITY: usize = 128; enum StoreKind { Mail, Calendar, + Files, } #[derive(Clone)] @@ -75,6 +77,7 @@ fn open_connection(path: &Path, kind: StoreKind) -> Result { // Calendar queries touch a tiny working set compared with mailbox/FTS // scans. A smaller cache avoids paying the mail profile twice. StoreKind::Calendar => (16_777_216i64, 2_048i64), + StoreKind::Files => (33_554_432i64, 4_096i64), }; conn.pragma_update(None, "mmap_size", mmap_size)?; conn.pragma_update(None, "cache_size", -cache_size_kib)?; @@ -163,6 +166,18 @@ impl Db { }) } + pub fn open_files(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + prepare_store(path, StoreKind::Files, "files", files_migrations::run)?; + let write_tx = + spawn_conn_thread(path.to_path_buf(), "files-writer", StoreKind::Files, false)?; + let read_tx = + spawn_conn_thread(path.to_path_buf(), "files-reader", StoreKind::Files, true)?; + Ok(Self { write_tx, read_tx }) + } + async fn call(&self, tx: &mpsc::Sender, f: F) -> Result where T: Send + 'static, diff --git a/crates/flectar-mail-core/src/db/repo/accounts.rs b/crates/flectar-mail-core/src/db/repo/accounts.rs index 38f872b..e0bb39f 100644 --- a/crates/flectar-mail-core/src/db/repo/accounts.rs +++ b/crates/flectar-mail-core/src/db/repo/accounts.rs @@ -4,6 +4,8 @@ use rusqlite::{Connection, OptionalExtension, Row, params}; use super::parse_json_column; +pub const CREDENTIAL_SETUP_STATE: &str = "credential_setup"; + fn account_from_row(row: &Row) -> rusqlite::Result { Ok(Account { id: row.get("id")?, @@ -44,10 +46,12 @@ pub fn list(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, email, display_name, avatar_url, provider, auth_kind, mail_protocol, sync_state, sync_error - FROM accounts ORDER BY sort_order, id", + FROM accounts + WHERE sync_state <> ?1 + ORDER BY sort_order, id", )?; let rows = stmt - .query_map([], account_from_row)? + .query_map(params![CREDENTIAL_SETUP_STATE], account_from_row)? .collect::>>()?; Ok(rows) } @@ -57,10 +61,12 @@ pub fn list_configs(conn: &Connection) -> Result> { "SELECT id, email, display_name, avatar_url, provider, auth_kind, mail_protocol, username, jmap_url, jmap_account_id, imap_host, imap_port, smtp_host, smtp_port, settings_json - FROM accounts ORDER BY sort_order, id", + FROM accounts + WHERE sync_state <> ?1 + ORDER BY sort_order, id", )?; let rows = stmt - .query_map([], config_from_row)? + .query_map(params![CREDENTIAL_SETUP_STATE], config_from_row)? .collect::>>()?; Ok(rows) } @@ -97,6 +103,14 @@ pub fn find_by_email(conn: &Connection, email: &str) -> Result> .optional()?) } +pub fn credential_setup_ids(conn: &Connection) -> Result> { + let mut statement = + conn.prepare("SELECT id FROM accounts WHERE sync_state = ?1 ORDER BY id")?; + Ok(statement + .query_map(params![CREDENTIAL_SETUP_STATE], |row| row.get(0))? + .collect::>>()?) +} + pub struct NewAccount<'a> { pub email: &'a str, pub display_name: Option<&'a str>, @@ -114,14 +128,18 @@ pub struct NewAccount<'a> { } pub fn insert(conn: &Connection, a: &NewAccount) -> Result { + insert_with_sync_state(conn, a, "idle") +} + +pub fn insert_with_sync_state(conn: &Connection, a: &NewAccount, sync_state: &str) -> Result { let settings_json = serde_json::to_string(&AccountSettings::default())?; conn.execute( "INSERT INTO accounts (email, display_name, avatar_url, provider, auth_kind, mail_protocol, username, jmap_url, jmap_account_id, imap_host, imap_port, smtp_host, smtp_port, created_at, - sort_order, settings_json) + sort_order, settings_json, sync_state) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14, - COALESCE((SELECT MAX(sort_order) + 1 FROM accounts), 0), ?15)", + COALESCE((SELECT MAX(sort_order) + 1 FROM accounts), 0), ?15, ?16)", params![ a.email, a.display_name, @@ -138,6 +156,7 @@ pub fn insert(conn: &Connection, a: &NewAccount) -> Result { a.smtp_port, now_ms(), settings_json, + sync_state, ], )?; Ok(conn.last_insert_rowid()) @@ -379,6 +398,41 @@ mod tests { ); } + #[test] + fn credential_setup_accounts_are_recoverable_but_not_visible_or_started() { + let conn = crate::db::testutil::conn(); + let account = NewAccount { + email: "pending@test.dev", + display_name: None, + avatar_url: None, + provider: Provider::Gmail, + auth_kind: AuthKind::Oauth2, + mail_protocol: MailProtocol::Imap, + username: "pending@test.dev", + jmap_url: "", + jmap_account_id: None, + imap_host: "imap.gmail.com", + imap_port: 993, + smtp_host: "smtp.gmail.com", + smtp_port: 465, + }; + let id = insert_with_sync_state(&conn, &account, CREDENTIAL_SETUP_STATE).unwrap(); + + assert!(list(&conn).unwrap().is_empty()); + assert!(list_configs(&conn).unwrap().is_empty()); + assert_eq!( + find_by_email(&conn, "PENDING@test.dev") + .unwrap() + .unwrap() + .id, + id + ); + + set_sync_state(&conn, id, "idle").unwrap(); + assert_eq!(list(&conn).unwrap().len(), 1); + assert_eq!(list_configs(&conn).unwrap().len(), 1); + } + #[test] fn empty_account_settings_use_the_bounded_default() { let conn = crate::db::testutil::conn(); diff --git a/crates/flectar-mail-core/src/db/snapshot.rs b/crates/flectar-mail-core/src/db/snapshot.rs index 7d23247..0572d01 100644 --- a/crates/flectar-mail-core/src/db/snapshot.rs +++ b/crates/flectar-mail-core/src/db/snapshot.rs @@ -1,7 +1,7 @@ -//! Coherent online snapshots of the physically separate mail and calendar -//! stores. Both writer queues are held at the same coordinated gate while +//! Coherent online snapshots of the physically separate mail, calendar and files +//! stores. All three writer queues are held at the same coordinated gate while //! SQLite's online-backup API copies committed pages, so no application write -//! can land between the two captured database states. +//! can land between the captured database states. use super::Db; use crate::error::{CoreError, Result}; @@ -11,9 +11,10 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc; const SNAPSHOT_FORMAT: &str = "flectar-mail-database-snapshot"; -const SNAPSHOT_VERSION: u32 = 1; +const SNAPSHOT_VERSION: u32 = 2; const MAIL_FILE: &str = "mail.sqlite3"; const CALENDAR_FILE: &str = "calendar.sqlite3"; +const FILES_FILE: &str = "files.sqlite3"; const MANIFEST_FILE: &str = "manifest.json"; const GATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const BACKUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60 * 60); @@ -43,6 +44,10 @@ pub struct DatabaseSnapshotManifest { pub core_version: String, pub mail: DatabaseSnapshotStore, pub calendar: DatabaseSnapshotStore, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub files: Option, + #[serde(default)] + pub file_transfer_payloads: bool, } fn snapshot_store( @@ -134,11 +139,43 @@ fn restrict_permissions(_directory: &Path, _files: &[&Path]) -> Result<()> { Ok(()) } +fn publish_snapshot(staging: &Path, destination: &Path) -> Result<()> { + #[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" + ))] + { + rustix::fs::renameat_with( + rustix::fs::CWD, + staging, + rustix::fs::CWD, + destination, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from)?; + } + #[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" + )))] + { + // Windows directory rename fails if the destination already exists. + std::fs::rename(staging, destination)?; + } + Ok(()) +} + /// Create a new snapshot directory atomically. The destination must not /// already exist, which prevents an export from overwriting an older backup. pub async fn create( mail_db: &Db, calendar_db: &Db, + files_db: &Db, + payload_root: Option, destination: &Path, ) -> Result { if destination.exists() { @@ -160,85 +197,75 @@ pub async fn create( let mail_path = staging.join(MAIL_FILE); let calendar_path = staging.join(CALENDAR_FILE); + let files_path = staging.join(FILES_FILE); let (ready_tx, ready_rx) = mpsc::channel::<()>(); - let mail_ready = ready_tx.clone(); - let calendar_ready = ready_tx; let (done_tx, done_rx) = mpsc::channel::<()>(); - let mail_done = done_tx.clone(); - let calendar_done = done_tx; - let (mail_start_tx, mail_start_rx) = mpsc::sync_channel::<()>(0); - let (calendar_start_tx, calendar_start_rx) = mpsc::sync_channel::<()>(0); - let (mail_release_tx, mail_release_rx) = mpsc::sync_channel::<()>(0); - let (calendar_release_tx, calendar_release_rx) = mpsc::sync_channel::<()>(0); - let mail_destination = mail_path.clone(); - let calendar_destination = calendar_path.clone(); - + let mut starts = Vec::new(); + let mut releases = Vec::new(); + let mut jobs = Vec::new(); + for (db, path, name) in [ + (mail_db.clone(), mail_path.clone(), MAIL_FILE), + (calendar_db.clone(), calendar_path.clone(), CALENDAR_FILE), + (files_db.clone(), files_path.clone(), FILES_FILE), + ] { + let ready = ready_tx.clone(); + let done = done_tx.clone(); + let (start_tx, start_rx) = mpsc::sync_channel::<()>(0); + let (release_tx, release_rx) = mpsc::sync_channel::<()>(0); + starts.push(start_tx); + releases.push(release_tx); + jobs.push(async move { + db.write(move |conn| { + ready.send(()).map_err(|e| coordination_error("ready", e))?; + start_rx + .recv_timeout(GATE_TIMEOUT) + .map_err(|e| coordination_error("start", e))?; + let result = snapshot_store(conn, &path, name); + done.send(()) + .map_err(|e| coordination_error("completion", e))?; + release_rx + .recv_timeout(BACKUP_TIMEOUT) + .map_err(|e| coordination_error("release", e))?; + result + }) + .await + }); + } + drop(ready_tx); + drop(done_tx); let coordinator = tokio::task::spawn_blocking(move || -> Result<()> { - for _ in 0..2 { + for _ in 0..3 { ready_rx .recv_timeout(GATE_TIMEOUT) - .map_err(|error| coordination_error("ready", error))?; + .map_err(|e| coordination_error("ready", e))?; + } + for start in starts { + start.send(()).map_err(|e| coordination_error("start", e))?; } - mail_start_tx - .send(()) - .map_err(|error| coordination_error("mail start", error))?; - calendar_start_tx - .send(()) - .map_err(|error| coordination_error("calendar start", error))?; - for _ in 0..2 { + for _ in 0..3 { done_rx .recv_timeout(BACKUP_TIMEOUT) - .map_err(|error| coordination_error("completion", error))?; + .map_err(|e| coordination_error("completion", e))?; + } + for release in releases { + release + .send(()) + .map_err(|e| coordination_error("release", e))?; } - mail_release_tx - .send(()) - .map_err(|error| coordination_error("mail release", error))?; - calendar_release_tx - .send(()) - .map_err(|error| coordination_error("calendar release", error))?; Ok(()) }); - - let (mail, calendar, coordination) = tokio::join!( - mail_db.write(move |conn| { - mail_ready - .send(()) - .map_err(|error| coordination_error("mail ready", error))?; - mail_start_rx - .recv_timeout(GATE_TIMEOUT) - .map_err(|error| coordination_error("mail start", error))?; - let result = snapshot_store(conn, &mail_destination, MAIL_FILE); - mail_done - .send(()) - .map_err(|error| coordination_error("mail completion", error))?; - mail_release_rx - .recv_timeout(BACKUP_TIMEOUT) - .map_err(|error| coordination_error("mail release", error))?; - result - }), - calendar_db.write(move |conn| { - calendar_ready - .send(()) - .map_err(|error| coordination_error("calendar ready", error))?; - calendar_start_rx - .recv_timeout(GATE_TIMEOUT) - .map_err(|error| coordination_error("calendar start", error))?; - let result = snapshot_store(conn, &calendar_destination, CALENDAR_FILE); - calendar_done - .send(()) - .map_err(|error| coordination_error("calendar completion", error))?; - calendar_release_rx - .recv_timeout(BACKUP_TIMEOUT) - .map_err(|error| coordination_error("calendar release", error))?; - result - }), - coordinator - ); + let (stores, coordination) = tokio::join!(futures::future::join_all(jobs), coordinator); + let mut stores = stores.into_iter(); + let mail = stores.next().unwrap(); + let calendar = stores.next().unwrap(); + let files = stores.next().unwrap(); let result = async { coordination.map_err(|error| coordination_error("task", error))??; let mail = mail?; let calendar = calendar?; + let files = files?; + verify_store(&files_path, &files, "files")?; verify_store(&mail_path, &mail, "mail")?; verify_store(&calendar_path, &calendar, "calendar")?; let manifest = DatabaseSnapshotManifest { @@ -248,11 +275,48 @@ pub async fn create( core_version: env!("CARGO_PKG_VERSION").to_owned(), mail, calendar, + files: Some(files), + file_transfer_payloads: payload_root.is_some(), }; + if let Some(source) = payload_root + && tokio::fs::try_exists(&source).await? + { + let mut queue = std::collections::VecDeque::from([( + source, + staging.join("file_transfers"), + 0usize, + )]); + while let Some((source, target, depth)) = queue.pop_front() { + if depth > 3 { + return Err(CoreError::Other( + "Invalid transfer staging hierarchy.".into(), + )); + } + tokio::fs::create_dir_all(&target).await?; + restrict_permissions(&target, &[])?; + let mut entries = tokio::fs::read_dir(source).await?; + while let Some(entry) = entries.next_entry().await? { + let kind = entry.file_type().await?; + let path = target.join(entry.file_name()); + if kind.is_dir() { + queue.push_back((entry.path(), path, depth + 1)); + } else if kind.is_file() { + crate::files::save_cached_file(&entry.path(), &path).await?; + } else { + return Err(CoreError::Other( + "Unexpected link in transfer staging.".into(), + )); + } + } + } + } let manifest_path = staging.join(MANIFEST_FILE); tokio::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?).await?; - restrict_permissions(&staging, &[&mail_path, &calendar_path, &manifest_path])?; - tokio::fs::rename(&staging, destination).await?; + restrict_permissions( + &staging, + &[&mail_path, &calendar_path, &files_path, &manifest_path], + )?; + publish_snapshot(&staging, destination)?; Ok(manifest) } .await; @@ -299,9 +363,16 @@ mod tests { .await .unwrap(); + let files = Db::open_files(&source.path().join("files.db")).unwrap(); let destination = output.path().join("snapshot"); - let manifest = create(&mail, &calendar, &destination).await.unwrap(); + let manifest = create(&mail, &calendar, &files, None, &destination) + .await + .unwrap(); assert_eq!(manifest.mail.schema_version, migrations::LATEST_VERSION); + assert_eq!( + manifest.files.as_ref().unwrap().schema_version, + crate::db::files_migrations::LATEST_VERSION + ); assert_eq!( manifest.calendar.schema_version, calendar_migrations::LATEST_VERSION @@ -328,7 +399,7 @@ mod tests { 1 ); - let error = create(&mail, &calendar, &destination) + let error = create(&mail, &calendar, &files, None, &destination) .await .unwrap_err() .to_string(); diff --git a/crates/flectar-mail-core/src/error.rs b/crates/flectar-mail-core/src/error.rs index 5cb186b..b863e1d 100644 --- a/crates/flectar-mail-core/src/error.rs +++ b/crates/flectar-mail-core/src/error.rs @@ -22,6 +22,8 @@ pub enum CoreError { Auth(String), #[error("account needs re-authentication")] NeedsReauth, + #[error("secure credential storage is unavailable: {0}")] + CredentialStoreUnavailable(String), #[error("keyring error: {0}")] Keyring(String), #[error("mime error: {0}")] @@ -54,6 +56,7 @@ impl CoreError { CoreError::Tls(_) => "tls", CoreError::Auth(_) => "auth", CoreError::NeedsReauth => "needs_reauth", + CoreError::CredentialStoreUnavailable(_) => "credential_store_unavailable", CoreError::Keyring(_) => "keyring", CoreError::Mime(_) => "mime", CoreError::CalDav(_) => "caldav", @@ -78,7 +81,22 @@ impl From for CoreError { impl From for CoreError { fn from(e: keyring::Error) -> Self { - CoreError::Keyring(e.to_string()) + match e { + keyring::Error::PlatformFailure(error) => { + tracing::warn!(%error, "platform credential service failed"); + CoreError::CredentialStoreUnavailable( + "the system keyring could not be reached; start or unlock it and try again" + .into(), + ) + } + keyring::Error::NoStorageAccess(error) => { + tracing::warn!(%error, "platform credential service denied access"); + CoreError::CredentialStoreUnavailable( + "the system keyring is locked or denied access; unlock it and try again".into(), + ) + } + error => CoreError::Keyring(error.to_string()), + } } } diff --git a/crates/flectar-mail-core/src/files/cache.rs b/crates/flectar-mail-core/src/files/cache.rs new file mode 100644 index 0000000..64e71f4 --- /dev/null +++ b/crates/flectar-mail-core/src/files/cache.rs @@ -0,0 +1,128 @@ +//! Account-scoped disk content cache. Remote identifiers never become paths. +use super::{FileClient, FileNode, err}; +use crate::{Core, error::Result}; +use rusqlite::{OptionalExtension, params}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; + +pub async fn content( + core: &Core, + account: i64, + space: i64, + node: &FileNode, + client: Option<&FileClient>, + offline: bool, +) -> Result { + if !node.my_rights.may_read { + return Err(err("You do not have permission to read this file.")); + } + if node.is_directory() { + return Err(err("Select a file.")); + } + let validator = node + .blob_id + .as_deref() + .filter(|id| !id.starts_with("http://") && !id.starts_with("https://")) + .or(node.etag.as_deref()) + .unwrap_or(""); + let key = format!( + "{:x}", + Sha256::digest(format!("{space}\0{}\0{validator}", node.id).as_bytes()) + ); + let directory = core.paths.files_cache_dir(account); + let path = directory.join(&key); + let lookup = node.id.clone(); + let expected_key = key.clone(); + let recorded=core.files_db.read(move|c|Ok(c.query_row("SELECT byte_size FROM content_cache WHERE space_id=?1 AND remote_id=?2 AND relative_path=?3",params![space,lookup,expected_key],|r|r.get::<_,i64>(0)).optional()?)).await?; + if !validator.is_empty() + && tokio::fs::metadata(&path) + .await + .is_ok_and(|m| recorded == Some(m.len() as i64)) + { + let remote = node.id.clone(); + core.files_db + .write(move |c| { + c.execute( + "UPDATE content_cache SET accessed_at=?3 WHERE space_id=?1 AND remote_id=?2", + params![space, remote, chrono::Utc::now().timestamp_millis()], + )?; + Ok(()) + }) + .await?; + return Ok(path); + } + if offline { + return Err(err( + "This file is not available offline. Connect and choose Keep offline first.", + )); + } + tokio::fs::create_dir_all(&directory).await?; + // Reserve the bounded transfer maximum when the server omitted its size. + let reserve = super::MAX_TRANSFER as i64; + trim(core, account, CACHE_BUDGET - reserve).await?; + // A per-core operation lock serializes duplicate downloads and cache writes. + if tokio::fs::metadata(&path).await.is_ok() { + tokio::fs::remove_file(&path).await?; + } + match client.ok_or_else(|| err("Storage is disconnected."))? { + FileClient::Jmap(c) => c.download_to(node, &path).await?, + FileClient::Dav(c) => c.download_to(node, &path).await?, + } + let size = tokio::fs::metadata(&path).await?.len() as i64; + let remote = node.id.clone(); + let validator = validator.to_owned(); + core.files_db.write(move |c|{c.execute("INSERT INTO content_cache(space_id,remote_id,validator,relative_path,byte_size,accessed_at) VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(space_id,remote_id) DO UPDATE SET validator=excluded.validator,relative_path=excluded.relative_path,byte_size=excluded.byte_size,accessed_at=excluded.accessed_at",params![space,remote,validator,key,size,chrono::Utc::now().timestamp_millis()])?;Ok(())}).await?; + Ok(path) +} + +// Two GiB per account, including explicitly pinned files. Pins survive ordinary +// eviction, but server deletion/revocation and account removal take precedence. +const CACHE_BUDGET: i64 = 2 * 1024 * 1024 * 1024; +pub async fn pin(core: &Core, space: i64, remote: String, pinned: bool) -> Result<()> { + core.files_db + .write(move |c| { + c.execute( + "UPDATE content_cache SET pinned=?3 WHERE space_id=?1 AND remote_id=?2", + params![space, remote, pinned], + )?; + Ok(()) + }) + .await +} +pub(crate) async fn trim(core: &Core, account: i64, budget: i64) -> Result<()> { + let obsolete = core.files_db.write(move |c| { + let tx=c.transaction()?; + let mut size: i64=tx.query_row("SELECT COALESCE(SUM(byte_size),0) FROM content_cache c JOIN spaces s ON s.id=c.space_id WHERE s.account_id=?1",[account],|r|r.get(0))?; + let candidates={let mut q=tx.prepare("SELECT c.space_id,c.remote_id,c.relative_path,c.byte_size FROM content_cache c JOIN spaces s ON s.id=c.space_id WHERE s.account_id=?1 AND c.pinned=0 ORDER BY c.accessed_at,c.remote_id")?;q.query_map([account],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,i64>(3)?)))?.collect::>>()?}; + let mut obsolete=Vec::new(); + for (space,remote,path,bytes) in candidates {if size<=budget {break;} tx.execute("DELETE FROM content_cache WHERE space_id=?1 AND remote_id=?2",params![space,remote])?;size-=bytes;obsolete.push(path);} + if size>budget {return Err(err("Offline files fill the 2 GiB account cache. Release an offline copy before downloading another file."));} + tx.commit()?;Ok(obsolete) + }).await?; + for key in obsolete { + if safe_key(&key) { + let _ = tokio::fs::remove_file(core.paths.files_cache_dir(account).join(key)).await; + } + } + sweep(core, account).await +} +fn safe_key(key: &str) -> bool { + key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()) +} +/// Remove crash leftovers and superseded blob versions. Only our opaque names +/// are touched; no server path can escape the account cache. +pub(crate) async fn sweep(core: &Core, account: i64) -> Result<()> { + let keys=core.files_db.read(move |c| {let mut q=c.prepare("SELECT relative_path FROM content_cache c JOIN spaces s ON s.id=c.space_id WHERE s.account_id=?1")?;Ok(q.query_map([account],|r|r.get::<_,String>(0))?.collect::>>()?)}).await?; + let mut entries = match tokio::fs::read_dir(core.paths.files_cache_dir(account)).await { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + while let Some(entry) = entries.next_entry().await? { + let key = entry.file_name().to_string_lossy().into_owned(); + if safe_key(&key) && !keys.contains(&key) && entry.file_type().await?.is_file() { + tokio::fs::remove_file(entry.path()).await?; + } + } + Ok(()) +} diff --git a/crates/flectar-mail-core/src/files/dav.rs b/crates/flectar-mail-core/src/files/dav.rs new file mode 100644 index 0000000..933e73b --- /dev/null +++ b/crates/flectar-mail-core/src/files/dav.rs @@ -0,0 +1,923 @@ +//! WebDAV (RFC 4918), ACL (RFC 3744), quota (RFC 4331), sync (RFC 6578). +//! Failed propstats never become successful metadata or permissions. +use super::{ + FileNode, MAX_TRANSFER, Rights, err, + transport::{Transport, validated_url}, + validate_name, +}; +use crate::error::Result; +use quick_xml::{NsReader, events::Event, name::ResolveResult}; +use std::{collections::HashMap, path::Path}; +use url::Url; +const DAV: &str = "DAV:"; + +#[derive(Clone, Debug, Default)] +pub struct Property { + pub namespace: String, + pub name: String, + pub text: String, + pub children: Vec, +} +impl Property { + fn is(&self, name: &str) -> bool { + self.namespace == DAV && self.name == name + } + fn child(&self, name: &str) -> Option<&Self> { + self.children.iter().find(|p| p.is(name)) + } + fn value(&self, name: &str) -> Option<&str> { + self.child(name).map(|p| p.text.trim()) + } + fn contains(&self, name: &str) -> bool { + self.is(name) || self.children.iter().any(|p| p.contains(name)) + } + pub fn describe(&self) -> String { + fn visit(p: &Property, depth: usize, out: &mut String) { + if !p.text.trim().is_empty() { + out.push_str(&format!( + "{}{}: {}\n", + " ".repeat(depth), + p.name, + p.text.trim() + )); + } + for child in &p.children { + visit(child, depth + 1, out) + } + } + let mut out = String::new(); + visit(self, 0, &mut out); + out + } + fn xml(&self) -> String { + format!( + "<{} xmlns=\"{}\">{}{}", + self.name, + escape(&self.namespace), + escape(&self.text), + self.children.iter().map(Self::xml).collect::(), + self.name + ) + } +} +pub struct Listing { + pub nodes: Vec, + pub current: FileNode, + pub quota_available: Option, + pub quota_used: Option, + pub sync_token: Option, +} +pub struct DavClient { + transport: Transport, + pub root: String, + pub supports_locks: bool, + pub supports_acl: bool, + locks: HashMap, +} +impl DavClient { + pub async fn connect(endpoint: &str, user: &str, secret: &str) -> Result { + let mut url = canonical_url(validated_url(endpoint)?)?; + if url.query().is_some() { + return Err(err("WebDAV storage URLs cannot contain a query.")); + } + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + let transport = Transport::new(url.as_str(), user, secret)?; + let response = transport + .send(transport.request("OPTIONS", url.as_str())?) + .await?; + let dav = response + .headers() + .get("DAV") + .and_then(|h| h.to_str().ok()) + .unwrap_or("") + .to_owned(); + Ok(Self { + transport, + root: url.into(), + supports_locks: dav.split(',').any(|s| s.trim() == "2"), + supports_acl: dav.split(',').any(|s| s.trim() == "access-control"), + locks: HashMap::new(), + }) + } + pub fn href(&self, base: &str, value: &str) -> Result { + let base = validated_url(base)?; + let root = validated_url(&self.root)?; + let url = canonical_url(base.join(value).map_err(|_| err("Invalid WebDAV href."))?)?; + if url.origin() != root.origin() + || url.query().is_some() + || url.fragment().is_some() + || !(url.path().starts_with(root.path()) + || url.path() == root.path().trim_end_matches('/')) + { + return Err(err( + "WebDAV returned a resource outside the configured storage root.", + )); + } + Ok(url.into()) + } + pub fn child_url(&self, parent: &str, name: &str, directory: bool) -> Result { + validate_name(name)?; + let parent = self.href(&self.root, parent)?; + let mut url = Url::parse(&parent).map_err(|_| err("Invalid folder."))?; + { + let mut path = url + .path_segments_mut() + .map_err(|_| err("Invalid folder path."))?; + path.pop_if_empty().push(name); + if directory { + path.push(""); + } + } + self.href(&self.root, url.as_str()) + } + async fn xml( + &self, + method: &str, + url: &str, + depth: &str, + body: String, + headers: &[(&str, String)], + ) -> Result { + let url = self.href(&self.root, url)?; + let mut request = self + .transport + .request(method, &url)? + .header("Depth", depth) + .header("Content-Type", "application/xml; charset=utf-8") + .body(body); + for (key, value) in headers { + request = request.header(*key, value); + } + let response = self.transport.send(request).await?; + let body = crate::http_body::text(response, 16 * 1024 * 1024, "WebDAV metadata").await?; + parse(&body) + } + pub async fn properties(&self, url: &str) -> Result { + self.xml("PROPFIND",url,"0","".into(),&[]).await + } + pub async fn list(&self, url: &str) -> Result { + let url = self.href(&self.root, url)?; + let tree=self.xml("PROPFIND",&url,"1","".into(),&[]).await?; + if !tree.is("multistatus") { + return Err(err("Expected a WebDAV multistatus response.")); + } + let mut listing = Listing { + nodes: Vec::new(), + current: FileNode::default(), + quota_available: None, + quota_used: None, + sync_token: None, + }; + let mut found = false; + for response in tree.children.iter().filter(|p| p.is("response")) { + let Some(href) = response.value("href") else { + return Err(err("WebDAV response has no href.")); + }; + let href = self.href(&url, href)?; + let props = successful_properties(response)?; + let node = node(&href, &props); + if href.trim_end_matches('/') == url.trim_end_matches('/') { + listing.quota_available = + prop(&props, "quota-available-bytes").and_then(|v| v.text.trim().parse().ok()); + listing.quota_used = + prop(&props, "quota-used-bytes").and_then(|v| v.text.trim().parse().ok()); + listing.sync_token = prop(&props, "sync-token").map(|v| v.text.trim().into()); + listing.current = node; + found = true; + } else { + // Stalwart assisted discovery may return grandchildren. Keep + // navigation one level deep rather than flattening the tree. + let prefix = format!("{}/", url.trim_end_matches('/')); + let relative = href + .strip_prefix(&prefix) + .unwrap_or("") + .trim_end_matches('/'); + if !relative.is_empty() && !relative.contains('/') { + listing.nodes.push(node); + } + } + } + if !found { + return Err(err("The server omitted the requested folder.")); + } + let parent_can_remove = listing.current.my_rights.may_delete; + for node in &mut listing.nodes { + node.my_rights.may_delete = parent_can_remove; + node.my_rights.may_rename = parent_can_remove && listing.current.my_rights.add(); + } + listing.nodes.sort_by(|a, b| { + b.is_directory() + .cmp(&a.is_directory()) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(listing) + } + fn conditions( + &self, + node: &FileNode, + request: reqwest::RequestBuilder, + require_etag: bool, + ) -> Result { + let mut request = request; + if let Some(etag) = node.etag.as_ref().filter(|v| !v.starts_with("W/")) { + request = request.header("If-Match", etag); + } else if require_etag { + return Err(err( + "The server supplied no strong ETag. Refresh; this file cannot safely be overwritten.", + )); + } + // Tagged lists also submit a parent lock when creating/moving a child. + let conditions = self + .locks + .iter() + .filter(|(href, _)| { + node.id == **href + || node + .id + .starts_with(&format!("{}/", href.trim_end_matches('/'))) + }) + .map(|(href, token)| format!("<{href}> (<{token}>)")) + .collect::>() + .join(" "); + if !conditions.is_empty() { + request = request.header("If", conditions); + } + Ok(request) + } + async fn mutate( + &self, + method: &str, + node: &FileNode, + extra: &[(&str, String)], + body: Option, + require_etag: bool, + ) -> Result<()> { + let url = self.href(&self.root, &node.id)?; + let mut request = + self.conditions(node, self.transport.request(method, &url)?, require_etag)?; + let mut headers = reqwest::header::HeaderMap::new(); + for (key, value) in extra { + let name = reqwest::header::HeaderName::from_bytes(key.as_bytes()) + .map_err(|_| err("Invalid mutation header."))?; + let value = reqwest::header::HeaderValue::from_str(value) + .map_err(|_| err("Invalid mutation header value."))?; + headers.insert(name, value); + } + // Replace generated If conditions when a mutation supplies a complete + // source/destination list. Duplicate If headers can lose preconditions. + request = request.headers(headers); + if let Some(body) = body { + request = request.body(body); + } + let response = self.transport.send(request).await?; + if response.status().as_u16() == 207 { + let xml = + crate::http_body::text(response, 16 * 1024 * 1024, "WebDAV operation").await?; + check_multistatus(&parse(&xml)?)?; + } + Ok(()) + } + pub async fn create_folder(&self, parent: &str, name: &str) -> Result<()> { + let node = FileNode { + id: self.child_url(parent, name, true)?, + ..Default::default() + }; + self.mutate( + "MKCOL", + &node, + &[("If-None-Match", "*".into())], + None, + false, + ) + .await + } + pub async fn upload( + &self, + parent: &str, + name: &str, + path: &Path, + replace: Option<&FileNode>, + ) -> Result<()> { + let node = match replace { + Some(node) => node.clone(), + None => FileNode { + id: self.child_url(parent, name, false)?, + ..Default::default() + }, + }; + let (data, length) = super::upload_body(path, MAX_TRANSFER).await?; + let mut headers = vec![ + ("Content-Length", length.to_string()), + ("Content-Type", crate::queue::mime_guess_from_name(name)), + ]; + if replace.is_none() { + headers.push(("If-None-Match", "*".into())); + } + self.mutate("PUT", &node, &headers, Some(data), replace.is_some()) + .await + } + async fn download_response(&self, node: &FileNode) -> Result { + if node.is_directory() { + return Err(err("Select an individual file to download.")); + } + let url = self.href(&self.root, &node.id)?; + let request = self.conditions(node, self.transport.request("GET", &url)?, false)?; + self.transport.send(request).await + } + pub async fn download(&self, node: &FileNode) -> Result> { + crate::http_body::bytes( + self.download_response(node).await?, + MAX_TRANSFER, + "File download", + ) + .await + } + pub async fn preview(&self, node: &FileNode) -> Result> { + crate::http_body::bytes( + self.download_response(node).await?, + 16 * 1024 * 1024, + "File preview", + ) + .await + } + pub async fn download_to(&self, node: &FileNode, destination: &Path) -> Result<()> { + super::save_response(destination, self.download_response(node).await?).await + } + pub async fn delete(&self, node: &FileNode) -> Result<()> { + if node.id.trim_end_matches('/') == self.root.trim_end_matches('/') { + return Err(err("The storage root cannot be deleted.")); + } + self.mutate("DELETE", node, &[], None, !node.is_directory()) + .await + } + pub async fn relocate( + &self, + node: &FileNode, + parent: &str, + name: &str, + copy: bool, + ) -> Result<()> { + let destination = self.child_url(parent, name, node.is_directory())?; + if destination == node.id + || destination.starts_with(&format!("{}/", node.id.trim_end_matches('/'))) + { + return Err(err("A folder cannot be moved or copied into itself.")); + } + let conditions = self + .locks + .iter() + .filter(|(href, _)| { + [node.id.as_str(), destination.as_str()].iter().any(|id| { + *id == href.as_str() + || id.starts_with(&format!("{}/", href.trim_end_matches('/'))) + }) + }) + .map(|(href, token)| format!("<{href}> (<{token}>)")) + .collect::>() + .join(" "); + let mut headers = vec![ + ("Destination", destination), + ("Overwrite", "F".into()), + ("Depth", "infinity".into()), + ]; + if !conditions.is_empty() { + headers.push(("If", conditions)); + } + self.mutate( + if copy { "COPY" } else { "MOVE" }, + node, + &headers, + None, + !node.is_directory(), + ) + .await + } + /// Explicit replacement carries validators for both resources. Never + /// delete a destination first: COPY/MOVE remains one server operation. + pub async fn relocate_with_policy( + &self, + node: &FileNode, + parent: &str, + name: &str, + copy: bool, + policy: super::CollisionPolicy, + ) -> Result<()> { + if matches!(policy, super::CollisionPolicy::Reject) { + return self.relocate(node, parent, name, copy).await; + } + let listing = self.list(parent).await?; + let Some(existing) = listing.nodes.iter().find(|n| n.name == name) else { + return self.relocate(node, parent, name, copy).await; + }; + if matches!(policy, super::CollisionPolicy::Rename) { + let (stem, extension) = name + .rsplit_once('.') + .filter(|(stem, _)| !stem.is_empty()) + .map(|(a, b)| (a, format!(".{b}"))) + .unwrap_or((name, String::new())); + for number in 1..=1000 { + let candidate = format!("{stem} ({number}){extension}"); + if !listing.nodes.iter().any(|n| n.name == candidate) { + return self.relocate(node, parent, &candidate, copy).await; + } + } + return Err(err("Could not find an unused destination name.")); + } + if node.is_directory() || existing.is_directory() { + return Err(err( + "Replacing a WebDAV folder cannot be guarded by a content ETag. Choose Keep both or select a new destination.", + )); + } + if existing.id == node.id { + return Err(err("Source and destination are the same file.")); + } + let etag = existing + .etag + .as_deref() + .filter(|s| !s.starts_with("W/")) + .ok_or_else(|| err("The destination has no strong ETag. Choose a new name."))?; + if matches!(policy, super::CollisionPolicy::Newest) { + let date = |n: &FileNode| { + n.modified.as_deref().and_then(|s| { + chrono::DateTime::parse_from_rfc2822(s) + .or_else(|_| chrono::DateTime::parse_from_rfc3339(s)) + .ok() + }) + }; + let (Some(source), Some(target)) = (date(node), date(existing)) else { + return Err(err("Both files need modification dates for Keep newest.")); + }; + if source <= target { + return Err(err( + "The destination is newer or has the same modification date; it was kept.", + )); + } + } + let mut target_conditions = format!("[{etag}]"); + let mut others = Vec::new(); + for (href, token) in &self.locks { + if href == &existing.id { + target_conditions.push_str(&format!(" <{token}>")); + } else if [node.id.as_str(), existing.id.as_str()].iter().any(|id| { + *id == href || id.starts_with(&format!("{}/", href.trim_end_matches('/'))) + }) { + others.push(format!("<{href}> (<{token}>)")); + } + } + // ETag and lock must be in the SAME list (AND). Separate lists for a + // resource are OR in RFC 4918 and could bypass the stale-write check. + let conditions = format!( + "<{}> ({target_conditions}) {}", + existing.id, + others.join(" ") + ); + self.mutate( + if copy { "COPY" } else { "MOVE" }, + node, + &[ + ("Destination", existing.id.clone()), + ("Overwrite", "T".into()), + ("Depth", "infinity".into()), + ("If", conditions), + ], + None, + true, + ) + .await + } + pub async fn lock(&mut self, node: &FileNode) -> Result<()> { + self.lock_with_options(node, false, false, 300).await + } + pub async fn lock_with_options( + &mut self, + node: &FileNode, + shared: bool, + recursive: bool, + seconds: u32, + ) -> Result<()> { + if !self.supports_locks { + return Err(err("The server does not advertise WebDAV locking.")); + } + let url = self.href(&self.root, &node.id)?; + let mut request = self + .transport + .request("LOCK", &url)? + .header("Timeout", format!("Second-{}", seconds.clamp(30, 3600))) + .header("Depth", if recursive { "infinity" } else { "0" }); + if let Some(token) = self.locks.get(&url) { + request = request.header("If", format!("(<{token}>)")); + } else { + request=request.header("Content-Type","application/xml").body(format!("",if shared {"shared"} else {"exclusive"})); + } + let response = self.transport.send(request).await?; + if let Some(token) = response + .headers() + .get("Lock-Token") + .and_then(|h| h.to_str().ok()) + { + self.locks.insert( + url, + token.trim_start_matches('<').trim_end_matches('>').into(), + ); + } else if !self.locks.contains_key(&url) { + return Err(err("The server omitted the lock token.")); + } + Ok(()) + } + pub fn owns_lock(&self, id: &str) -> bool { + self.locks.contains_key(id) + } + pub async fn unlock(&mut self, node: &FileNode) -> Result<()> { + let token = self + .locks + .get(&node.id) + .ok_or_else(|| err("This session does not own the lock."))? + .clone(); + self.mutate( + "UNLOCK", + node, + &[("Lock-Token", format!("<{token}>"))], + None, + false, + ) + .await?; + self.locks.remove(&node.id); + Ok(()) + } + pub async fn patch_property( + &self, + node: &FileNode, + namespace: &str, + name: &str, + value: Option<&str>, + ) -> Result<()> { + if name.is_empty() + || !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + || namespace.len() > 1024 + || url::Url::parse(namespace).is_err() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(err("Invalid XML property name.")); + } + let action = if value.is_some() { "set" } else { "remove" }; + let xml = format!( + "{}", + escape(namespace), + escape(value.unwrap_or("")) + ); + self.mutate( + "PROPPATCH", + node, + &[("Content-Type", "application/xml; charset=utf-8".into())], + Some(xml.into()), + !node.is_directory(), + ) + .await + } + /// Preserve every unrelated ACE. Never silently discard existing shares. + pub async fn share( + &self, + node: &FileNode, + principal: &str, + write: bool, + remove: bool, + ) -> Result<()> { + self.share_privileges( + node, + principal, + if write { &["read", "write"] } else { &["read"] }, + remove, + ) + .await + } + pub async fn share_privileges( + &self, + node: &FileNode, + principal: &str, + privileges: &[&str], + remove: bool, + ) -> Result<()> { + if privileges.iter().any(|p| { + ![ + "read", + "write", + "write-content", + "write-properties", + "bind", + "unbind", + "read-acl", + "write-acl", + "unlock", + "read-current-user-privilege-set", + ] + .contains(p) + }) { + return Err(err("Unsupported WebDAV privilege.")); + } + if !self.supports_acl || !node.my_rights.may_share { + return Err(err("You cannot change sharing for this file.")); + } + let root = validated_url(&self.root)?; + let principal = canonical_url( + root.join(principal) + .map_err(|_| err("Invalid principal URL."))?, + )?; + if principal.origin() != root.origin() + || !(principal.path().starts_with("/dav/pal/") + || principal.path().starts_with("/dav/principal/")) + { + return Err(err("Enter a principal URL on this storage server.")); + } + let tree = self.properties(&node.id).await?; + let response = tree + .children + .iter() + .find(|p| p.is("response")) + .ok_or_else(|| err("Missing ACL response."))?; + let props = successful_properties(response)?; + let acl = + prop(&props, "acl").ok_or_else(|| err("The server did not return the current ACL."))?; + let mut entries = String::new(); + for ace in &acl.children { + if !ace.is("ace") { + continue; + } + let matches = ace + .child("principal") + .and_then(|p| p.value("href")) + .and_then(|p| root.join(p).ok()) + .and_then(|p| canonical_url(p).ok()) + .is_some_and(|p| { + p.as_str().trim_end_matches('/') == principal.as_str().trim_end_matches('/') + }); + if matches && (ace.contains("protected") || ace.contains("inherited")) { + return Err(err("This permission is protected or inherited.")); + } + // RFC 3744: protected/inherited ACEs must not be submitted in ACL. + if !matches && !ace.contains("protected") && !ace.contains("inherited") { + entries.push_str(&ace.xml()); + } + } + if !remove { + let grants = privileges + .iter() + .map(|p| format!("")) + .collect::(); + entries.push_str(&format!("{}{grants}",escape(principal.as_str()))); + } + self.mutate( + "ACL", + node, + &[("Content-Type", "application/xml".into())], + Some(format!("{entries}").into()), + !node.is_directory(), + ) + .await + } + pub async fn sync_collection(&self, url: &str, token: &str) -> Result { + self.xml("REPORT",url,"1",format!("{}1",escape(token)),&[]).await + } + pub async fn collection_changed(&self, url: &str, token: &str) -> Result<(bool, String)> { + let tree = self.sync_collection(url, token).await?; + if !tree.is("multistatus") { + return Err(err("Invalid collection sync response.")); + } + let token = tree + .value("sync-token") + .ok_or_else(|| err("Missing collection sync token."))? + .to_owned(); + // Reconcile changed collections with Depth 1 so permissions, locks, + // quota and deletion semantics use the same validated listing parser. + Ok((tree.children.iter().any(|p| p.is("response")), token)) + } +} +// Compare paths after normalizing percent-encoded segments. Servers may encode +// the account's '@' even when the configured URL does not. Encoded separators +// are rejected instead of being reinterpreted as a different hierarchy. +fn canonical_url(mut url: Url) -> Result { + let segments = url + .path() + .split('/') + .skip(1) + .map(|part| { + let decoded = percent_encoding::percent_decode_str(part) + .decode_utf8() + .map_err(|_| err("Invalid UTF-8 WebDAV path."))? + .into_owned(); + if decoded.contains(['/', '\\']) || decoded.chars().any(char::is_control) { + return Err(err("Invalid encoded WebDAV path segment.")); + } + Ok(decoded) + }) + .collect::>>()?; + { + let mut path = url + .path_segments_mut() + .map_err(|_| err("Invalid WebDAV path."))?; + path.clear(); + for segment in segments { + path.push(&segment); + } + } + Ok(url) +} + +fn prop<'a>(props: &'a [Property], name: &str) -> Option<&'a Property> { + props.iter().find(|p| p.is(name)) +} +fn status(value: &str) -> u16 { + value + .split_whitespace() + .nth(1) + .and_then(|n| n.parse().ok()) + .unwrap_or(0) +} +fn successful_properties(response: &Property) -> Result> { + if response + .value("status") + .is_some_and(|s| !(200..300).contains(&status(s))) + { + return Err(err("A WebDAV resource could not be loaded.")); + } + let mut props = Vec::new(); + let mut success = false; + for stat in response.children.iter().filter(|p| p.is("propstat")) { + if stat + .value("status") + .is_some_and(|s| (200..300).contains(&status(s))) + { + success = true; + if let Some(p) = stat.child("prop") { + props.extend(p.children.clone()); + } + } + } + if !success { + return Err(err( + "No readable properties were returned for this resource.", + )); + } + Ok(props) +} +fn check_multistatus(tree: &Property) -> Result<()> { + if !tree.is("multistatus") { + return Err(err("Invalid WebDAV operation response.")); + } + for response in tree.children.iter().filter(|p| p.is("response")) { + if response + .value("status") + .is_some_and(|s| !(200..300).contains(&status(s))) + { + return Err(err( + "Some WebDAV operations failed. Refresh before retrying.", + )); + } + for stat in response.children.iter().filter(|p| p.is("propstat")) { + if !stat + .value("status") + .is_some_and(|s| (200..300).contains(&status(s))) + { + return Err(err("The server rejected a property change.")); + } + } + } + Ok(()) +} +fn node(href: &str, props: &[Property]) -> FileNode { + let value = |name| prop(props, name).map(|p| p.text.trim().to_owned()); + let has = |name| { + prop(props, "current-user-privilege-set") + .is_some_and(|p| p.contains(name) || p.contains("all")) + }; + let directory = prop(props, "resourcetype").is_some_and(|p| p.contains("collection")); + let name = value("displayname") + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + let leaf = href + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or("Files"); + percent_encoding::percent_decode_str(leaf) + .decode_utf8_lossy() + .into_owned() + }); + FileNode { + id: href.into(), + name, + node_type: Some(if directory { "directory" } else { "file" }.into()), + blob_id: (!directory).then(|| href.into()), + size: value("getcontentlength").and_then(|n| n.parse().ok()), + media_type: value("getcontenttype"), + modified: value("getlastmodified"), + created: value("creationdate"), + etag: value("getetag"), + locked: prop(props, "lockdiscovery").is_some_and(|p| p.contains("activelock")), + my_rights: Rights { + may_read: has("read"), + may_add_children: has("bind") || has("write"), + may_delete: has("unbind") || has("write"), + may_modify_content: has("write-content") || has("write"), + may_share: has("write-acl"), + ..Default::default() + }, + ..Default::default() + } +} +fn escape(s: &str) -> String { + quick_xml::escape::escape(s).into_owned() +} +fn parse(xml: &str) -> Result { + let mut reader = NsReader::from_str(xml); + reader.config_mut().expand_empty_elements = true; + let mut stack: Vec = Vec::new(); + let mut root = None; + let mut count = 0usize; + loop { + let (ns, event) = reader + .read_resolved_event() + .map_err(|_| err("Malformed WebDAV XML."))?; + let namespace = match ns { + ResolveResult::Bound(n) => n.as_ref().to_owned(), + _ => String::new(), + }; + match event { + Event::Start(e) => { + count += 1; + if count > 150000 || stack.len() > 64 { + return Err(err("WebDAV metadata exceeds safety limits.")); + } + let p = Property { + namespace, + name: e.local_name().as_ref().to_owned(), + ..Default::default() + }; + stack.push(p); + } + Event::End(_) => { + let p = stack.pop().ok_or_else(|| err("Malformed XML nesting."))?; + if let Some(parent) = stack.last_mut() { + parent.children.push(p); + } else if root.replace(p).is_some() { + return Err(err("Multiple XML roots.")); + } + } + Event::Text(e) => { + if let Some(p) = stack.last_mut() { + p.text + .push_str(&e.xml_content(quick_xml::XmlVersion::Implicit1_0)); + } + } + Event::CData(e) => { + if let Some(p) = stack.last_mut() { + p.text + .push_str(&e.xml_content(quick_xml::XmlVersion::Implicit1_0)); + } + } + Event::GeneralRef(e) => { + let name = e.as_ref(); + let escaped = format!("&{name};"); + let value = quick_xml::escape::unescape(&escaped) + .map_err(|_| err("Unknown XML entity."))?; + if let Some(p) = stack.last_mut() { + p.text.push_str(&value); + } + } + Event::DocType(_) => return Err(err("WebDAV XML document types are not allowed.")), + Event::Eof => break, + _ => {} + } + } + if !stack.is_empty() { + return Err(err("Incomplete WebDAV XML.")); + } + root.ok_or_else(|| err("Empty WebDAV XML.")) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn namespace_and_failed_propstats_do_not_grant_rights() { + let tree=parse("A & BHTTP/1.1 200 OKHTTP/1.1 403 Forbidden").unwrap(); + let file = node( + "https://example.org/dav/file/me/a", + &successful_properties(&tree).unwrap(), + ); + assert_eq!(file.name, "A & B"); + assert!(!file.my_rights.may_share); + assert!(!file.my_rights.may_read); + } + #[test] + fn prop_patch_checks_individual_statuses() { + assert!(check_multistatus(&parse("HTTP/1.1 424 Failed Dependency").unwrap()).is_err()); + } + #[test] + fn rejects_doctype_and_incomplete_xml() { + assert!(parse("").is_err()); + assert!(parse("").is_err()); + } +} diff --git a/crates/flectar-mail-core/src/files/jmap.rs b/crates/flectar-mail-core/src/files/jmap.rs new file mode 100644 index 0000000..cd3b5c1 --- /dev/null +++ b/crates/flectar-mail-core/src/files/jmap.rs @@ -0,0 +1,992 @@ +//! RFC 8620 method envelopes with Stalwart's FileNode extension. The extension +//! is a draft: preserve unknown metadata and negotiate both permission shapes. +use super::{ + FileNode, MAX_TRANSFER, PAGE_SIZE, err, + transport::{Transport, expand, ordered_records}, + validate_name, +}; +use crate::error::Result; +use serde_json::{Value, json}; +use std::path::Path; + +pub const CAPABILITY: &str = "urn:ietf:params:jmap:filenode"; +const CORE: &str = "urn:ietf:params:jmap:core"; +const SHARING: &str = "urn:ietf:params:jmap:principals"; +const QUOTA: &str = "urn:ietf:params:jmap:quota"; + +#[derive(Clone, Debug)] +pub struct StorageAccount { + pub id: String, + pub name: String, + pub read_only: bool, +} +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BrowseOptions { + pub node_type: Option, + pub min_size: Option, + pub max_size: Option, + pub sort: String, + pub descending: bool, +} +impl BrowseOptions { + pub fn filtered(&self) -> bool { + self.node_type.is_some() || self.min_size.is_some() || self.max_size.is_some() + } + pub fn apply_local(&self, nodes: &mut Vec) { + nodes.retain(|n| { + self.node_type + .as_deref() + .is_none_or(|t| (t == "directory") == n.is_directory()) + && self + .min_size + .is_none_or(|v| n.size.is_some_and(|s| s >= v as u64)) + && self + .max_size + .is_none_or(|v| n.size.is_some_and(|s| s <= v as u64)) + }); + nodes.sort_by(|a, b| { + let order = match self.sort.as_str() { + "size" => a.size.cmp(&b.size), + "nodeType" => b.is_directory().cmp(&a.is_directory()), + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + .then_with(|| a.id.cmp(&b.id)); + if self.descending { + order.reverse() + } else { + order + } + }); + } +} +fn filename_pattern(text: &str) -> String { + let mut pattern = String::from("*"); + for c in text.chars() { + match c { + '*' => pattern.push_str("[*]"), + '?' => pattern.push_str("[?]"), + '[' => pattern.push_str("[[]"), + _ => pattern.push(c), + } + } + pattern.push('*'); + pattern +} +pub struct FilePage { + pub nodes: Vec, + pub state: String, + pub query_state: String, + pub next_position: Option, +} +#[derive(Clone)] +pub struct FileClient { + transport: Transport, + session: Value, + pub accounts: Vec, + pub account_id: String, + pub state: Option, + pub collision: super::CollisionPolicy, + pub compare_case_insensitively: bool, +} +impl FileClient { + pub async fn connect( + base: &str, + user: &str, + secret: &str, + preferred: Option<&str>, + ) -> Result { + let transport = Transport::new(base, user, secret)?; + let session = transport + .discover(&format!("{}/.well-known/jmap", base.trim_end_matches('/'))) + .await?; + if session["capabilities"].get(CAPABILITY).is_none() { + return Err(err( + "This server does not advertise JMAP file storage. You can configure its WebDAV URL instead.", + )); + } + let accounts = session["accounts"] + .as_object() + .ok_or_else(|| err("Invalid JMAP accounts."))? + .iter() + .filter(|(_, a)| a["accountCapabilities"].get(CAPABILITY).is_some()) + .map(|(id, a)| StorageAccount { + id: id.clone(), + name: a["name"].as_str().unwrap_or(id).into(), + read_only: a["isReadOnly"].as_bool().unwrap_or(true), + }) + .collect::>(); + let selected = preferred + .filter(|id| accounts.iter().any(|a| a.id == *id)) + .or_else(|| { + session["primaryAccounts"][CAPABILITY] + .as_str() + .filter(|id| accounts.iter().any(|a| a.id == *id)) + }) + .or_else(|| accounts.first().map(|a| a.id.as_str())) + .ok_or_else(|| err("No accessible file storage account."))? + .to_owned(); + for key in ["apiUrl", "uploadUrl", "downloadUrl"] { + let template = session[key] + .as_str() + .ok_or_else(|| err("Incomplete JMAP session."))?; + let url = expand( + template, + &[ + ("accountId", &selected), + ("blobId", "probe"), + ("name", "probe"), + ("type", "application/octet-stream"), + ], + )?; + let _ = transport.request("GET", &url)?; + } + Ok(Self { + transport, + session, + accounts, + account_id: selected, + state: None, + collision: super::CollisionPolicy::Reject, + compare_case_insensitively: false, + }) + } + pub fn capabilities(&self) -> &Value { + &self.session["accounts"][&self.account_id]["accountCapabilities"][CAPABILITY] + } + pub fn can_create_root(&self) -> bool { + self.capabilities()["mayCreateTopLevelFileNode"].as_bool() == Some(true) + && !self.read_only() + } + pub fn read_only(&self) -> bool { + self.accounts + .iter() + .find(|a| a.id == self.account_id) + .is_none_or(|a| a.read_only) + } + pub fn modern(&self) -> bool { + self.capabilities().get("forbiddenNameChars").is_some() + || self.capabilities()["fileNodeQuerySortOptions"] + .as_array() + .is_some_and(|a| a.iter().any(|v| v == "nodeType")) + } + pub fn select_account(&mut self, id: &str) -> Result<()> { + if !self.accounts.iter().any(|a| a.id == id) { + return Err(err("File account is no longer available.")); + } + self.account_id = id.into(); + self.state = None; + Ok(()) + } + fn name(&self, name: &str) -> Result<()> { + validate_name(name)?; + let cap = self.capabilities(); + if name.len() as u64 > cap["maxSizeFileNodeName"].as_u64().unwrap_or(255) + || cap["forbiddenNameChars"] + .as_str() + .is_some_and(|chars| name.chars().any(|c| chars.contains(c))) + || cap["forbiddenNodeNames"].as_array().is_some_and(|names| { + names + .iter() + .any(|n| n.as_str().is_some_and(|n| n.eq_ignore_ascii_case(name))) + }) + { + return Err(err("The server does not allow this file name.")); + } + Ok(()) + } + /// Standard get/query/changes/queryChanges/set/copy envelopes share strict + /// response correlation and per-object error handling, including HTTP 200 errors. + async fn call(&self, method: &str, capability: &str, mut args: Value) -> Result { + if self.session["capabilities"].get(capability).is_none() { + return Err(err("The server does not advertise this capability.")); + } + let account = if capability == SHARING { + self.session["primaryAccounts"][SHARING] + .as_str() + .ok_or_else(|| err("No personal sharing account is available."))? + } else if self.session["accounts"][&self.account_id]["accountCapabilities"] + .get(capability) + .is_some() + { + self.account_id.as_str() + } else if capability == CAPABILITY { + return Err(err("File storage account is no longer available.")); + } else { + self.session["primaryAccounts"][capability] + .as_str() + .or_else(|| { + self.session["accounts"].as_object().and_then(|accounts| { + accounts + .iter() + .find(|(_, a)| a["accountCapabilities"].get(capability).is_some()) + .map(|(id, _)| id.as_str()) + }) + }) + .ok_or_else(|| err("No account advertises this capability."))? + }; + args["accountId"] = json!(account); + let api = self.session["apiUrl"] + .as_str() + .ok_or_else(|| err("Missing JMAP API URL."))?; + let response = + self.transport + .json(self.transport.request("POST", api)?.json( + &json!({"using":[CORE,capability],"methodCalls":[[method,args,"files"]]}), + )) + .await?; + method_result(&response, method, account) + } + pub async fn get(&self, ids: &[String]) -> Result { + self.call("FileNode/get", CAPABILITY, json!({"ids":ids})) + .await + } + pub async fn query(&self, filter: Value, position: usize, sort: Value) -> Result { + self.call("FileNode/query",CAPABILITY,json!({"filter":filter,"position":position,"limit":PAGE_SIZE,"sort":sort,"calculateTotal":true})).await + } + pub async fn list( + &mut self, + parent: Option<&str>, + text: &str, + position: usize, + ) -> Result { + self.list_filtered(parent, text, position, None, &BrowseOptions::default()) + .await + } + pub async fn list_all(&mut self, position: usize) -> Result { + self.list_filtered( + None, + "", + position, + Some(json!({})), + &BrowseOptions::default(), + ) + .await + } + pub async fn list_options( + &mut self, + parent: Option<&str>, + text: &str, + position: usize, + options: &BrowseOptions, + ) -> Result { + self.list_filtered(parent, text, position, None, options) + .await + } + async fn list_filtered( + &mut self, + parent: Option<&str>, + text: &str, + position: usize, + override_filter: Option, + options: &BrowseOptions, + ) -> Result { + let mut filter = if text.is_empty() { + match parent { + Some(id) => json!({"parentId":id}), + None => { + if self.modern() { + json!({"isTopLevel":true}) + } else { + json!({"hasParentId":false}) + } + } + } + } else { + json!({"nameMatch":filename_pattern(text)}) + }; + if !text.is_empty() + && let Some(id) = parent + { + filter["ancestorId"] = json!(id); + } + if let Some(kind) = &options.node_type { + filter["nodeType"] = json!(kind); + } + if let Some(min) = options.min_size { + filter["minSize"] = json!(min); + } + if let Some(max) = options.max_size { + filter["maxSize"] = json!(max); + } + if let Some(value) = override_filter { + filter = value; + } + let supported = self.capabilities()["fileNodeQuerySortOptions"].as_array(); + let property = if options.sort.is_empty() { + "name" + } else { + &options.sort + }; + let sort = if supported.is_some_and(|a| a.iter().any(|v| v == property)) { + json!([{"property":property,"isAscending":!options.descending}]) + } else { + json!([]) + }; + let query = self.query(filter, position, sort).await?; + let ids: Vec = serde_json::from_value(query["ids"].clone())?; + if ids.len() > PAGE_SIZE { + return Err(err("File server exceeded the requested page limit.")); + } + let max_get = self.session["capabilities"][CORE]["maxObjectsInGet"] + .as_u64() + .unwrap_or(100) + .clamp(1, 100) as usize; + let mut nodes = Vec::new(); + let mut state = None; + // Read a state even for an empty folder so create uses a fresh precondition. + let chunks = if ids.is_empty() { + vec![&ids[..]] + } else { + ids.chunks(max_get).collect() + }; + for ids in chunks { + let result = self.get(ids).await?; + let current = result["state"] + .as_str() + .ok_or_else(|| err("Missing FileNode state."))? + .to_owned(); + if state.as_ref().is_some_and(|s| s != ¤t) { + return Err(err("Storage changed while loading. Refresh to try again.")); + } + state = Some(current); + nodes.extend(ordered_records( + ids, + serde_json::from_value::>(result["list"].clone())?, + |node| &node.id, + )?); + } + let mut ancestors = std::collections::HashMap::::new(); + for node in &nodes { + ancestors.insert(node.id.clone(), node.clone()); + } + for node in &mut nodes { + let mut parent = node.parent_id.clone(); + let mut seen = std::collections::HashSet::new(); + while let Some(id) = parent { + if !seen.insert(id.clone()) || seen.len() > 64 { + return Err(err("Invalid FileNode ancestor hierarchy.")); + } + if !ancestors.contains_key(&id) { + let result = self.get(std::slice::from_ref(&id)).await?; + if result["state"].as_str() != state.as_deref() { + return Err(err("Storage changed while resolving permissions. Refresh.")); + } + let Some(value) = result["list"].as_array().and_then(|a| a.first()) else { + break; + }; + ancestors.insert(id.clone(), serde_json::from_value(value.clone())?); + } + let ancestor = &ancestors[&id]; + node.my_rights.inherit(&ancestor.my_rights); + parent = ancestor.parent_id.clone(); + } + } + self.state = state; + let total = query["total"].as_u64().map(|n| n as usize); + let next = position + ids.len(); + Ok(FilePage { + nodes, + state: self.state.clone().unwrap_or_default(), + query_state: query["queryState"].as_str().unwrap_or_default().into(), + next_position: (!ids.is_empty() && total.map_or(ids.len() == PAGE_SIZE, |n| next < n)) + .then_some(next), + }) + } + /// RFC 8620 EventSource is a wake-up hint. Durable /changes and periodic + /// reconciliation remain authoritative after disconnects or missed events. + pub async fn wait_for_change(&self) -> Result<()> { + use futures::StreamExt; + let template = self.session["eventSourceUrl"] + .as_str() + .ok_or_else(|| err("File push is not advertised."))?; + let url = expand( + template, + &[ + ("types", "FileNode"), + ("closeafter", "state"), + ("ping", "30"), + ], + )?; + let response = self + .transport + .send( + self.transport + .request("GET", &url)? + .header("Accept", "text/event-stream"), + ) + .await?; + if !response + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("text/event-stream")) + { + return Err(err("Invalid file push response.")); + } + let mut stream = response.bytes_stream(); + let mut line = Vec::new(); + while let Some(chunk) = stream.next().await { + for byte in chunk.map_err(|_| err("File push disconnected."))? { + if byte == b'\n' { + let text = String::from_utf8_lossy(&line); + if let Some(data) = text.trim_end().strip_prefix("data:") + && let Ok(event) = serde_json::from_str::(data.trim()) + && event["@type"] == "StateChange" + && event["changed"] + .as_object() + .is_some_and(|a| a.values().any(|v| v.get("FileNode").is_some())) + { + return Ok(()); + } + line.clear(); + } else { + if line.len() >= 64 * 1024 { + return Err(err("File push event exceeds the metadata limit.")); + } + line.push(byte); + } + } + } + Err(err("File push disconnected.")) + } + pub async fn changes(&self, since: &str) -> Result { + self.call( + "FileNode/changes", + CAPABILITY, + json!({"sinceState":since,"maxChanges":1000}), + ) + .await + } + pub async fn query_changes(&self, since: &str, filter: Value, sort: Value) -> Result { + self.call( + "FileNode/queryChanges", + CAPABILITY, + json!({"sinceQueryState":since,"filter":filter,"sort":sort,"maxChanges":1000}), + ) + .await + } + pub(crate) async fn rights(&self, id: &str) -> Result { + let mut rights = super::Rights::default(); + let mut next = Some(id.to_owned()); + let mut seen = std::collections::HashSet::new(); + while let Some(id) = next { + if !seen.insert(id.clone()) || seen.len() > 64 { + return Err(err("Invalid file hierarchy.")); + } + let result = self.get(&[id]).await?; + if result["state"].as_str() != self.state.as_deref() { + return Err(err("Storage changed. Refresh before editing files.")); + } + let Some(value) = result["list"].as_array().and_then(|a| a.first()) else { + if seen.len() > 1 { + break; + } // A shared node need not expose its ancestors. + return Err(err("File no longer exists.")); + }; + let node: FileNode = serde_json::from_value(value.clone())?; + rights.inherit(&node.my_rights); + next = node.parent_id; + } + Ok(rights) + } + async fn require_destination(&self, parent: Option<&str>) -> Result<()> { + let allowed = match parent { + Some(parent) => self.rights(parent).await?.add(), + None => self.can_create_root(), + }; + if !allowed { + return Err(err("You cannot create files in this folder.")); + } + Ok(()) + } + async fn validate_set_rights(&self, args: &Value) -> Result<()> { + if let Some(create) = args["create"].as_object() { + for node in create.values() { + self.name( + node["name"] + .as_str() + .ok_or_else(|| err("File name is required."))?, + )?; + let parent = node["parentId"].as_str(); + if let Some(reference) = parent.and_then(|s| s.strip_prefix('#')) { + if !create.contains_key(reference) { + return Err(err("Unknown parent creation reference.")); + } + } else { + self.require_destination(parent).await?; + } + } + } + if let Some(update) = args["update"].as_object() { + for (id, patch) in update { + let rights = self.rights(id).await?; + for (key, _) in patch + .as_object() + .ok_or_else(|| err("Invalid file patch."))? + { + let allowed = match key.split('/').next().unwrap_or("") { + "shareWith" => rights.may_share, + "name" | "parentId" => rights.rename(), + "isSubscribed" => rights.may_read, + _ => rights.modify(), + }; + if !allowed { + return Err(err("You do not have permission to change this file.")); + } + } + if patch.get("parentId").is_some() { + self.require_destination(patch["parentId"].as_str()).await?; + } + } + } + if let Some(destroy) = args["destroy"].as_array() { + for id in destroy { + if !self + .rights(id.as_str().ok_or_else(|| err("Invalid file ID."))?) + .await? + .delete() + { + return Err(err("You cannot delete this file.")); + } + } + } + Ok(()) + } + pub async fn set(&mut self, mut args: Value) -> Result { + if self.read_only() { + return Err(err("This storage account is read-only.")); + } + self.validate_set_rights(&args).await?; + let state = self + .state + .as_ref() + .ok_or_else(|| err("Refresh storage before changing files."))?; + args["ifInState"] = json!(state); + if !matches!(self.collision, super::CollisionPolicy::Reject) { + args["onExists"] = json!(self.collision); + } + args["compareCaseInsensitively"] = json!(self.compare_case_insensitively); + let result = self.call("FileNode/set", CAPABILITY, args).await?; + self.state = result["newState"].as_str().map(str::to_owned); + Ok(result) + } + pub async fn create_folder(&mut self, parent: Option<&str>, name: &str) -> Result { + self.name(name)?; + self.set(json!({"create":{"new":{"name":name,"parentId":parent,"blobId":null}}})) + .await + } + pub async fn update(&mut self, id: &str, patch: Value) -> Result { + if let Some(name) = patch.get("name").and_then(Value::as_str) { + self.name(name)?; + } + self.set(json!({"update":{id:patch}})).await + } + pub async fn delete(&mut self, id: &str, recursive: bool) -> Result { + self.set(json!({"destroy":[id],"onDestroyRemoveChildren":recursive})) + .await + } + /// RFC 8620 /copy is cross-account. Within one account, create nodes + /// referencing existing blobs; creation references preserve the hierarchy. + pub async fn copy(&mut self, id: &str, parent: Option<&str>, name: &str) -> Result { + self.name(name)?; + let state = self + .state + .clone() + .ok_or_else(|| err("Refresh before copying files."))?; + let source = self.get(&[id.to_owned()]).await?; + if source["state"] != state { + return Err(err("Storage changed. Refresh before copying.")); + } + let root: FileNode = serde_json::from_value( + source["list"] + .as_array() + .and_then(|a| a.first()) + .ok_or_else(|| err("Source file no longer exists."))? + .clone(), + )?; + let limit = self.session["capabilities"][CORE]["maxObjectsInSet"] + .as_u64() + .unwrap_or(100) + .min(10000) as usize; + let mut nodes = vec![root]; + let mut index = 0; + while index < nodes.len() { + if nodes[index].is_directory() { + let mut position = 0; + loop { + let page = self.list(Some(&nodes[index].id), "", position).await?; + if page.state != state { + self.state = Some(state); + return Err(err("Storage changed. Refresh before copying.")); + } + if nodes.len() + page.nodes.len() > limit { + return Err(err( + "This folder exceeds the server's batch copy limit. Copy smaller subfolders.", + )); + } + nodes.extend(page.nodes); + if let Some(next) = page.next_position { + position = next; + } else { + break; + } + } + } + index += 1; + } + if parent.is_some_and(|parent| nodes.iter().any(|n| n.id == parent)) { + return Err(err("A folder cannot be copied into itself.")); + } + let ids = nodes + .iter() + .enumerate() + .map(|(i, n)| (n.id.clone(), format!("copy{i}"))) + .collect::>(); + let mut create = serde_json::Map::new(); + for (index, node) in nodes.iter().enumerate() { + let parent_id = if index == 0 { + parent.map(str::to_owned) + } else { + node.parent_id + .as_ref() + .and_then(|id| ids.get(id)) + .map(|id| format!("#{id}")) + }; + let mut value = json!({"name":if index==0 {name} else {&node.name},"parentId":parent_id,"blobId":node.blob_id}); + if !node.is_directory() { + value["type"] = json!(node.media_type); + value["executable"] = json!(node.executable); + } + // Same-account copies use /set, so preserve source timestamps + // explicitly. Otherwise Keep newest would compare a fresh default + // timestamp and could replace a newer destination with older data. + for (property, date) in [ + ("created", &node.created), + ("modified", &node.modified), + ("accessed", &node.accessed), + ] { + if let Some(date) = date { + value[property] = json!(date); + } + } + if matches!(self.collision, super::CollisionPolicy::Newest) && node.modified.is_none() { + return Err(err( + "Source modification time is unavailable for Keep newest.", + )); + } + create.insert(format!("copy{index}"), value); + } + self.state = Some(state); + self.set(json!({"create":create})).await + } + pub async fn copy_from_account( + &mut self, + from_account: &str, + from_state: &str, + id: &str, + parent: Option<&str>, + name: &str, + ) -> Result { + self.name(name)?; + self.require_destination(parent).await?; + if from_account == self.account_id { + return self.copy(id, parent, name).await; + } + if self.read_only() { + return Err(err("This storage account is read-only.")); + } + let mut source = self.clone(); + source.select_account(from_account)?; + source.state = Some(from_state.into()); + let root = source.get(&[id.into()]).await?; + if root["state"].as_str() != Some(from_state) { + return Err(err("Source storage changed. Refresh before copying.")); + } + let node: FileNode = serde_json::from_value( + root["list"] + .as_array() + .and_then(|a| a.first()) + .ok_or_else(|| err("Source file was not found."))? + .clone(), + )?; + let mut nodes = vec![node]; + let mut seen = std::collections::HashSet::from([id.to_owned()]); + let mut index = 0; + // Gather and validate the source before changing the destination. + while index < nodes.len() { + self.name(&nodes[index].name)?; + if nodes[index].is_directory() { + let mut position = 0; + loop { + let page = source.list(Some(&nodes[index].id), "", position).await?; + if page.state != from_state { + return Err(err("Source storage changed. Refresh before copying.")); + } + if nodes.len() + page.nodes.len() > 1000 { + return Err(err( + "Copy at most 1,000 nodes between storage spaces at once.", + )); + } + for node in page.nodes { + if !seen.insert(node.id.clone()) { + return Err(err("Invalid source file hierarchy.")); + } + nodes.push(node); + } + match page.next_position { + Some(next) => position = next, + None => break, + } + } + } + index += 1; + } + let mut created = serde_json::Map::new(); + let initial_state = self + .state + .clone() + .ok_or_else(|| err("Refresh before copying files."))?; + for (index, node) in nodes.iter().enumerate() { + let destination_parent = if index == 0 { + parent.map(str::to_owned) + } else { + Some( + created + .get(node.parent_id.as_deref().unwrap_or("")) + .and_then(|v: &Value| v["id"].as_str()) + .ok_or_else(|| err("Copied parent folder is missing."))? + .to_owned(), + ) + }; + // Stalwart indexes the create map by source ID. Each folder must + // exist before copying its children into the returned destination ID. + let result = self.call("FileNode/copy", CAPABILITY, json!({"fromAccountId":from_account,"ifFromInState":from_state,"ifInState":self.state,"create":{&node.id:{"parentId":destination_parent,"name":if index == 0 {name} else {&node.name}}},"onSuccessDestroyOriginal":false,"onExists":if matches!(self.collision,super::CollisionPolicy::Reject){Value::Null}else{json!(self.collision)},"compareCaseInsensitively":self.compare_case_insensitively})).await + .map_err(|e| err(format!("Copy stopped after {} items: {e}. Refresh the destination before retrying.", created.len())))?; + let value = result["created"][&node.id].clone(); + if value["id"].as_str().is_none() { + return Err(err( + "Copy response omitted the new file ID. Refresh before retrying.", + )); + } + created.insert(node.id.clone(), value); + self.state = result["newState"].as_str().map(str::to_owned); + } + Ok( + json!({"accountId":self.account_id,"fromAccountId":from_account,"oldState":initial_state,"newState":self.state,"created":created}), + ) + } + + pub async fn upload( + &mut self, + parent: Option<&str>, + name: &str, + path: &Path, + replace: Option<&str>, + ) -> Result { + self.name(name)?; + let limit = self.session["capabilities"][CORE]["maxSizeUpload"] + .as_u64() + .unwrap_or(MAX_TRANSFER as u64) + .min(MAX_TRANSFER as u64) as usize; + let (data, length) = super::upload_body(path, limit).await?; + let url = expand( + self.session["uploadUrl"] + .as_str() + .ok_or_else(|| err("Missing upload URL."))?, + &[("accountId", &self.account_id)], + )?; + let uploaded = self + .transport + .json( + self.transport + .request("POST", &url)? + .header("Content-Length", length) + .header("Content-Type", crate::queue::mime_guess_from_name(name)) + .body(data), + ) + .await?; + if uploaded["accountId"] != self.account_id { + return Err(err("Upload account mismatch.")); + } + let blob = uploaded["blobId"] + .as_str() + .ok_or_else(|| err("Upload did not return a blob."))?; + let mut value = json!({"blobId":blob,"type":uploaded["type"].as_str().unwrap_or("application/octet-stream"),"modified":chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs,true)}); + if let Some(id) = replace { + self.update(id, value).await + } else { + value["name"] = json!(name); + value["parentId"] = json!(parent); + self.set(json!({"create":{"new":value}})).await + } + } + async fn download_response(&self, node: &FileNode) -> Result { + let blob = node + .blob_id + .as_deref() + .ok_or_else(|| err("Folders cannot be downloaded as individual files."))?; + let url = expand( + self.session["downloadUrl"] + .as_str() + .ok_or_else(|| err("Missing download URL."))?, + &[ + ("accountId", &self.account_id), + ("blobId", blob), + ("name", &node.name), + ( + "type", + node.media_type + .as_deref() + .unwrap_or("application/octet-stream"), + ), + ], + )?; + self.transport + .send(self.transport.request("GET", &url)?) + .await + } + pub async fn download(&self, node: &FileNode) -> Result> { + crate::http_body::bytes( + self.download_response(node).await?, + MAX_TRANSFER, + "File download", + ) + .await + } + pub async fn preview(&self, node: &FileNode) -> Result> { + crate::http_body::bytes( + self.download_response(node).await?, + 16 * 1024 * 1024, + "File preview", + ) + .await + } + pub async fn download_to(&self, node: &FileNode, destination: &Path) -> Result<()> { + super::save_response(destination, self.download_response(node).await?).await + } + pub async fn quota(&self) -> Result { + self.call("Quota/get", QUOTA, json!({"ids":null})).await + } + pub async fn principals(&self, text: &str) -> Result { + self.call( + "Principal/query", + SHARING, + json!({"filter":{"text":text},"limit":100}), + ) + .await + } + pub async fn resolve_principal(&self, recipient: &str) -> Result { + if !recipient.contains('@') { + return Ok(recipient.into()); + } + let result = self.principals(recipient).await?; + let ids = result["ids"] + .as_array() + .ok_or_else(|| err("Invalid principal lookup."))?; + let result = self + .call("Principal/get", SHARING, json!({"ids":ids})) + .await?; + let matches = result["list"] + .as_array() + .ok_or_else(|| err("Invalid principal lookup."))? + .iter() + .filter(|p| { + p["email"] + .as_str() + .is_some_and(|email| email.eq_ignore_ascii_case(recipient)) + }) + .filter_map(|p| p["id"].as_str()) + .collect::>(); + if matches.len() != 1 { + return Err(err( + "The recipient could not be uniquely identified. Use their principal ID if directory lookup is disabled.", + )); + } + Ok(matches[0].into()) + } + pub async fn dismiss_share_notifications(&self, ids: &[String], state: &str) -> Result { + if ids.len() > 100 { + return Err(err("Too many sharing notifications.")); + } + self.call( + "ShareNotification/set", + SHARING, + json!({"ifInState":state,"destroy":ids}), + ) + .await + } + pub async fn share_notifications(&self) -> Result { + { + let query = self.call("ShareNotification/query", SHARING, json!({"filter":{"objectType":"FileNode"},"sort":[{"property":"created","isAscending":false}],"limit":100})).await?; + self.call( + "ShareNotification/get", + SHARING, + json!({"ids":query["ids"]}), + ) + .await + } + } +} +fn method_result(response: &Value, method: &str, account: &str) -> Result { + let calls = response["methodResponses"] + .as_array() + .ok_or_else(|| err("Invalid JMAP response."))?; + let call = calls + .iter() + .find(|c| c[2] == "files") + .ok_or_else(|| err("Missing JMAP method response."))?; + if call[0] == "error" { + return Err(err(format!( + "File operation failed: {}", + call[1]["type"].as_str().unwrap_or("unknown error") + ))); + } + if call[0] != method || call[1]["accountId"] != account { + return Err(err("JMAP response did not match the request.")); + } + for key in ["notCreated", "notUpdated", "notDestroyed"] { + if let Some(errors) = call[1][key].as_object().filter(|e| !e.is_empty()) { + let kinds = errors + .values() + .filter_map(|v| v["type"].as_str()) + .collect::>() + .join(", "); + return Err(err(format!( + "Some file operations failed ({kinds}). Refresh to inspect completed changes before retrying." + ))); + } + } + Ok(call[1].clone()) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_success_status_with_method_or_object_errors() { + for value in [ + json!({"methodResponses":[["error",{"type":"stateMismatch"},"files"]]}), + json!({"methodResponses":[["FileNode/set",{"accountId":"a","notUpdated":{"x":{"type":"forbidden"}}},"files"]]}), + json!({"methodResponses":[["FileNode/set",{"accountId":"other"},"files"]]}), + ] { + assert!(method_result(&value, "FileNode/set", "a").is_err()); + } + } + #[test] + fn opaque_template_values_are_encoded() { + let url = expand( + "https://example.org/{accountId}/{blobId}/{name}?type={type}", + &[ + ("accountId", "a/b"), + ("blobId", "#?"), + ("name", "a b.pdf"), + ("type", "text/plain"), + ], + ) + .unwrap(); + assert_eq!( + url, + "https://example.org/a%2Fb/%23%3F/a%20b.pdf?type=text%2Fplain" + ); + } +} diff --git a/crates/flectar-mail-core/src/files/mail_search.rs b/crates/flectar-mail-core/src/files/mail_search.rs new file mode 100644 index 0000000..3c210b3 --- /dev/null +++ b/crates/flectar-mail-core/src/files/mail_search.rs @@ -0,0 +1,286 @@ +//! Read-only JMAP Mail attachment search across server history. Results remain +//! transient, so changing a Files search never widens Mail's synchronization +//! window or inserts duplicate messages into its projection. +use super::{ + AttachmentFile, PAGE_SIZE, err, + transport::{Transport, expand, ordered_records}, +}; +use crate::{ + Core, + accounts::credentials::{self, Slot}, + error::Result, + models::AuthKind, +}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +const MAIL: &str = "urn:ietf:params:jmap:mail"; +#[derive(Clone, Debug)] +pub struct RemoteAttachment { + pub account: String, + pub email: String, + pub blob: String, +} +pub struct MailSearch { + transport: Transport, + session: Value, + account: String, + local_account: i64, +} +pub struct Page { + pub files: Vec, + pub next: Option, + pub state: String, +} +impl Core { + pub async fn connect_attachment_search(&self, account: i64) -> Result { + let config = self + .list_account_configs() + .await? + .into_iter() + .find(|a| a.id == account) + .ok_or_else(|| err("Account was removed."))?; + if config.auth_kind != AuthKind::Password { + return Err(err( + "Server attachment search requires a JMAP password or application-password account.", + )); + } + let secret = + credentials::load_async(self.credentials.clone(), account, Slot::Password).await?; + let base = crate::jmap::client::normalize_base_url(&config.jmap_url, &config.email)?; + let transport = Transport::new( + &base, + if config.username.is_empty() { + &config.email + } else { + &config.username + }, + &secret, + )?; + let session = transport + .discover(&format!("{base}/.well-known/jmap")) + .await?; + MailSearch::from_session( + transport, + session, + account, + config.jmap_account_id.as_deref(), + ) + } + pub async fn attachment_file_content(&self, file: &AttachmentFile) -> Result { + let Some(remote) = &file.remote else { + return Ok(PathBuf::from(self.get_attachment(file.id).await?)); + }; + if !self + .list_account_configs() + .await? + .iter() + .any(|a| a.id == file.account_id) + { + return Err(err("The account was removed.")); + } + // Resolve to the existing Mail attachment cache whenever synchronized. + let account = file.account_id; + let email = remote.email.clone(); + let blob = remote.blob.clone(); + let local=self.db.read(move |c| {use rusqlite::OptionalExtension;Ok(c.query_row("SELECT a.id FROM attachments a JOIN messages m ON m.id=a.message_id WHERE m.account_id=?1 AND m.jmap_id=?2 AND a.jmap_blob_id=?3",rusqlite::params![account,email,blob],|r|r.get::<_,i64>(0)).optional()?)}).await?; + if let Some(id) = local { + return Ok(PathBuf::from(self.get_attachment(id).await?)); + } + let key = format!( + "{:x}", + Sha256::digest(format!("{}\0{}", remote.account, remote.blob)) + ); + let directory = self.paths.attachments_dir(file.account_id).join("remote"); + tokio::fs::create_dir_all(&directory).await?; + let path = directory.join(key); + if tokio::fs::try_exists(&path).await? { + return Ok(path); + } + let client = self.connect_attachment_search(file.account_id).await?; + if client.account != remote.account { + return Err(err("The mail account changed. Repeat the server search.")); + } + // Remote search downloads share Mail's cache directory. Bound this + // reconstructable area and remove oldest entries before another transfer. + let mut entries = tokio::fs::read_dir(&directory).await?; + let mut cached = Vec::new(); + let mut total = 0u64; + while let Some(entry) = entries.next_entry().await? { + if !entry.file_type().await?.is_file() { + continue; + } + let metadata = entry.metadata().await?; + total = total.saturating_add(metadata.len()); + cached.push((metadata.modified().ok(), entry.path(), metadata.len())); + } + cached.sort_by_key(|(time, _, _)| *time); + for (_, old, size) in cached { + if total <= 1536 * 1024 * 1024 { + break; + } + tokio::fs::remove_file(old).await?; + total = total.saturating_sub(size); + } + let url = expand( + client.session["downloadUrl"] + .as_str() + .ok_or_else(|| err("Missing mail download URL."))?, + &[ + ("accountId", &remote.account), + ("blobId", &remote.blob), + ("name", &file.filename), + ("type", &file.media_type), + ], + )?; + let response = client + .transport + .send(client.transport.request("GET", &url)?) + .await?; + super::save_response(&path, response).await?; + Ok(path) + } +} +impl MailSearch { + fn from_session( + transport: Transport, + session: Value, + local_account: i64, + preferred: Option<&str>, + ) -> Result { + if session["capabilities"].get(MAIL).is_none() { + return Err(err("This server does not advertise JMAP Mail.")); + } + let account = preferred + .or_else(|| session["primaryAccounts"][MAIL].as_str()) + .ok_or_else(|| err("No mail account is available."))? + .to_owned(); + if session["accounts"][&account]["accountCapabilities"] + .get(MAIL) + .is_none() + { + return Err(err("The selected account does not support JMAP Mail.")); + } + let api = session["apiUrl"] + .as_str() + .ok_or_else(|| err("Missing mail API URL."))?; + let _ = transport.request("POST", api)?; + Ok(Self { + transport, + session, + account, + local_account, + }) + } + async fn call(&self, method: &str, mut args: Value) -> Result { + args["accountId"] = json!(self.account); + let api = self.session["apiUrl"] + .as_str() + .ok_or_else(|| err("Missing mail API URL."))?; + let response=self.transport.json(self.transport.request("POST",api)?.json(&json!({"using":["urn:ietf:params:jmap:core",MAIL],"methodCalls":[[method,args,"attachments"]]}))).await?; + let row = response["methodResponses"] + .as_array() + .and_then(|rows| rows.iter().find(|r| r[2] == "attachments")) + .ok_or_else(|| err("Missing mail search response."))?; + if row[0] != method || row[1]["accountId"] != self.account { + return Err(err(format!( + "Mail search failed: {}", + row[1]["type"].as_str().unwrap_or("invalid response") + ))); + } + Ok(row[1].clone()) + } + /// RFC 8621 text includes indexed attachment text supported by the server. + /// All attachments of matching messages are returned; no claim is made + /// that every individual attachment contains the query. + pub async fn search(&self, text: &str, position: usize) -> Result { + if text.len() > 1024 { + return Err(err("Search is too long.")); + } + let mut filter = json!({"hasAttachment":true}); + if !text.is_empty() { + filter["text"] = json!(text); + } + let query=self.call("Email/query",json!({"filter":filter,"position":position,"limit":PAGE_SIZE,"sort":[{"property":"receivedAt","isAscending":false}],"collapseThreads":false})).await?; + let ids: Vec = serde_json::from_value(query["ids"].clone()) + .map_err(|_| err("Invalid mail search IDs."))?; + if ids.len() > PAGE_SIZE { + return Err(err("Mail server exceeded the requested page limit.")); + } + let state = query["queryState"] + .as_str() + .ok_or_else(|| err("Missing mail query state."))? + .to_owned(); + let mut files = Vec::new(); + let max = self.session["capabilities"]["urn:ietf:params:jmap:core"]["maxObjectsInGet"] + .as_u64() + .unwrap_or(100) + .clamp(1, 100) as usize; + for chunk in ids.chunks(max) { + let result=self.call("Email/get",json!({"ids":chunk,"properties":["id","from","subject","receivedAt","attachments"]})).await?; + let list = result["list"] + .as_array() + .ok_or_else(|| err("Invalid mail search results."))?; + for email in ordered_records(chunk, list.clone(), |email| { + email["id"].as_str().unwrap_or("") + })? { + let id = email["id"] + .as_str() + .ok_or_else(|| err("Missing email identifier."))?; + let sender = email["from"] + .as_array() + .into_iter() + .flatten() + .map(|a| { + format!( + "{} {}", + a["name"].as_str().unwrap_or(""), + a["email"].as_str().unwrap_or("") + ) + }) + .collect::>() + .join(", "); + for part in email["attachments"].as_array().into_iter().flatten() { + let Some(blob) = part["blobId"].as_str() else { + continue; + }; + let name = part["name"].as_str().unwrap_or("attachment"); + if part["disposition"] == "inline" + && part["name"].as_str().is_none_or(str::is_empty) + { + continue; + } + files.push(AttachmentFile { + id: 0, + account_id: self.local_account, + thread_id: None, + filename: name.into(), + media_type: part["type"] + .as_str() + .unwrap_or("application/octet-stream") + .into(), + size: part["size"].as_u64().unwrap_or(0), + sender: sender.clone(), + subject: email["subject"].as_str().unwrap_or("").into(), + date: email["receivedAt"] + .as_str() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|d| d.timestamp_millis()) + .unwrap_or(0), + cached: false, + remote: Some(RemoteAttachment { + account: self.account.clone(), + email: id.into(), + blob: blob.into(), + }), + }); + } + } + } + Ok(Page { + files, + next: (ids.len() == PAGE_SIZE).then_some(position + ids.len()), + state, + }) + } +} diff --git a/crates/flectar-mail-core/src/files/mod.rs b/crates/flectar-mail-core/src/files/mod.rs new file mode 100644 index 0000000..34f727b --- /dev/null +++ b/crates/flectar-mail-core/src/files/mod.rs @@ -0,0 +1,475 @@ +//! File storage and a paged, account-scoped attachment library. +//! Remote credentials stay in the core and are bound to the configured origin. +pub mod cache; +pub mod dav; +pub mod jmap; +pub mod mail_search; +pub mod progress; +#[cfg(test)] +#[path = "tests.rs"] +mod protocol_tests; +pub mod service; +#[cfg(test)] +mod storage_tests; +pub mod store; +pub mod sync; +mod text_index; +mod transport; + +use crate::{ + Core, + accounts::credentials::{self, Slot}, + error::{CoreError, Result}, + models::AuthKind, +}; +use rusqlite::{OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CollisionPolicy { + #[default] + Reject, + Rename, + Replace, + Newest, +} + +pub const PAGE_SIZE: usize = 100; +pub const MAX_TRANSFER: usize = 512 * 1024 * 1024; + +pub(crate) fn err(message: impl Into) -> CoreError { + CoreError::Other(message.into()) +} + +/// Only a leaf name: never allow remote names to become local paths. +pub fn validate_name(name: &str) -> Result<()> { + if name.is_empty() + || name == "." + || name == ".." + || name.len() > 255 + || name + .chars() + .any(|c| c.is_control() || matches!(c, '/' | '\\')) + { + return Err(err( + "Enter a file name of 1–255 bytes without slashes or control characters.", + )); + } + Ok(()) +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Rights { + #[serde(default)] + pub may_read: bool, + #[serde(default)] + pub may_add_children: bool, + #[serde(default)] + pub may_rename: bool, + #[serde(default)] + pub may_delete: bool, + #[serde(default)] + pub may_modify_content: bool, + #[serde(default)] + pub may_share: bool, + // Older Stalwart FileNode drafts use one write right. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub may_write: bool, +} +impl Rights { + /// FileNode ACLs are additive and inherited by descendants. + pub(crate) fn inherit(&mut self, parent: &Self) { + self.may_read |= parent.may_read; + self.may_add_children |= parent.may_add_children; + self.may_rename |= parent.may_rename; + self.may_delete |= parent.may_delete; + self.may_modify_content |= parent.may_modify_content; + self.may_share |= parent.may_share; + self.may_write |= parent.may_write; + } + pub fn add(&self) -> bool { + self.may_add_children || self.may_write + } + pub fn rename(&self) -> bool { + self.may_rename || self.may_write + } + pub fn delete(&self) -> bool { + self.may_delete || self.may_write + } + pub fn modify(&self) -> bool { + self.may_modify_content || self.may_write + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileNode { + pub id: String, + pub name: String, + #[serde(default)] + pub parent_id: Option, + #[serde(default)] + pub blob_id: Option, + #[serde(default)] + pub size: Option, + #[serde(default, rename = "type")] + pub media_type: Option, + #[serde(default)] + pub node_type: Option, + #[serde(default)] + pub modified: Option, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub accessed: Option, + #[serde(default, deserialize_with = "nullable_bool")] + pub executable: bool, + #[serde(default)] + pub is_subscribed: Option, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub my_rights: Rights, + #[serde(default)] + pub share_with: Option, + /// WebDAV opaque strong validator; never manufacture one. + #[serde(default)] + pub etag: Option, + #[serde(default)] + pub locked: bool, +} +fn nullable_bool<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result { + Ok(Option::::deserialize(deserializer)?.unwrap_or(false)) +} + +impl FileNode { + pub fn is_directory(&self) -> bool { + self.node_type + .as_deref() + .map_or(self.blob_id.is_none(), |kind| kind == "directory") + } +} + +#[derive(Clone, Debug)] +pub struct AttachmentFile { + pub remote: Option, + pub id: i64, + pub account_id: i64, + pub thread_id: Option, + pub filename: String, + pub media_type: String, + pub size: u64, + pub sender: String, + pub subject: String, + pub date: i64, + pub cached: bool, +} +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ConnectionSettings { + pub endpoint: String, + pub webdav: bool, +} + +fn attachment_match_query(query: &str) -> String { + query + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| !s.is_empty()) + .take(32) + .map(|s| format!("\"{s}\"*")) + .collect::>() + .join(" AND ") +} + +fn attachment_page( + conn: &rusqlite::Connection, + account: Option, + query: &str, + before: Option, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT a.id, m.account_id, m.thread_id, COALESCE(NULLIF(a.filename,''),'attachment'), + COALESCE(a.mime_type,'application/octet-stream'), COALESCE(a.size,0), + trim(COALESCE(m.from_name,'') || ' ' || COALESCE(m.from_addr,'')), m.subject, m.date, + a.file_path IS NOT NULL FROM attachments a JOIN messages m ON m.id=a.message_id + WHERE (?1 IS NULL OR m.account_id=?1) AND (?2 IS NULL OR a.id < ?2) + AND (a.is_inline=0 OR length(COALESCE(a.filename,''))>0) + AND (?3='' OR a.id IN (SELECT rowid FROM attachment_files_fts WHERE attachment_files_fts MATCH ?3) OR a.id IN (SELECT rowid FROM attachment_text_fts WHERE attachment_text_fts MATCH ?3)) + ORDER BY a.id DESC LIMIT ?4")?; + Ok(stmt + .query_map( + params![account, before, query, (PAGE_SIZE + 1) as i64], + |r| { + Ok(AttachmentFile { + remote: None, + id: r.get(0)?, + account_id: r.get(1)?, + thread_id: r.get(2)?, + filename: r.get(3)?, + media_type: r.get(4)?, + size: r.get::<_, i64>(5)?.max(0) as u64, + sender: r.get(6)?, + subject: r.get(7)?, + date: r.get(8)?, + cached: r.get(9)?, + }) + }, + )? + .collect::>>()?) +} + +impl Core { + /// SQL pagination never hydrates message bodies or marks messages read. + /// Includes named inline parts, but hides unnamed signature/CID images. + pub async fn attachment_files( + &self, + account: Option, + query: String, + before: Option, + ) -> Result> { + if query.len() > 1024 { + return Err(err("Search is too long.")); + } + let query = attachment_match_query(&query); + self.db + .read(move |conn| attachment_page(conn, account, &query, before)) + .await + } + pub async fn file_connection_settings(&self, account: i64) -> Result { + self.files_db + .read(move |conn| { + let value: Option = conn + .query_row( + "SELECT settings_json FROM connections WHERE account_id=?1", + [account], + |r| r.get(0), + ) + .optional()?; + Ok(value + .map(|v| serde_json::from_str(&v)) + .transpose()? + .unwrap_or_default()) + }) + .await + } + pub async fn save_file_connection_settings( + &self, + account: i64, + settings: ConnectionSettings, + ) -> Result<()> { + let value = serde_json::to_string(&settings)?; + self.files_db.write(move |conn| { + let tx=conn.transaction()?; + let old:Option=tx.query_row("SELECT settings_json FROM connections WHERE account_id=?1",[account],|r|r.get(0)).optional()?; + if old.as_ref().is_some_and(|old|old!=&value) { + tx.execute("UPDATE operations SET state='cancelled',error='Storage connection changed. Select the source and destination again.' WHERE account_id=?1 AND state IN ('preparing','queued')",[account])?; + tx.execute("DELETE FROM spaces WHERE account_id=?1",[account])?; + tx.execute("UPDATE connections SET selected_space=NULL WHERE account_id=?1",[account])?; + } + tx.execute("INSERT INTO connections(account_id,settings_json,updated_at) VALUES(?1,?2,?3) ON CONFLICT(account_id) DO UPDATE SET settings_json=excluded.settings_json,updated_at=excluded.updated_at",params![account,value,chrono::Utc::now().timestamp_millis()])?; + tx.commit()?; Ok(()) + }).await + } + pub async fn connect_files( + &self, + account: i64, + settings: &ConnectionSettings, + ) -> Result { + let config = self + .list_account_configs() + .await? + .into_iter() + .find(|a| a.id == account) + .ok_or_else(|| err("Account was removed."))?; + if config.auth_kind != AuthKind::Password { + return Err(err( + "Connect storage using a Stalwart password or application-password account.", + )); + } + let secret = + credentials::load_async(self.credentials.clone(), account, Slot::Password).await?; + let user = if config.username.is_empty() { + &config.email + } else { + &config.username + }; + if settings.webdav { + Ok(FileClient::Dav( + dav::DavClient::connect(&settings.endpoint, user, &secret).await?, + )) + } else { + let endpoint = if settings.endpoint.is_empty() { + &config.jmap_url + } else { + &settings.endpoint + }; + let base = crate::jmap::client::normalize_base_url(endpoint, &config.email)?; + Ok(FileClient::Jmap( + jmap::FileClient::connect(&base, user, &secret, config.jmap_account_id.as_deref()) + .await?, + )) + } + } +} + +pub enum FileClient { + Jmap(jmap::FileClient), + Dav(dav::DavClient), +} + +/// Save via a private sibling temporary file. A failed/cancelled transfer cannot +/// truncate an existing destination; persist_noclobber also closes the TOCTOU race. +pub async fn save_bytes(destination: &Path, bytes: &[u8]) -> Result<()> { + let parent = destination + .parent() + .ok_or_else(|| err("Invalid destination."))?; + let temp = tempfile::NamedTempFile::new_in(parent)?; + tokio::fs::write(temp.path(), bytes).await?; + tokio::fs::File::open(temp.path()).await?.sync_all().await?; + temp.persist_noclobber(destination) + .map_err(|e| err(format!("Could not save file: {}", e.error)))?; + Ok(()) +} + +/// Stream a selected file with a fixed byte length. The opened handle pins the +/// source; a file growing during upload cannot exceed the advertised boundary. +pub(crate) async fn upload_body(path: &Path, limit: usize) -> Result<(reqwest::Body, u64)> { + use tokio::io::AsyncReadExt; + let file = tokio::fs::File::open(path).await?; + let metadata = file.metadata().await?; + if !metadata.is_file() || metadata.len() > limit as u64 { + return Err(err( + "Select a regular file within the storage upload limit.", + )); + } + let length = metadata.len(); + let progress = progress::begin(1, length); + let stream = + futures::stream::try_unfold((file.take(length), 0_u64), move |(mut file, read)| { + let progress = progress.clone(); + async move { + if read == length { + return Ok::<_, std::io::Error>(None); + } + let mut chunk = vec![0_u8; 64 * 1024]; + let count = file.read(&mut chunk).await?; + if count == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "Upload file changed while reading", + )); + } + if let Some(p) = &progress { + p.advance(count as u64); + } + chunk.truncate(count); + Ok(Some((chunk, (file, read + count as u64)))) + } + }); + Ok((reqwest::Body::wrap_stream(stream), length)) +} + +pub(crate) async fn save_response( + destination: &Path, + mut response: reqwest::Response, +) -> Result<()> { + use tokio::io::AsyncWriteExt; + if response + .content_length() + .is_some_and(|n| n > MAX_TRANSFER as u64) + { + return Err(err("File exceeds the download limit.")); + } + let temp = tempfile::NamedTempFile::new_in( + destination + .parent() + .ok_or_else(|| err("Invalid destination."))?, + )?; + let mut file = tokio::fs::File::from_std(temp.reopen()?); + let progress = progress::begin(2, response.content_length().unwrap_or(0)); + let mut total = 0usize; + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| err("Download interrupted."))? + { + if chunk.len() > MAX_TRANSFER.saturating_sub(total) { + return Err(err("File exceeds the download limit.")); + } + file.write_all(&chunk).await?; + if let Some(p) = &progress { + p.advance(chunk.len() as u64); + } + total += chunk.len(); + } + file.sync_all().await?; + drop(file); + temp.persist_noclobber(destination) + .map_err(|e| err(format!("Could not save file: {}", e.error)))?; + Ok(()) +} +/// Attachment downloads use the existing single-flight cache, then copy via a +/// sibling temporary file so cancellation cannot leave a partial destination. +pub async fn save_cached_file(source: &Path, destination: &Path) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let metadata = tokio::fs::metadata(source).await?; + if !metadata.is_file() { + return Err(err("Select a regular file.")); + } + let progress = progress::begin(3, metadata.len()); + let mut source = tokio::fs::File::open(source) + .await? + .take(MAX_TRANSFER as u64 + 1); + let temp = tempfile::NamedTempFile::new_in( + destination + .parent() + .ok_or_else(|| err("Invalid destination."))?, + )?; + let mut file = tokio::fs::File::from_std(temp.reopen()?); + let mut count = 0; + let mut buffer = vec![0; 64 * 1024]; + loop { + let read = source.read(&mut buffer).await?; + if read == 0 { + break; + } + file.write_all(&buffer[..read]).await?; + count += read as u64; + if let Some(p) = &progress { + p.advance(read as u64); + } + } + if count > MAX_TRANSFER as u64 { + return Err(err("File exceeds the download limit.")); + } + file.flush().await?; + file.sync_all().await?; + drop(file); + temp.persist_noclobber(destination) + .map_err(|e| err(format!("Could not save file: {}", e.error)))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_path_names() { + for name in ["", ".", "..", "../secret", "a/b", "a\\b", "a\n"] { + assert!(validate_name(name).is_err()); + } + assert!(validate_name("Résumé & budget.pdf").is_ok()); + } + #[tokio::test] + async fn save_does_not_clobber() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("file"); + save_bytes(&path, b"original").await.unwrap(); + assert!(save_bytes(&path, b"replacement").await.is_err()); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } +} diff --git a/crates/flectar-mail-core/src/files/progress.rs b/crates/flectar-mail-core/src/files/progress.rs new file mode 100644 index 0000000..38ff90c --- /dev/null +++ b/crates/flectar-mail-core/src/files/progress.rs @@ -0,0 +1,55 @@ +//! Optional transfer telemetry scoped to a caller's async operation. Protocol +//! correctness never depends on an observer or a native UI being present. +use std::sync::{ + Arc, + atomic::{AtomicU8, AtomicU64, Ordering}, +}; +#[derive(Default)] +pub struct Progress { + done: AtomicU64, + total: AtomicU64, + phase: AtomicU8, +} +#[derive(Clone, Copy, Debug)] +pub struct Snapshot { + pub done: u64, + pub total: u64, + pub phase: u8, +} +impl Progress { + pub fn snapshot(&self) -> Snapshot { + Snapshot { + done: self.done.load(Ordering::Relaxed), + total: self.total.load(Ordering::Relaxed), + phase: self.phase.load(Ordering::Relaxed), + } + } + pub fn reset(&self) { + self.done.store(0, Ordering::Relaxed); + self.total.store(0, Ordering::Relaxed); + self.phase.store(0, Ordering::Relaxed); + } + fn begin(&self, phase: u8, total: u64) { + self.done.store(0, Ordering::Relaxed); + self.total.store(total, Ordering::Relaxed); + self.phase.store(phase, Ordering::Relaxed); + } + pub(crate) fn advance(&self, bytes: u64) { + self.done.fetch_add(bytes, Ordering::Relaxed); + } +} +tokio::task_local! {static CURRENT:Arc;} +pub async fn track(observer: Arc, work: impl std::future::Future) -> T { + CURRENT.scope(observer, work).await +} +pub(crate) fn begin(phase: u8, total: u64) -> Option> { + CURRENT + .try_with(|p| { + p.begin(phase, total); + p.clone() + }) + .ok() +} +pub(crate) fn bytes() -> u64 { + CURRENT.try_with(|p| p.snapshot().done).unwrap_or(0) +} diff --git a/crates/flectar-mail-core/src/files/service.rs b/crates/flectar-mail-core/src/files/service.rs new file mode 100644 index 0000000..c9414d6 --- /dev/null +++ b/crates/flectar-mail-core/src/files/service.rs @@ -0,0 +1,1471 @@ +//! Protocol-independent file workspace, owned by the core. Native hosts only +//! choose paths, render previews and dispatch commands; no Slint dependency. +use super::{self as files, AttachmentFile, ConnectionSettings, FileClient, FileNode, PAGE_SIZE}; +use crate::Core; +use serde_json::json; +use std::path::{Path, PathBuf}; +type Result = std::result::Result; +pub enum Output { + Operations(Vec), + Status(String), + Preview(Preview), +} +pub enum Preview { + Text(String), + Data(Vec, String), +} +#[derive(Clone)] +pub enum Entry { + Attachment(AttachmentFile), + Remote(FileNode), +} +#[derive(Default)] +pub struct FilesService { + pub browse: super::jmap::BrowseOptions, + pub collision: super::CollisionPolicy, + pub case_insensitive: bool, + pub offline: bool, + pub cached_space: Option, + pub cached_spaces: Vec<(i64, String, String)>, + pub active_operation: Option, + pub accounts: Vec<(i64, String)>, + pub account: usize, + pub settings: ConnectionSettings, + pub attachments: bool, + pub server_attachments: bool, + pub mail_search: Option, + pub client: Option, + pub history: Vec, + pub entries: Vec, + pub query: String, + pub next: Option, + pub before: Option, + pub query_state: Option, + pub can_create: bool, + pub quota: String, + pub notification_ids: Vec, + pub notification_state: Option, +} +impl FilesService { + pub fn account_id(&self) -> Option { + self.account + .checked_sub(1) + .and_then(|i| self.accounts.get(i)) + .map(|a| a.0) + } + pub fn parent(&self) -> Option { + self.history.last().map(|n| n.id.clone()) + } + pub fn selected(&self, index: i32) -> Result { + usize::try_from(index) + .ok() + .and_then(|i| self.entries.get(i)) + .cloned() + .ok_or_else(|| "Select a file first.".into()) + } + async fn init(&mut self, core: &Core) -> Result<()> { + let configs = core + .list_account_configs() + .await + .map_err(|e| e.to_string())?; + let prior = self.account_id(); + self.accounts = configs.into_iter().map(|a| (a.id, a.email)).collect(); + self.account = prior + .and_then(|id| self.accounts.iter().position(|a| a.0 == id)) + .map(|i| i + 1) + .unwrap_or(0); + let selected = self.account_id(); + self.cached_spaces = core + .files_db + .read(move |c| { + let mut q = c.prepare( + "SELECT id,name,remote_id FROM spaces WHERE account_id=?1 ORDER BY remote_id", + )?; + Ok( + q.query_map([selected], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::>>()?, + ) + }) + .await + .map_err(|e| e.to_string())?; + if prior.is_some() && self.account == 0 { + self.cached_space = None; + self.offline = false; + self.mail_search = None; + self.client = None; + self.history.clear(); + self.entries.clear(); + } + Ok(()) + } + async fn settings(&mut self, core: &Core) -> Result<()> { + self.settings = if let Some(id) = self.account_id() { + core.file_connection_settings(id) + .await + .map_err(|e| e.to_string())? + } else { + ConnectionSettings::default() + }; + Ok(()) + } + async fn connect(&mut self, core: &Core) -> Result<()> { + let id = self + .account_id() + .ok_or_else(|| "Select an account to browse server storage.".to_string())?; + self.client = None; + self.can_create = false; + let mut client = core + .connect_files(id, &self.settings) + .await + .map_err(|e| e.to_string())?; + core.save_file_connection_settings(id, self.settings.clone()) + .await + .map_err(|e| e.to_string())?; + if let (Some(space), FileClient::Jmap(c)) = ( + super::store::selected_space(&core.files_db, id) + .await + .map_err(|e| e.to_string())?, + &mut client, + ) { + let remote = core + .files_db + .read(move |db| { + Ok( + db.query_row("SELECT remote_id FROM spaces WHERE id=?1", [space], |r| { + r.get::<_, String>(0) + })?, + ) + }) + .await + .map_err(|e| e.to_string())?; + c.select_account(&remote).map_err(|e| e.to_string())?; + } + self.client = Some(client); + self.offline = false; + Ok(()) + } + pub async fn execute( + &mut self, + core: &Core, + action: &str, + a: &str, + b: &str, + selected: i32, + local_path: Option, + ) -> Result { + // Mail metadata reads must remain responsive during a large remote + // tree scan. They use Mail's own SQLite queue and do not touch file + // content or remote mutations. Other commands share the lifecycle gate. + let metadata_only = (self.attachments + && b != "storage" + && matches!( + action, + "load" + | "account" + | "account-id" + | "search" + | "refresh" + | "more" + | "attachment-source" + )) + || (matches!(action, "account" | "account-id") && b == "attachments"); + let _files_guard = if metadata_only { + None + } else { + Some(core.file_work_lock.lock().await) + }; + if !metadata_only { + core.files_db.write(|c|{c.execute("UPDATE operations SET state='failed',error='Upload staging was interrupted. Select the source again.' WHERE state='preparing'",[])?;Ok(())}).await.map_err(|e|e.to_string())?; + } + if let Some(id) = self.active_operation.take() { + super::store::finish( + &core.files_db, + id, + "uncertain", + Some( + "Cancelled while contacting storage. Inspect the destination before retrying." + .into(), + ), + ) + .await + .map_err(|e| e.to_string())?; + } + if matches!( + action, + "transfers" | "retry-operation" | "cancel-operation" | "resolve-operation" + ) { + let account = self.account_id().ok_or("Select an account.")?; + if action != "transfers" { + let id = a.parse::().map_err(|_| "Invalid operation.")?; + let (kind,state,payload,space)=core.files_db.read(move |c|Ok(c.query_row("SELECT action,state,payload_json,space_id FROM operations WHERE id=?1 AND account_id=?2",rusqlite::params![id,account],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,Option>(3)?)))?)).await.map_err(|e|e.to_string())?; + if action == "retry-operation" { + if state != "queued" { + return Err("Only operations that have not contacted the server may be resumed. Inspect uncertain changes and mark them reviewed.".into()); + } + let payload: serde_json::Value = + serde_json::from_str(&payload).map_err(|e| e.to_string())?; + let mut pending = FilesService { + accounts: self.accounts.clone(), + account: self.account, + settings: self.settings.clone(), + collision: serde_json::from_value(payload["collision"].clone()) + .unwrap_or_default(), + case_insensitive: payload["caseInsensitive"].as_bool().unwrap_or(false), + attachments: false, + ..Default::default() + }; + pending.connect(core).await?; + if let (Some(space), Some(FileClient::Jmap(client))) = + (space, pending.client.as_mut()) + { + let remote = core + .files_db + .read(move |c| { + Ok(c.query_row( + "SELECT remote_id FROM spaces WHERE id=?1", + [space], + |r| r.get::<_, String>(0), + )?) + }) + .await + .map_err(|e| e.to_string())?; + client.select_account(&remote).map_err(|e| e.to_string())?; + let current = client + .list(None, "", 0) + .await + .map_err(|e| e.to_string())? + .state; + if payload["state"].as_str().is_some_and(|s| s != current) { + return Err("Storage changed since this operation was queued. Cancel it and repeat the action from a refreshed view.".into()); + } + } + pending.history = serde_json::from_value(payload["history"].clone()) + .map_err(|e| e.to_string())?; + pending.cached_space = space; + if !payload["node"].is_null() { + pending.entries.push(Entry::Remote( + serde_json::from_value(payload["node"].clone()) + .map_err(|e| e.to_string())?, + )); + } + pending.can_create = true; // Current protocol permissions/preconditions are checked below. + let path = payload["stagedName"] + .as_str() + .map(|name| { + files::validate_name(name).map_err(|e| e.to_string())?; + Ok::<_, String>( + core.paths + .files_staging_dir(account) + .join(id.to_string()) + .join(name), + ) + }) + .transpose()?; + if matches!(kind.as_str(), "upload" | "replace") && path.is_none() { + return Err("The queued upload has no complete staging data. Cancel it and select the source again.".into()); + } + if let Some(FileClient::Jmap(c)) = &mut pending.client { + c.collision = serde_json::from_value(payload["collision"].clone()) + .unwrap_or_default(); + c.compare_case_insensitively = + payload["caseInsensitive"].as_bool().unwrap_or(false); + } + super::store::finish(&core.files_db, id, "running", None) + .await + .map_err(|e| e.to_string())?; + self.active_operation = Some(id); + let result = perform( + &mut pending, + core, + &kind, + payload["a"].as_str().unwrap_or(""), + payload["b"].as_str().unwrap_or(""), + 0, + path, + ) + .await; + super::store::finish( + &core.files_db, + id, + if result.is_ok() { + "completed" + } else { + "uncertain" + }, + result.as_ref().err().cloned(), + ) + .await + .map_err(|e| e.to_string())?; + self.active_operation = None; + result?; + } else { + if state == "running" { + return Err("Cancel the active operation first.".into()); + } + super::store::finish( + &core.files_db, + id, + if action == "resolve-operation" { + "completed" + } else { + "cancelled" + }, + None, + ) + .await + .map_err(|e| e.to_string())?; + } + let _ = tokio::fs::remove_dir_all( + core.paths.files_staging_dir(account).join(id.to_string()), + ) + .await; + } + return Ok(Output::Operations( + super::store::operations(&core.files_db, account) + .await + .map_err(|e| e.to_string())?, + )); + } + if let Some(FileClient::Jmap(c)) = &mut self.client { + c.collision = if matches!(action, "folder" | "rename" | "move" | "copy" | "copy-space") + { + self.collision + } else { + super::CollisionPolicy::Reject + }; + c.compare_case_insensitively = self.case_insensitive; + } + let mutation = matches!( + action, + "upload" + | "replace" + | "folder" + | "rename" + | "move" + | "copy" + | "copy-space" + | "delete" + | "share" + | "unshare" + | "metadata" + | "remove-property" + | "lock" + | "unlock" + ); + let mut local_path = local_path; + let job = if mutation { + self.init(core).await?; + let account = self.account_id().ok_or("Select an account.")?; + core.save_file_connection_settings(account, self.settings.clone()) + .await + .map_err(|e| e.to_string())?; + let node = self.selected(selected).ok().and_then(|e| match e { + Entry::Remote(n) => Some(n), + _ => None, + }); + let mut state = match &self.client { + Some(FileClient::Jmap(c)) => c.state.clone(), + _ => None, + }; + if state.is_none() + && !self.settings.webdav + && let Some(space) = self.cached_space + { + state = core + .files_db + .read(move |c| { + Ok( + c.query_row("SELECT state FROM spaces WHERE id=?1", [space], |r| { + r.get::<_, Option>(0) + })?, + ) + }) + .await + .map_err(|e| e.to_string())?; + } + let payload = json!({"a":a,"b":b,"node":node,"history":self.history,"state":state,"collision":self.collision,"caseInsensitive":self.case_insensitive}); + let id = super::store::begin( + &core.files_db, + account, + self.cached_space, + action.into(), + payload.to_string(), + ) + .await + .map_err(|e| e.to_string())?; + if matches!(action, "upload" | "replace") { + super::store::finish(&core.files_db, id, "preparing", None) + .await + .map_err(|e| e.to_string())?; + let staged: Result = async { + let source = local_path.as_ref().ok_or("Choose an upload file.")?; + let directory = core.paths.files_staging_dir(account).join(id.to_string()); + tokio::fs::create_dir_all(&directory) + .await + .map_err(|e| e.to_string())?; + let destination = + directory.join(source.file_name().ok_or("Invalid file name.")?); + files::save_cached_file(source, &destination) + .await + .map_err(|e| e.to_string())?; + Ok(destination) + } + .await; + let destination = match staged { + Ok(path) => path, + Err(error) => { + super::store::finish(&core.files_db, id, "failed", Some(error.clone())) + .await + .map_err(|e| e.to_string())?; + return Err(error); + } + }; + local_path = Some(destination.clone()); + let mut payload = payload; + payload["stagedName"] = json!( + destination + .file_name() + .and_then(|v| v.to_str()) + .ok_or("File name must be valid Unicode.")? + ); + core.files_db + .write(move |c| { + c.execute( + "UPDATE operations SET payload_json=?2,state='queued' WHERE id=?1", + rusqlite::params![id, payload.to_string()], + )?; + Ok(()) + }) + .await + .map_err(|e| e.to_string())?; + } + if self.offline { + return Ok(Output::Status(format!( + "Queued operation #{id}. Reconnect and review it in Transfers." + ))); + } + super::store::finish(&core.files_db, id, "running", None) + .await + .map_err(|e| e.to_string())?; + self.active_operation = Some(id); + Some(id) + } else { + None + }; + let result = perform(self, core, action, a, b, selected, local_path).await; + if let Some(id) = job { + super::store::finish( + &core.files_db, + id, + if result.is_ok() { + "completed" + } else { + "uncertain" + }, + result.as_ref().err().cloned(), + ) + .await + .map_err(|e| e.to_string())?; + self.active_operation = None; + if result.is_ok() + && let Some(account) = self.account_id() + { + let _ = tokio::fs::remove_dir_all( + core.paths.files_staging_dir(account).join(id.to_string()), + ) + .await; + } + } + result + } + async fn refresh(&mut self, core: &Core, more: bool) -> Result<()> { + let result = self.refresh_remote(core, more).await; + if result.is_ok() { + self.offline = false; + return result; + } + let network_failure = result.as_ref().err().is_some_and(|e| { + e.contains("Could not reach file storage") + || e.contains("Could not discover file storage") + || e.contains("timed out") + }); + if !self.attachments && network_failure { + let account = self.account_id().ok_or("Select an account.")?; + if let Some(space) = super::store::selected_space(&core.files_db, account) + .await + .map_err(|e| e.to_string())? + { + self.cached_space = Some(space); + core.files_db.write(move |c|{c.execute("UPDATE connections SET selected_space=(SELECT remote_id FROM spaces WHERE id=?2) WHERE account_id=?1",rusqlite::params![account,space])?;Ok(())}).await.map_err(|e|e.to_string())?; + let mut nodes = + super::store::cached(&core.files_db, space, self.parent(), self.query.clone()) + .await + .map_err(|e| e.to_string())?; + self.browse.apply_local(&mut nodes); + self.entries = nodes.into_iter().map(Entry::Remote).collect(); + self.offline = true; + self.next = None; + self.can_create = + self.query.is_empty() && self.history.last().is_some_and(|n| n.my_rights.add()); + self.quota = + "Offline · cached metadata; permissions are rechecked before changes.".into(); + return Ok(()); + } + } + result + } + async fn refresh_remote(&mut self, core: &Core, more: bool) -> Result<()> { + if !more { + self.entries.clear(); + self.next = None; + self.before = None; + self.query_state = None; + } + if self.attachments && self.server_attachments { + if self.mail_search.is_none() { + self.mail_search = Some( + core.connect_attachment_search( + self.account_id() + .ok_or("Select one account to search server mail.")?, + ) + .await + .map_err(|e| e.to_string())?, + ); + } + let page = self + .mail_search + .as_ref() + .unwrap() + .search(&self.query, if more { self.next.unwrap_or(0) } else { 0 }) + .await + .map_err(|e| e.to_string())?; + if more && self.query_state.as_ref().is_some_and(|s| s != &page.state) { + return Err("Mail changed during search. Refresh before loading more.".into()); + } + self.query_state = Some(page.state); + self.next = page.next; + self.entries + .extend(page.files.into_iter().map(Entry::Attachment)); + self.can_create = false; + } else if self.attachments { + let mut rows = core + .attachment_files( + self.account_id(), + self.query.clone(), + if more { self.before } else { None }, + ) + .await + .map_err(|e| e.to_string())?; + let has_more = rows.len() > PAGE_SIZE; + rows.truncate(PAGE_SIZE); + self.before = rows.last().map(|a| a.id); + self.next = has_more.then_some(0); + self.entries.extend(rows.into_iter().map(Entry::Attachment)); + self.can_create = false; + } else { + if self.client.is_none() { + self.connect(core).await?; + } + let parent = self.parent(); + let account = self.account_id().ok_or("Select an account.")?; + let (remote, name, protocol) = match self.client.as_ref().unwrap() { + FileClient::Jmap(c) => ( + c.account_id.clone(), + c.accounts + .iter() + .find(|a| a.id == c.account_id) + .map(|a| a.name.clone()) + .unwrap_or_default(), + "jmap", + ), + FileClient::Dav(c) => (c.root.clone(), c.root.clone(), "dav"), + }; + let space = super::store::space(&core.files_db, account, remote, name, protocol) + .await + .map_err(|e| e.to_string())?; + self.cached_space = Some(space); + core.files_db.write(move |c|{c.execute("UPDATE connections SET selected_space=(SELECT remote_id FROM spaces WHERE id=?2) WHERE account_id=?1",rusqlite::params![account,space])?;Ok(())}).await.map_err(|e|e.to_string())?; + match self.client.as_mut().unwrap() { + FileClient::Jmap(client) => { + let page = client + .list_options( + parent.as_deref(), + &self.query, + if more { self.next.unwrap_or(0) } else { 0 }, + &self.browse, + ) + .await + .map_err(|e| e.to_string())?; + if more + && self + .query_state + .as_ref() + .is_some_and(|state| state != &page.query_state) + { + return Err( + "Storage changed during pagination. Refresh before loading more." + .into(), + ); + } + self.query_state = Some(page.query_state.clone()); + self.next = page.next_position; + self.can_create = self.query.is_empty() + && !client.read_only() + && self + .history + .last() + .map_or(client.can_create_root(), |p| p.my_rights.add()); + self.entries + .extend(page.nodes.into_iter().map(Entry::Remote)); + if self.query.is_empty() && !self.browse.filtered() && self.next.is_none() { + let nodes = self + .entries + .iter() + .filter_map(|e| match e { + Entry::Remote(n) => Some(n.clone()), + _ => None, + }) + .collect(); + super::store::replace_collection( + &core.files_db, + space, + parent.clone(), + nodes, + Some(page.state), + Some(page.query_state), + None, + ) + .await + .map_err(|e| e.to_string())?; + } + if !more { + self.quota = client + .quota() + .await + .ok() + .and_then(|v| v["list"].as_array().cloned()) + .map(|items| { + items + .iter() + .map(|q| { + format!( + "{}: {} / {} {}", + q["name"].as_str().unwrap_or("Storage"), + q["used"], + q["hardLimit"], + q["unit"].as_str().unwrap_or("") + ) + }) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + } + } + FileClient::Dav(client) => { + let url = parent.as_deref().unwrap_or(&client.root); + let mut listing = client.list(url).await.map_err(|e| e.to_string())?; + self.can_create = self.query.is_empty() && listing.current.my_rights.add(); + let cache_parent = parent.clone(); + let mut nodes = listing.nodes.clone(); + for node in &mut nodes { + node.parent_id = cache_parent.clone(); + } + super::store::replace_collection( + &core.files_db, + space, + cache_parent, + nodes, + None, + None, + listing.sync_token.clone(), + ) + .await + .map_err(|e| e.to_string())?; + self.quota = match (listing.quota_used, listing.quota_available) { + (Some(used), Some(available)) => { + format!("{} used · {} available", size(used), size(available)) + } + _ => String::new(), + }; + self.browse.apply_local(&mut listing.nodes); + let tokens = self.query.to_lowercase(); + // DAV has no standard full-text search implemented by + // Stalwart. Label this explicitly as current-folder search. + self.entries = listing + .nodes + .into_iter() + .filter(|n| { + tokens.split_whitespace().all(|s| { + format!("{} {}", n.name, n.media_type.as_deref().unwrap_or("")) + .to_lowercase() + .contains(s) + }) + }) + .map(Entry::Remote) + .collect(); + self.next = None; + } + } + } + Ok(()) + } + async fn destination(&mut self, path: &str) -> Result> { + if path.is_empty() { + return Ok(self.parent()); + } + if !path.starts_with('/') { + return Err( + "Use a folder path starting with /, or leave it empty for the current folder." + .into(), + ); + } + let segments = path + .split('/') + .filter(|s| !s.is_empty()) + .collect::>(); + if segments.len() > 64 { + return Err("Destination path is too deep.".into()); + } + match self.client.as_mut().ok_or("Storage is disconnected.")? { + FileClient::Dav(client) => { + let mut url = client.root.clone(); + for segment in segments { + url = client + .child_url(&url, segment, true) + .map_err(|e| e.to_string())?; + } + let listing = client.list(&url).await.map_err(|e| e.to_string())?; + if !listing.current.my_rights.add() { + return Err("You cannot add files in this folder.".into()); + } + Ok(Some(url)) + } + FileClient::Jmap(client) => { + let mut parent = None; + for segment in segments { + let mut position = 0; + let mut found = None; + loop { + let page = client + .list(parent.as_deref(), "", position) + .await + .map_err(|e| e.to_string())?; + if let Some(node) = page + .nodes + .into_iter() + .find(|n| n.name == segment && n.is_directory()) + { + found = Some(node); + break; + } + if let Some(next) = page.next_position { + position = next; + } else { + break; + } + } + let node = found + .ok_or_else(|| format!("Destination folder ‘{segment}’ was not found."))?; + if !node.my_rights.add() { + return Err("You cannot add files in this folder.".into()); + } + parent = Some(node.id); + } + Ok(parent) + } + } + } +} + +async fn perform( + dir: &mut FilesService, + core: &Core, + action: &str, + a: &str, + b: &str, + selected: i32, + local_path: Option, +) -> Result { + // Account removal invalidates an authenticated session before any action. + let old_account = dir.account_id(); + dir.init(core).await?; + if old_account.is_some() + && dir.account_id().is_none() + && !matches!(action, "account" | "account-id") + { + return Err("The selected account was removed. Select another account.".into()); + } + let mut status = String::new(); + match action { + "load" => { + dir.init(core).await?; + dir.settings(core).await?; + } + "account" | "account-id" => { + dir.cached_space = None; + dir.offline = false; + let index = if action == "account-id" { + let id = a.parse::().map_err(|_| "Invalid account.")?; + dir.accounts + .iter() + .position(|account| account.0 == id) + .map(|index| index + 1) + .ok_or("Account no longer exists.")? + } else { + a.parse::().map_err(|_| "Invalid account.")? + }; + if index > dir.accounts.len() { + return Err("Account no longer exists.".into()); + } + dir.account = index; + if matches!(b, "attachments" | "storage") { + dir.attachments = b == "attachments"; + } + dir.client = None; + dir.mail_search = None; + dir.history.clear(); + dir.entries.clear(); + dir.can_create = false; + dir.query.clear(); + dir.settings(core).await?; + } + "attachment-source" => { + dir.server_attachments = a == "server"; + dir.query_state = None; + dir.query.clear(); + } + "scope" => { + dir.attachments = a == "attachments"; + dir.query.clear(); + } + "connect" => { + dir.attachments = false; + dir.history.clear(); + dir.entries.clear(); + dir.cached_space = None; + dir.settings = ConnectionSettings { + endpoint: a.into(), + webdav: b == "dav", + }; + dir.connect(core).await?; + core.save_file_connection_settings( + dir.account_id().ok_or("Select an account.")?, + dir.settings.clone(), + ) + .await + .map_err(|e| e.to_string())?; + } + "space" if dir.offline => { + let index = a.parse::().map_err(|_| "Invalid storage space.")?; + let (id, _, remote) = dir + .cached_spaces + .get(index) + .cloned() + .ok_or("Storage space is unavailable offline.")?; + let account = dir.account_id().ok_or("Select an account.")?; + core.files_db + .write(move |c| { + c.execute( + "UPDATE connections SET selected_space=?2 WHERE account_id=?1", + rusqlite::params![account, remote], + )?; + Ok(()) + }) + .await + .map_err(|e| e.to_string())?; + dir.cached_space = Some(id); + dir.client = None; + dir.history.clear(); + dir.query.clear(); + } + "space" => { + let FileClient::Jmap(client) = dir.client.as_mut().ok_or("Storage is disconnected.")? + else { + return Err("Shared WebDAV spaces use their own connection URL.".into()); + }; + let index = a.parse::().map_err(|_| "Invalid storage space.")?; + let id = client + .accounts + .get(index) + .ok_or("Storage space is unavailable.")? + .id + .clone(); + client.select_account(&id).map_err(|e| e.to_string())?; + dir.history.clear(); + dir.query.clear(); + } + "index-text" => { + let count = core + .index_cached_attachment_text(dir.account_id()) + .await + .map_err(|e| e.to_string())?; + status = format!( + "Indexed {count} cached text attachments. Text, JSON and XML up to 1 MiB are supported; other formats remain searchable by metadata." + ); + } + "filter" => { + let mut options: super::jmap::BrowseOptions = + serde_json::from_str(a).map_err(|_| "Invalid file filters.")?; + if !["name", "size", "nodeType"].contains(&options.sort.as_str()) { + return Err("Unsupported file sorting.".into()); + } + if options + .node_type + .as_deref() + .is_some_and(|s| !["file", "directory"].contains(&s)) + { + return Err("Unsupported file kind.".into()); + } + if options + .min_size + .zip(options.max_size) + .is_some_and(|(a, b)| a > b) + { + return Err("Minimum size exceeds maximum size.".into()); + } + if options.node_type.as_deref() == Some("directory") { + options.min_size = None; + options.max_size = None; + } + dir.browse = options; + } + "search" => { + if a.len() > 1024 { + return Err("Search is too long.".into()); + } + dir.query = a.into(); + } + "up" => { + dir.history.pop(); + dir.query.clear(); + } + "enter" => { + let Entry::Remote(node) = dir.selected(selected)? else { + return Err("Select a folder.".into()); + }; + if !node.is_directory() { + return Err("Select a folder.".into()); + } + if dir.history.len() >= 64 { + return Err("Folder path is too deep.".into()); + } + dir.history.push(node); + dir.query.clear(); + } + "notifications" | "clear-notifications" => { + let Some(FileClient::Jmap(client)) = dir.client.as_ref() else { + return Err("Sharing notifications require JMAP storage.".into()); + }; + if action == "clear-notifications" && !dir.notification_ids.is_empty() { + client + .dismiss_share_notifications( + &dir.notification_ids, + dir.notification_state + .as_deref() + .ok_or("Reload sharing activity before clearing it.")?, + ) + .await + .map_err(|e| e.to_string())?; + } + let result = client + .share_notifications() + .await + .map_err(|e| e.to_string())?; + dir.notification_state = result["state"].as_str().map(str::to_owned); + dir.notification_ids = result["list"] + .as_array() + .ok_or("Invalid sharing notifications.")? + .iter() + .filter_map(|n| n["id"].as_str().map(str::to_owned)) + .collect(); + let messages = result["list"] + .as_array() + .ok_or("Invalid sharing notifications.")? + .iter() + .map(|n| { + format!( + "{}\n{} · {}\n{}", + n["name"].as_str().unwrap_or("File"), + n["changedBy"]["name"].as_str().unwrap_or(""), + n["created"].as_str().unwrap_or(""), + if n["newRights"].is_null() { + "Access removed" + } else { + "Permissions updated" + } + ) + }) + .collect::>(); + return Ok(Output::Preview(Preview::Text(if messages.is_empty() { + "No sharing notifications.".into() + } else { + messages.join("\n\n") + }))); + } + "poll" => { + if dir.attachments { + return Ok(Output::Status(String::new())); + } + if let Some(FileClient::Jmap(client)) = dir.client.as_ref() + && let Some(state) = client.state.as_deref() + && let Ok(changes) = client.changes(state).await + && ["created", "updated", "destroyed"] + .iter() + .all(|k| changes[*k].as_array().is_some_and(Vec::is_empty)) + { + return Ok(Output::Status(format!("{} items", dir.entries.len()))); + } + } + "preview" | "download" | "details" | "keep-offline" | "release-offline" => { + let entry = dir.selected(selected)?; + if action == "details" { + let mut text = details(&entry); + if let (Entry::Remote(node), Some(FileClient::Dav(c))) = (&entry, &dir.client) + && !dir.offline + { + text.push_str("\n\nWebDAV properties:\n"); + text.push_str( + &c.properties(&node.id) + .await + .map_err(|e| e.to_string())? + .describe(), + ); + } + return Ok(Output::Preview(Preview::Text(text))); + } + if action == "release-offline" { + let Entry::Remote(node) = &entry else { + return Err("Attachment caching is managed by Mail.".into()); + }; + super::cache::pin( + core, + dir.cached_space.ok_or("Refresh storage.")?, + node.id.clone(), + false, + ) + .await + .map_err(|e| e.to_string())?; + return Ok(Output::Status( + "Offline copy may now be removed when cache space is needed.".into(), + )); + } + if action == "keep-offline" { + match &entry { + Entry::Attachment(a) => { + core.attachment_file_content(a) + .await + .map_err(|e| e.to_string())?; + } + Entry::Remote(n) => { + super::cache::content( + core, + dir.account_id().ok_or("Select an account.")?, + dir.cached_space.ok_or("Refresh storage.")?, + n, + dir.client.as_ref(), + dir.offline, + ) + .await + .map_err(|e| e.to_string())?; + } + } + if let Entry::Remote(node) = &entry { + super::cache::pin( + core, + dir.cached_space.ok_or("Refresh storage.")?, + node.id.clone(), + true, + ) + .await + .map_err(|e| e.to_string())?; + } + return Ok(Output::Status("File is available offline.".into())); + } + let destination = if action == "download" { + Some(local_path.ok_or("Choose a download destination.")?) + } else { + None + }; + if let Some(path) = destination { + match &entry { + Entry::Attachment(a) => { + let source = core + .attachment_file_content(a) + .await + .map_err(|e| e.to_string())?; + files::save_cached_file(Path::new(&source), &path) + .await + .map_err(|e| e.to_string())?; + } + Entry::Remote(n) => { + let source = super::cache::content( + core, + dir.account_id().ok_or("Select an account.")?, + dir.cached_space.ok_or("Refresh storage.")?, + n, + dir.client.as_ref(), + dir.offline, + ) + .await + .map_err(|e| e.to_string())?; + files::save_cached_file(&source, &path) + .await + .map_err(|e| e.to_string())? + } + } + return Ok(Output::Status("File downloaded.".into())); + } + let data = match &entry { + Entry::Attachment(a) => { + let path = core + .attachment_file_content(a) + .await + .map_err(|e| e.to_string())?; + read_bounded(Path::new(&path), 16 * 1024 * 1024).await? + } + Entry::Remote(n) => { + if n.size.is_some_and(|n| n > 16 * 1024 * 1024) { + return Ok(Output::Preview(Preview::Text( + "This file is too large to preview. Download it to view locally." + .into(), + ))); + } + let path = super::cache::content( + core, + dir.account_id().ok_or("Select an account.")?, + dir.cached_space.ok_or("Refresh storage.")?, + n, + dir.client.as_ref(), + dir.offline, + ) + .await + .map_err(|e| e.to_string())?; + read_bounded(&path, 16 * 1024 * 1024).await? + } + }; + let media = match entry { + Entry::Attachment(a) => a.media_type, + Entry::Remote(n) => n.media_type.unwrap_or_default(), + }; + return Ok(Output::Preview(Preview::Data(data, media))); + } + "folder" => { + if !dir.can_create { + return Err("You cannot create files in this folder.".into()); + } + let parent = dir.parent(); + match dir.client.as_mut().ok_or("Storage is disconnected.")? { + FileClient::Jmap(c) => { + c.create_folder(parent.as_deref(), a) + .await + .map_err(|e| e.to_string())?; + } + FileClient::Dav(c) => { + c.create_folder(parent.as_deref().unwrap_or(&c.root), a) + .await + .map_err(|e| e.to_string())?; + } + } + status = "Folder created.".into(); + } + "upload" | "replace" => { + let node = if action == "replace" { + match dir.selected(selected)? { + Entry::Remote(n) if n.my_rights.modify() && !n.is_directory() => Some(n), + _ => return Err("This file cannot be replaced.".into()), + } + } else { + if !dir.can_create { + return Err("You cannot upload into this folder.".into()); + } + None + }; + let path = local_path.ok_or("Choose a file to upload.")?; + let name = node.as_ref().map(|n| n.name.clone()).unwrap_or_else(|| { + safe_name(&path.file_name().unwrap_or_default().to_string_lossy()) + }); + let parent = dir.parent(); + match dir.client.as_mut().ok_or("Storage is disconnected.")? { + FileClient::Jmap(c) => { + c.upload( + parent.as_deref(), + &name, + &path, + node.as_ref().map(|n| n.id.as_str()), + ) + .await + .map_err(|e| e.to_string())?; + } + FileClient::Dav(c) => c + .upload( + parent.as_deref().unwrap_or(&c.root), + &name, + &path, + node.as_ref(), + ) + .await + .map_err(|e| e.to_string())?, + } + status = "File uploaded.".into(); + } + "copy-space" => { + let Entry::Remote(node) = dir.selected(selected)? else { + return Err("Select a storage file or folder.".into()); + }; + if !node.my_rights.may_read { + return Err("You cannot read this file.".into()); + } + let (space, path) = b.split_once('|').ok_or("Invalid destination.")?; + let index = space + .parse::() + .map_err(|_| "Invalid storage space.")?; + let Some(FileClient::Jmap(source)) = dir.client.as_ref() else { + return Err("Copy between storage spaces requires JMAP.".into()); + }; + let target = source + .accounts + .get(index) + .ok_or("Storage space is unavailable.")? + .id + .clone(); + let source_id = source.account_id.clone(); + let source_state = source.state.clone().ok_or("Refresh before copying.")?; + if target == source_id { + let parent = dir.destination(path).await?; + let Some(FileClient::Jmap(client)) = dir.client.as_mut() else { + unreachable!() + }; + client.state = Some(source_state); + client + .copy(&node.id, parent.as_deref(), a) + .await + .map_err(|e| e.to_string())?; + } else { + let client = core + .connect_files(dir.account_id().ok_or("Select an account.")?, &dir.settings) + .await + .map_err(|e| e.to_string())?; + let mut destination = FilesService { + client: Some(client), + ..Default::default() + }; + let Some(FileClient::Jmap(client)) = destination.client.as_mut() else { + return Err("JMAP connection required.".into()); + }; + client.select_account(&target).map_err(|e| e.to_string())?; + client.list(None, "", 0).await.map_err(|e| e.to_string())?; + let parent = destination.destination(path).await?; + let Some(FileClient::Jmap(client)) = destination.client.as_mut() else { + unreachable!() + }; + client.collision = dir.collision; + client.compare_case_insensitively = dir.case_insensitive; + client + .copy_from_account(&source_id, &source_state, &node.id, parent.as_deref(), a) + .await + .map_err(|e| e.to_string())?; + } + status = "File copied to the selected storage space.".into(); + } + "rename" | "move" | "copy" | "delete" | "share" | "unshare" | "lock" | "unlock" + | "metadata" | "remove-property" => { + let Entry::Remote(node) = dir.selected(selected)? else { + return Err("Email attachments cannot be changed in storage.".into()); + }; + match action { + "metadata" | "remove-property" if !node.my_rights.modify() => { + return Err("You cannot change this file’s properties.".into()); + } + "rename" | "move" if !node.my_rights.rename() => { + return Err("You cannot move or rename this file.".into()); + } + "delete" if !node.my_rights.delete() => { + return Err("You cannot delete this file.".into()); + } + "share" | "unshare" if !node.my_rights.may_share => { + return Err("You cannot change sharing for this file.".into()); + } + _ => {} + } + // Resolving the destination must not replace the optimistic state + // from the displayed source list with a newer state. + let old_state = match dir.client.as_ref() { + Some(FileClient::Jmap(c)) => c.state.clone(), + _ => None, + }; + let parent = if matches!(action, "move" | "copy") { + dir.destination(b).await? + } else { + dir.parent() + }; + match dir.client.as_mut().ok_or("Storage is disconnected.")? { + FileClient::Jmap(c) => { + c.state = old_state; + match action { + "metadata"=> { + let dates=a.split('|').collect::>(); + if dates.len()!=3 {return Err("Invalid dates.".into());} + let mut patch=json!({}); + if !node.is_directory() {patch["executable"]=json!(b.contains('x'));} + for (key,date) in ["created","modified","accessed"].iter().zip(dates) { + if !date.is_empty() { + let parsed=chrono::DateTime::parse_from_rfc3339(date).map_err(|_|format!("Enter a valid {key} date with its UTC offset."))?; + patch[*key]=json!(parsed.with_timezone(&chrono::Utc).to_rfc3339_opts(chrono::SecondsFormat::Secs,true)); + } + } + c.update(&node.id,patch).await + } + "rename"=>{c.update(&node.id,json!({"name":a})).await}, + "move"=>{c.update(&node.id,json!({"name":a,"parentId":parent})).await}, + "copy"=>{c.copy(&node.id,parent.as_deref(),a).await}, + "delete"=>{c.delete(&node.id,true).await}, + "share"|"unshare"=> { + if a.is_empty() || a.len()>256 {return Err("Enter a recipient account ID.".into());} + let recipient=c.resolve_principal(a).await.map_err(|e|e.to_string())?; + let key=format!("shareWith/{}",recipient.replace('~',"~0").replace('/',"~1")); + let rights=if action=="unshare" {serde_json::Value::Null} else if c.modern() {json!({"mayRead":true,"mayAddChildren":b.contains('a'),"mayRename":b.contains('r'),"mayDelete":b.contains('d'),"mayModifyContent":b.contains('w'),"mayShare":b.contains('s')})} else {json!({"mayRead":true,"mayWrite":b.chars().any(|c|"ward".contains(c)),"mayShare":b.contains('s')})}; + c.update(&node.id,json!({key:rights})).await + } + _=>return Err("Locks are available through WebDAV.".into()) + }.map_err(|e|e.to_string())?; + } + FileClient::Dav(c) => match action { + "rename" | "move" | "copy" => { + c.relocate_with_policy( + &node, + parent.as_deref().unwrap_or(&c.root), + a, + action == "copy", + dir.collision, + ) + .await + } + "delete" => c.delete(&node).await, + "metadata" | "remove-property" => { + let (namespace, name) = b + .split_once('|') + .unwrap_or(("urn:flectar:files", "description")); + c.patch_property( + &node, + namespace, + name, + if action == "remove-property" { + None + } else { + Some(a) + }, + ) + .await + } + "share" | "unshare" => { + let mut privileges = vec!["read"]; + for (flag, right) in [ + ('w', "write-content"), + ('p', "write-properties"), + ('a', "bind"), + ('d', "unbind"), + ('s', "write-acl"), + ] { + if b.contains(flag) { + privileges.push(right); + } + } + c.share_privileges(&node, a, &privileges, action == "unshare") + .await + } + "lock" => { + c.lock_with_options( + &node, + b.contains('s'), + b.contains('r'), + a.parse().unwrap_or(300), + ) + .await + } + "unlock" => c.unlock(&node).await, + _ => unreachable!(), + } + .map_err(|e| e.to_string())?, + } + status = "File updated.".into(); + } + "refresh" | "more" => {} + _ => return Err("Unknown file action.".into()), + } + dir.refresh(core, action == "more").await?; + if status.is_empty() { + status = if dir.attachments { + format!("{} attachments · synchronized mail", dir.entries.len()) + } else if dir.settings.webdav { + format!("{} items · search covers this folder", dir.entries.len()) + } else { + format!("{} items", dir.entries.len()) + }; + } + Ok(Output::Status(status)) +} +async fn read_bounded(path: &Path, limit: usize) -> Result> { + use tokio::io::AsyncReadExt; + let file = tokio::fs::File::open(path) + .await + .map_err(|e| e.to_string())?; + let mut data = Vec::new(); + file.take(limit as u64 + 1) + .read_to_end(&mut data) + .await + .map_err(|e| e.to_string())?; + if data.len() > limit { + return Err("File exceeds the transfer limit.".into()); + } + Ok(data) +} +fn size(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.) + } else { + format!("{:.1} MB", bytes as f64 / (1024. * 1024.)) + } +} +fn safe_name(name: &str) -> String { + if files::validate_name(name).is_ok() { + name.into() + } else { + "download".into() + } +} +fn details(entry: &Entry) -> String { + match entry { + Entry::Attachment(a) => format!( + "{}\n{} · {}\n\nFrom: {}\nSubject: {}\n\n{}", + a.filename, + a.media_type, + size(a.size), + a.sender, + a.subject, + if a.cached { + "Available offline" + } else { + "Downloads on demand" + } + ), + Entry::Remote(n) => format!( + "{}\n{} · {}\n\nCreated: {}\nModified: {}\nAccessed: {}\nRole: {}\nExecutable: {}\nSubscribed: {}\n\nSharing:\n{}", + n.name, + n.media_type.as_deref().unwrap_or("Folder"), + n.size.map(size).unwrap_or_default(), + n.created.as_deref().unwrap_or("—"), + n.modified.as_deref().unwrap_or("—"), + n.accessed.as_deref().unwrap_or("—"), + n.role.as_deref().unwrap_or("—"), + n.executable, + n.is_subscribed.unwrap_or(true), + n.share_with + .as_ref() + .map(|v| serde_json::to_string_pretty(v).unwrap_or_default()) + .unwrap_or_else(|| "No sharing details returned".into()) + ), + } +} diff --git a/crates/flectar-mail-core/src/files/storage_tests.rs b/crates/flectar-mail-core/src/files/storage_tests.rs new file mode 100644 index 0000000..73c0bee --- /dev/null +++ b/crates/flectar-mail-core/src/files/storage_tests.rs @@ -0,0 +1,456 @@ +use super::{store, *}; +use crate::{ + Core, + config::Paths, + db::{Db, testutil}, +}; +use serde_json::json; + +#[tokio::test] +async fn independent_store_migrates_legacy_settings_and_recovers_uncertain_jobs() { + let root = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(root.path())) + .await + .unwrap(); + core.db + .write(|c| { + testutil::seed_account(c); + c.execute( + "INSERT INTO app_settings(key,value) VALUES('files:1',?1)", + [json!({"endpoint":"https://files.example.test","webdav":false}).to_string()], + )?; + Ok(()) + }) + .await + .unwrap(); + core.recover_files().await.unwrap(); + assert_eq!( + core.file_connection_settings(1).await.unwrap().endpoint, + "https://files.example.test" + ); + let space = store::space( + &core.files_db, + 1, + "personal".into(), + "Personal".into(), + "jmap", + ) + .await + .unwrap(); + let queued = store::begin(&core.files_db, 1, Some(space), "upload".into(), "{}".into()) + .await + .unwrap(); + let running = store::begin(&core.files_db, 1, Some(space), "rename".into(), "{}".into()) + .await + .unwrap(); + store::finish(&core.files_db, running, "running", None) + .await + .unwrap(); + core.recover_files().await.unwrap(); + let operations = store::operations(&core.files_db, 1).await.unwrap(); + assert_eq!( + operations.iter().find(|o| o.id == queued).unwrap().state, + "queued" + ); + assert_eq!( + operations.iter().find(|o| o.id == running).unwrap().state, + "uncertain" + ); + assert!(root.path().join("flectar-files.db").exists()); + let tables=core.files_db.read(|c|Ok(c.query_row("SELECT count(*) FROM sqlite_master WHERE name IN ('messages','attachments','calendar_events')",[],|r|r.get::<_,i64>(0))?)).await.unwrap(); + assert_eq!(tables, 0); +} + +#[tokio::test] +async fn cached_files_are_scoped_and_complete_collection_replacement_removes_stale_rows() { + let root = tempfile::tempdir().unwrap(); + let db = Db::open_files(&root.path().join("files.db")).unwrap(); + db.write(|c|{c.execute("INSERT INTO connections(account_id,settings_json,updated_at) VALUES(1,'{}',0),(2,'{}',0)",[])?;Ok(())}).await.unwrap(); + let one = store::space(&db, 1, "same-id".into(), "One".into(), "jmap") + .await + .unwrap(); + let two = store::space(&db, 2, "same-id".into(), "Two".into(), "jmap") + .await + .unwrap(); + let node = FileNode { + id: "x".into(), + name: "Résumé.pdf".into(), + media_type: Some("application/pdf".into()), + ..Default::default() + }; + store::replace_collection( + &db, + one, + None, + vec![node], + Some("s1".into()), + Some("q1".into()), + None, + ) + .await + .unwrap(); + assert_eq!( + store::cached(&db, one, None, "resume".into()) + .await + .unwrap() + .len(), + 1 + ); + assert!( + store::cached(&db, two, None, "resume".into()) + .await + .unwrap() + .is_empty() + ); + // A visible folder page must not advance the full-account sync cursor. + assert!( + db.read(move |c| Ok(c + .query_row("SELECT state FROM spaces WHERE id=?1", [one], |r| r + .get::<_, Option>(0))?)) + .await + .unwrap() + .is_none() + ); + store::replace_collection( + &db, + one, + None, + vec![], + Some("s2".into()), + Some("q2".into()), + None, + ) + .await + .unwrap(); + assert!( + store::cached(&db, one, None, "".into()) + .await + .unwrap() + .is_empty() + ); + db.write(|c| { + c.execute("DELETE FROM connections WHERE account_id=1", [])?; + Ok(()) + }) + .await + .unwrap(); + assert!(store::operations(&db, 1).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn metadata_snapshot_contains_all_three_stores() { + let root = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(root.path())) + .await + .unwrap(); + let manifest = core + .create_database_snapshot(out.path().join("snapshot")) + .await + .unwrap(); + assert_eq!(manifest.version, 2); + assert!(manifest.files.is_some()); + for name in ["mail.sqlite3", "calendar.sqlite3", "files.sqlite3"] { + assert!(out.path().join("snapshot").join(name).exists()); + } +} + +#[tokio::test] +async fn connection_changes_cancel_queued_writes_but_preserve_reconciliation_history() { + let root = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(root.path())) + .await + .unwrap(); + core.db + .write(|c| { + testutil::seed_account(c); + Ok(()) + }) + .await + .unwrap(); + core.save_file_connection_settings( + 1, + ConnectionSettings { + endpoint: "https://one.test".into(), + webdav: false, + }, + ) + .await + .unwrap(); + let space = store::space( + &core.files_db, + 1, + "personal".into(), + "Personal".into(), + "jmap", + ) + .await + .unwrap(); + let queued = store::begin(&core.files_db, 1, Some(space), "upload".into(), "{}".into()) + .await + .unwrap(); + let uncertain = store::begin(&core.files_db, 1, Some(space), "copy".into(), "{}".into()) + .await + .unwrap(); + store::finish( + &core.files_db, + uncertain, + "uncertain", + Some("Inspect destination".into()), + ) + .await + .unwrap(); + core.save_file_connection_settings( + 1, + ConnectionSettings { + endpoint: "https://two.test".into(), + webdav: true, + }, + ) + .await + .unwrap(); + let jobs = store::operations(&core.files_db, 1).await.unwrap(); + assert_eq!( + jobs.iter().find(|o| o.id == queued).unwrap().state, + "cancelled" + ); + assert_eq!( + jobs.iter().find(|o| o.id == uncertain).unwrap().state, + "uncertain" + ); + assert!( + store::selected_space(&core.files_db, 1) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn cache_eviction_preserves_pins_and_removes_unreferenced_versions() { + let root = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(root.path())) + .await + .unwrap(); + core.db + .write(|c| { + testutil::seed_account(c); + Ok(()) + }) + .await + .unwrap(); + core.save_file_connection_settings(1, ConnectionSettings::default()) + .await + .unwrap(); + let space = store::space( + &core.files_db, + 1, + "personal".into(), + "Personal".into(), + "jmap", + ) + .await + .unwrap(); + let dir = core.paths.files_cache_dir(1); + tokio::fs::create_dir_all(&dir).await.unwrap(); + for key in ["a".repeat(64), "b".repeat(64), "c".repeat(64)] { + tokio::fs::write(dir.join(key), b"data").await.unwrap(); + } + core.files_db.write(move |c|{for (id,pinned) in [("a",true),("b",false)] {c.execute("INSERT INTO content_cache(space_id,remote_id,validator,relative_path,byte_size,accessed_at,pinned) VALUES(?1,?2,'v1',?3,4,0,?4)",rusqlite::params![space,id,id.repeat(64),pinned])?;}Ok(())}).await.unwrap(); + cache::trim(&core, 1, 4).await.unwrap(); + assert!(dir.join("a".repeat(64)).exists()); + assert!(!dir.join("b".repeat(64)).exists()); + assert!(!dir.join("c".repeat(64)).exists()); + assert!(cache::trim(&core, 1, 0).await.is_err()); + assert!(dir.join("a".repeat(64)).exists()); + cache::pin(&core, space, "a".into(), false).await.unwrap(); + cache::trim(&core, 1, 0).await.unwrap(); + assert!(!dir.join("a".repeat(64)).exists()); +} + +#[tokio::test] +async fn snapshots_include_portable_staged_uploads() { + let root = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(root.path())) + .await + .unwrap(); + core.db + .write(|c| { + testutil::seed_account(c); + Ok(()) + }) + .await + .unwrap(); + core.save_file_connection_settings(1, ConnectionSettings::default()) + .await + .unwrap(); + let id = store::begin( + &core.files_db, + 1, + None, + "upload".into(), + json!({"stagedName":"budget.txt"}).to_string(), + ) + .await + .unwrap(); + let dir = core.paths.files_staging_dir(1).join(id.to_string()); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("budget.txt"), b"durable bytes") + .await + .unwrap(); + let manifest = core + .create_database_snapshot(out.path().join("snapshot")) + .await + .unwrap(); + assert!(manifest.file_transfer_payloads); + assert_eq!( + std::fs::read( + out.path() + .join("snapshot/file_transfers/1") + .join(id.to_string()) + .join("budget.txt") + ) + .unwrap(), + b"durable bytes" + ); +} + +#[tokio::test] +async fn attachment_metadata_browsing_does_not_wait_for_remote_tree_sync() { + let root = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(root.path())) + .await + .unwrap(); + let _scan = core.file_work_lock.lock().await; + let mut service = service::FilesService { + attachments: true, + ..Default::default() + }; + tokio::time::timeout( + std::time::Duration::from_secs(2), + service.execute(&core, "load", "", "", -1, None), + ) + .await + .expect("Mail attachments must not wait for the storage scan") + .unwrap(); +} + +#[tokio::test] +async fn sidebar_selects_account_by_identity_and_enters_attachment_scope() { + let root = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(root.path())) + .await + .unwrap(); + core.db + .write(|c| { + testutil::seed_account(c); + Ok(()) + }) + .await + .unwrap(); + let _scan = core.file_work_lock.lock().await; + let mut service = service::FilesService::default(); + tokio::time::timeout( + std::time::Duration::from_secs(2), + service.execute(&core, "account-id", "1", "attachments", -1, None), + ) + .await + .expect("Switching to mail attachments must not wait for file sync") + .unwrap(); + assert!(service.attachments); + assert_eq!(service.account_id(), Some(1)); + assert!( + service + .execute(&core, "account-id", "9999", "attachments", -1, None) + .await + .is_err() + ); + assert_eq!(service.account_id(), Some(1)); + service + .execute(&core, "account", "0", "attachments", -1, None) + .await + .unwrap(); + assert!(service.account_id().is_none()); +} + +#[tokio::test] +async fn folder_refresh_prunes_revoked_descendants_but_preserves_promoted_children() { + let root = tempfile::tempdir().unwrap(); + let db = Db::open_files(&root.path().join("files.db")).unwrap(); + db.write(|c| { + c.execute("INSERT INTO connections VALUES(1,'{}',NULL,0)", [])?; + Ok(()) + }) + .await + .unwrap(); + let space = store::space(&db, 1, "storage".into(), "Storage".into(), "jmap") + .await + .unwrap(); + let node = |id: &str, parent: Option<&str>| FileNode { + id: id.into(), + name: id.into(), + parent_id: parent.map(str::to_owned), + ..Default::default() + }; + let nodes = vec![ + node("folder", None), + node("private", Some("folder")), + node("promoted", Some("folder")), + node("child", Some("promoted")), + ]; + db.write(move |c| { + for node in nodes { + store::upsert(c, space, &node)?; + } + c.execute( + "INSERT INTO content_cache VALUES(?1,'private','v','cached',4,0,1)", + [space], + )?; + c.execute( + "INSERT INTO collections VALUES(?1,'folder',NULL,NULL,1,0)", + [space], + )?; + Ok(()) + }) + .await + .unwrap(); + store::replace_collection( + &db, + space, + None, + vec![node("promoted", None)], + None, + None, + None, + ) + .await + .unwrap(); + let found = store::cached(&db, space, None, "private".into()) + .await + .unwrap(); + assert!(found.is_empty(), "revoked descendant remained searchable"); + let children = store::cached(&db, space, Some("promoted".into()), "".into()) + .await + .unwrap(); + assert_eq!(children.len(), 1); + assert_eq!(children[0].id, "child"); + db.read(move |c| { + assert_eq!( + c.query_row("SELECT count(*) FROM content_cache", [], |r| r + .get::<_, i64>(0))?, + 0 + ); + assert_eq!( + c.query_row( + "SELECT count(*) FROM collections WHERE parent_id='folder'", + [], + |r| r.get::<_, i64>(0) + )?, + 0 + ); + Ok(()) + }) + .await + .unwrap(); +} diff --git a/crates/flectar-mail-core/src/files/store.rs b/crates/flectar-mail-core/src/files/store.rs new file mode 100644 index 0000000..ab14648 --- /dev/null +++ b/crates/flectar-mail-core/src/files/store.rs @@ -0,0 +1,203 @@ +//! Durable projections and operation history. Every projection update and its +//! sync cursor commit in the same SQLite transaction. +use super::{ConnectionSettings, FileNode}; +use crate::{Core, db::Db, error::Result}; +use rusqlite::{OptionalExtension, params}; + +#[derive(Clone, Debug)] +pub struct Operation { + pub id: i64, + pub action: String, + pub state: String, + pub error: String, + pub bytes: u64, + pub description: String, +} +pub async fn operations(db: &Db, account: i64) -> Result> { + db.read(move |c| { + let mut query=c.prepare("SELECT id,action,state,COALESCE(error,''),progress_bytes,payload_json FROM operations WHERE account_id=?1 ORDER BY id DESC LIMIT 100")?; + let rows=query.query_map([account],|r|{ + let payload=r.get::<_,String>(5)?; + let payload=serde_json::from_str::(&payload).unwrap_or_default(); + let name=payload["stagedName"].as_str().or_else(||payload["node"]["name"].as_str()).or_else(||payload["a"].as_str()).unwrap_or(""); + let folder=payload["history"].as_array().into_iter().flatten().filter_map(|n|n["name"].as_str()).collect::>().join(" / "); + Ok(Operation{id:r.get(0)?,action:r.get(1)?,state:r.get(2)?,error:r.get(3)?,bytes:r.get::<_,i64>(4)?.max(0) as u64,description:format!("/{folder} · {name}")}) + })?; + Ok(rows.collect::>>()?) + }).await +} + +impl Core { + pub(crate) async fn recover_files(&self) -> Result<()> { + let accounts = self + .list_account_configs() + .await? + .into_iter() + .map(|a| a.id) + .collect::>(); + // Migrate the earlier branch's settings idempotently. The original is + // deleted only after the destination commit succeeds. + let legacy = self + .db + .read(|c| { + let mut q = + c.prepare("SELECT key,value FROM app_settings WHERE key LIKE 'files:%'")?; + Ok( + q.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? + .collect::>>()?, + ) + }) + .await?; + for (key, value) in legacy { + if let Some(id) = key + .strip_prefix("files:") + .and_then(|v| v.parse::().ok()) + .filter(|id| accounts.contains(id)) + { + let _: ConnectionSettings = serde_json::from_str(&value)?; + self.files_db.write(move |c| { c.execute("INSERT OR IGNORE INTO connections(account_id,settings_json,updated_at) VALUES(?1,?2,?3)",params![id,value,chrono::Utc::now().timestamp_millis()])?; Ok(()) }).await?; + } + self.db + .write(move |c| { + c.execute("DELETE FROM app_settings WHERE key=?1", [key])?; + Ok(()) + }) + .await?; + } + self.files_db.write(move |c| { + let tx=c.transaction()?; + let ids={let mut q=tx.prepare("SELECT account_id FROM connections")?;q.query_map([],|r| r.get::<_,i64>(0))?.collect::>>()?}; + for id in ids { if !accounts.contains(&id) { tx.execute("DELETE FROM connections WHERE account_id=?1",[id])?; } } + tx.execute("UPDATE operations SET state='uncertain',error='Interrupted during a server request. Refresh and reconcile before retrying.',updated_at=?1 WHERE state='running'",[chrono::Utc::now().timestamp_millis()])?; + tx.execute("UPDATE operations SET state='failed',error='Interrupted while staging an upload. Select the source again.' WHERE state='preparing'",[])?; + tx.commit()?; Ok(()) + }).await?; + self.prune_file_staging().await + } + async fn prune_file_staging(&self) -> Result<()> { + let pending=self.files_db.read(|c| { + let mut q=c.prepare("SELECT account_id,id FROM operations WHERE state IN ('queued','running','uncertain')")?; + Ok(q.query_map([],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,i64>(1)?)))?.collect::>>()?) + }).await?; + let mut accounts = + match tokio::fs::read_dir(self.paths.data_dir.join("file_transfers")).await { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + while let Some(account) = accounts.next_entry().await? { + if !account.file_type().await?.is_dir() { + continue; + } + let Ok(account_id) = account.file_name().to_string_lossy().parse::() else { + continue; + }; + let mut jobs = tokio::fs::read_dir(account.path()).await?; + while let Some(job) = jobs.next_entry().await? { + if !job.file_type().await?.is_dir() { + continue; + } + let Ok(id) = job.file_name().to_string_lossy().parse::() else { + continue; + }; + if !pending.contains(&(account_id, id)) { + tokio::fs::remove_dir_all(job.path()).await?; + } + } + } + Ok(()) + } +} + +pub async fn space( + db: &Db, + account: i64, + remote: String, + name: String, + protocol: &str, +) -> Result { + let protocol = protocol.to_owned(); + db.write(move |c| { + c.execute("INSERT INTO spaces(account_id,remote_id,name,protocol) VALUES(?1,?2,?3,?4) ON CONFLICT(account_id,remote_id) DO UPDATE SET name=excluded.name",params![account,remote,name,protocol])?; + + Ok(c.query_row("SELECT id FROM spaces WHERE account_id=?1 AND remote_id=?2",params![account,remote],|r|r.get(0))?) + }).await +} +pub(crate) fn upsert(c: &rusqlite::Connection, space: i64, node: &FileNode) -> Result<()> { + c.execute("INSERT INTO nodes(space_id,remote_id,parent_id,name,media_type,node_json) VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(space_id,remote_id) DO UPDATE SET parent_id=excluded.parent_id,name=excluded.name,media_type=excluded.media_type,node_json=excluded.node_json",params![space,node.id,node.parent_id,node.name,node.media_type,serde_json::to_string(node)?])?; + Ok(()) +} +pub async fn replace_collection( + db: &Db, + space: i64, + parent: Option, + nodes: Vec, + state: Option, + query: Option, + token: Option, +) -> Result<()> { + db.write(move |c| { + let tx=c.transaction()?; + // Apply moves before pruning. A promoted child must survive removal of + // its former parent, while deleted/revoked subtrees must leave search + // and the offline cache immediately after a complete folder refresh. + let ids = serde_json::to_string(&nodes.iter().map(|n| &n.id).collect::>())?; + for node in nodes { upsert(&tx,space,&node)?; } + let removed = "WITH RECURSIVE removed(remote_id) AS ( + SELECT remote_id FROM nodes WHERE space_id=?1 AND parent_id IS ?2 + AND remote_id NOT IN (SELECT value FROM json_each(?3)) + UNION + SELECT n.remote_id FROM nodes n JOIN removed r ON n.parent_id=r.remote_id WHERE n.space_id=?1 + )"; + for (table, key) in [("content_cache", "remote_id"), ("collections", "parent_id"), ("nodes", "remote_id")] { + tx.execute(&format!("{removed} DELETE FROM {table} WHERE space_id=?1 AND {key} IN (SELECT remote_id FROM removed)"), params![space,parent,ids])?; + } + tx.execute("INSERT INTO collections(space_id,parent_id,sync_token,query_state,complete,synced_at) VALUES(?1,?2,?3,?4,1,?5) ON CONFLICT(space_id,parent_id) DO UPDATE SET sync_token=excluded.sync_token,query_state=excluded.query_state,complete=1,synced_at=excluded.synced_at",params![space,parent.unwrap_or_default(),token,query,chrono::Utc::now().timestamp_millis()])?; + let _=state; // Only a complete account scan owns the global sync cursor. + tx.commit()?; Ok(()) + }).await +} +pub async fn cached( + db: &Db, + space: i64, + parent: Option, + query: String, +) -> Result> { + let query = super::attachment_match_query(&query); + db.read(move |c| { + let mut q=c.prepare("SELECT node_json FROM nodes WHERE space_id=?1 AND (?3!='' OR parent_id IS ?2) AND (?3='' OR id IN (SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?3)) ORDER BY name,remote_id LIMIT 10000")?; + let json=q.query_map(params![space,parent,query],|r|r.get::<_,String>(0))?.collect::>>()?; + json.into_iter().map(|v|Ok(serde_json::from_str(&v)?)).collect() + }).await +} +pub async fn selected_space(db: &Db, account: i64) -> Result> { + db.read(move |c|Ok(c.query_row("SELECT s.id FROM spaces s JOIN connections c ON c.account_id=s.account_id AND c.selected_space=s.remote_id WHERE s.account_id=?1",[account],|r|r.get(0)).optional()?)).await +} +pub async fn begin( + db: &Db, + account: i64, + space: Option, + action: String, + payload: String, +) -> Result { + db.write(move |c| {let now=chrono::Utc::now().timestamp_millis();c.execute("INSERT INTO operations(account_id,space_id,action,payload_json,state,created_at,updated_at) VALUES(?1,?2,?3,?4,'queued',?5,?5)",params![account,space,action,payload,now])?;Ok(c.last_insert_rowid())}).await +} +pub async fn finish(db: &Db, id: i64, state: &str, error: Option) -> Result<()> { + let state = state.to_owned(); + let bytes = super::progress::bytes().min(i64::MAX as u64) as i64; + db.write(move |c| { + c.execute( + "UPDATE operations SET state=?2,error=?3,updated_at=?4,progress_bytes=MAX(progress_bytes,?5) WHERE id=?1", + params![id, state, error, chrono::Utc::now().timestamp_millis(),bytes], + )?; + Ok(()) + }) + .await +} +pub async fn activity(db: &Db, account: i64) -> Result { + db.read(move |c| { + let mut q=c.prepare("SELECT id,action,state,COALESCE(error,'') FROM operations WHERE account_id=?1 ORDER BY id DESC LIMIT 100")?; + let rows=q.query_map([account],|r|Ok(format!("#{} · {} · {}\n{}",r.get::<_,i64>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?)))?.collect::>>()?; + Ok(rows.join("\n\n")) + }).await +} diff --git a/crates/flectar-mail-core/src/files/sync.rs b/crates/flectar-mail-core/src/files/sync.rs new file mode 100644 index 0000000..605bdd8 --- /dev/null +++ b/crates/flectar-mail-core/src/files/sync.rs @@ -0,0 +1,376 @@ +//! Rebuildable disk projections. A scan stages pages in SQLite, then publishes +//! the complete tree and cursor atomically. Readers keep the last good tree +//! throughout a failed scan; memory is bounded to a protocol page. +use super::{FileClient, FileNode, store}; +use crate::{Core, error::Result}; +use rusqlite::{OptionalExtension, params}; + +pub async fn account(core: &Core, account: i64) -> Result<()> { + let _guard = core.file_work_lock.lock().await; + let settings = core.file_connection_settings(account).await?; + let mut client = core.connect_files(account, &settings).await?; + match &mut client { + FileClient::Jmap(c) => { + // A successful session is authoritative about accessible accounts. + let available = c.accounts.iter().map(|a| a.id.clone()).collect::>(); + core.files_db.write(move |db|{let tx=db.transaction()?; + let ids={let mut q=tx.prepare("SELECT id,remote_id FROM spaces WHERE account_id=?1 AND protocol='jmap'")?;q.query_map([account],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?)))?.collect::>>()?}; + for (id,remote) in ids {if !available.contains(&remote) { + tx.execute("UPDATE operations SET state='cancelled',error='Access to this storage space was revoked.' WHERE space_id=?1 AND state IN ('preparing','queued')",[id])?; + tx.execute("DELETE FROM spaces WHERE id=?1",[id])?; + }}tx.commit()?;Ok(())}).await?; + super::cache::sweep(core, account).await?; + for remote in c.accounts.clone() { + c.select_account(&remote.id)?; + let space = + store::space(&core.files_db, account, remote.id, remote.name, "jmap").await?; + let since = core + .files_db + .read(move |db| { + Ok( + db.query_row("SELECT state FROM spaces WHERE id=?1", [space], |r| { + r.get::<_, Option>(0) + })?, + ) + }) + .await?; + if let Some(since) = since + && let Ok(changes) = c.changes(&since).await + { + if ["created", "updated", "destroyed"] + .iter() + .all(|key| changes[*key].as_array().is_some_and(Vec::is_empty)) + { + continue; + } + if changes["hasMoreChanges"].as_bool() == Some(false) + && apply_jmap_delta(core, c, space, &changes).await? + { + continue; + } + } + begin(core, space).await?; + let mut position = 0; + let mut state = None; + let mut query = None; + loop { + let page = c.list_all(position).await?; + if state.as_ref().is_some_and(|s| s != &page.state) + || query.as_ref().is_some_and(|s| s != &page.query_state) + { + return Err(super::err( + "Storage changed during synchronization; the last complete cache was preserved.", + )); + } + state = Some(page.state); + query = Some(page.query_state); + stage(core, space, page.nodes).await?; + match page.next_position { + Some(next) if next > position => position = next, + None => break, + _ => return Err(super::err("Invalid file pagination.")), + } + } + publish(core, space, state).await?; + } + } + FileClient::Dav(c) => { + let space = store::space( + &core.files_db, + account, + c.root.clone(), + c.root.clone(), + "dav", + ) + .await?; + begin(core, space).await?; + let mut pending = + std::collections::VecDeque::from([(c.root.clone(), None::, 0usize)]); + let mut visited = std::collections::HashSet::new(); + while let Some((url, parent, depth)) = pending.pop_front() { + if depth > 64 || !visited.insert(url.clone()) { + return Err(super::err("Invalid WebDAV folder hierarchy.")); + } + let key = parent.clone().unwrap_or_default(); + let lookup = key.clone(); + let previous=core.files_db.read(move |db|Ok(db.query_row("SELECT sync_token FROM collections WHERE space_id=?1 AND parent_id=?2",params![space,lookup],|r|r.get::<_,Option>(0)).optional()?.flatten())).await?; + let unchanged = if let Some(previous) = previous { + match c.collection_changed(&url, &previous).await { + Ok((false, token)) => Some(token), + _ => None, + } + } else { + None + }; + let (mut nodes, token) = if let Some(token) = unchanged { + let parent = parent.clone(); + let nodes = core + .files_db + .read(move |db| { + let mut q = db.prepare( + "SELECT node_json FROM nodes WHERE space_id=?1 AND parent_id IS ?2", + )?; + let rows = q + .query_map(params![space, parent], |r| r.get::<_, String>(0))? + .collect::>>()?; + rows.into_iter() + .map(|s| Ok(serde_json::from_str(&s)?)) + .collect::>>() + }) + .await?; + (nodes, Some(token)) + } else { + let listing = c.list(&url).await?; + (listing.nodes, listing.sync_token) + }; + for node in &mut nodes { + node.parent_id = parent.clone(); + if node.is_directory() { + pending.push_back((node.id.clone(), Some(node.id.clone()), depth + 1)); + } + } + stage(core, space, nodes).await?; + core.files_db.write(move |db|{db.execute("INSERT OR REPLACE INTO scan_collections(space_id,parent_id,sync_token) VALUES(?1,?2,?3)",params![space,key,token])?;Ok(())}).await?; + } + publish(core, space, None).await?; + } + } + super::cache::sweep(core, account).await?; + Ok(()) +} +async fn apply_jmap_delta( + core: &Core, + client: &mut super::jmap::FileClient, + space: i64, + changes: &serde_json::Value, +) -> Result { + let Some(state) = changes["newState"].as_str() else { + return Ok(false); + }; + let mut nodes = Vec::new(); + client.state = Some(state.into()); + for id in ["created", "updated"] + .iter() + .flat_map(|key| changes[*key].as_array().into_iter().flatten()) + .filter_map(|id| id.as_str()) + { + let result = client.get(&[id.into()]).await?; + if result["state"].as_str() != Some(state) { + return Ok(false); + } + let Some(value) = result["list"].as_array().and_then(|v| v.first()) else { + return Ok(false); + }; + let mut node: FileNode = serde_json::from_value(value.clone())?; + if node.is_directory() { + return Ok(false); + } // Sharing on a directory affects descendants. + node.my_rights = client.rights(id).await?; + nodes.push(node); + } + let destroyed = changes["destroyed"] + .as_array() + .ok_or_else(|| super::err("Invalid file changes."))? + .iter() + .filter_map(|id| id.as_str().map(str::to_owned)) + .collect::>(); + let state = state.to_owned(); + core.files_db + .write(move |db| { + let tx = db.transaction()?; + for id in &destroyed { + let directory: Option = tx + .query_row( + "SELECT node_json FROM nodes WHERE space_id=?1 AND remote_id=?2", + params![space, id], + |r| r.get(0), + ) + .optional()?; + if directory.is_some_and(|s| { + serde_json::from_str::(&s).is_ok_and(|n| n.is_directory()) + }) { + return Ok(false); + } + } + for id in destroyed { + tx.execute( + "DELETE FROM content_cache WHERE space_id=?1 AND remote_id=?2", + params![space, id], + )?; + tx.execute( + "DELETE FROM nodes WHERE space_id=?1 AND remote_id=?2", + params![space, id], + )?; + } + for node in nodes { + store::upsert(&tx, space, &node)?; + } + tx.execute( + "UPDATE spaces SET state=?2,last_synced_at=?3 WHERE id=?1", + params![space, state, chrono::Utc::now().timestamp_millis()], + )?; + tx.commit()?; + Ok(true) + }) + .await +} +async fn begin(core: &Core, space: i64) -> Result<()> { + core.files_db + .write(move |c| { + c.execute("DELETE FROM scan_nodes WHERE space_id=?1", [space])?; + c.execute("DELETE FROM scan_collections WHERE space_id=?1", [space])?; + Ok(()) + }) + .await +} +async fn stage(core: &Core, space: i64, nodes: Vec) -> Result<()> { + core.files_db.write(move |c|{let tx=c.transaction()?;for node in nodes {tx.execute("INSERT INTO scan_nodes(space_id,remote_id,node_json) VALUES(?1,?2,?3) ON CONFLICT(space_id,remote_id) DO UPDATE SET node_json=excluded.node_json",params![space,node.id,serde_json::to_string(&node)?])?;}tx.commit()?;Ok(())}).await +} +async fn publish(core: &Core, space: i64, state: Option) -> Result<()> { + core.files_db.write(move |c|{ + let tx=c.transaction()?; + tx.execute("DELETE FROM nodes WHERE space_id=?1",[space])?; + tx.execute("INSERT INTO nodes(space_id,remote_id,parent_id,name,media_type,node_json) SELECT space_id,remote_id,json_extract(node_json,'$.parentId'),json_extract(node_json,'$.name'),json_extract(node_json,'$.type'),node_json FROM scan_nodes WHERE space_id=?1",[space])?; + tx.execute("DELETE FROM content_cache WHERE space_id=?1 AND remote_id NOT IN (SELECT remote_id FROM nodes WHERE space_id=?1)",[space])?; + tx.execute("UPDATE spaces SET state=?2,last_synced_at=?3 WHERE id=?1",params![space,state,chrono::Utc::now().timestamp_millis()])?; + tx.execute("DELETE FROM collections WHERE space_id=?1",[space])?; + tx.execute("INSERT INTO collections(space_id,parent_id,sync_token,complete,synced_at) SELECT space_id,parent_id,sync_token,1,?2 FROM scan_collections WHERE space_id=?1",params![space,chrono::Utc::now().timestamp_millis()])?; + tx.execute("DELETE FROM scan_nodes WHERE space_id=?1",[space])?; + tx.execute("DELETE FROM scan_collections WHERE space_id=?1",[space])?; + tx.commit()?;Ok(()) + }).await +} + +/// One cancellable worker for all configured file accounts. The host owns its +/// task handle so profile replacement cannot keep the old profile alive. +pub async fn run(core: Core) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let (wake, mut events) = tokio::sync::mpsc::channel::(16); + let mut watches = tokio::task::JoinSet::new(); + let mut handles = std::collections::HashMap::::new(); + loop { + tokio::select! { + _=interval.tick()=>{ + let accounts=core.files_db.read(|c|{let mut q=c.prepare("SELECT account_id FROM connections")?;Ok(q.query_map([],|r|r.get::<_,i64>(0))?.collect::>>()?)}).await; + if let Ok(accounts)=accounts { + handles.retain(|id,handle|{if !accounts.contains(id){handle.abort();false}else{true}}); + for id in accounts { + handles.entry(id).or_insert_with(||{ + let core=core.clone();let wake=wake.clone(); + watches.spawn(async move { + loop { + let result=async { + let settings=core.file_connection_settings(id).await?; + let FileClient::Jmap(client)=core.connect_files(id,&settings).await? else {return Err(super::err("WebDAV uses collection polling."));}; + client.wait_for_change().await + }.await; + if result.is_ok(){if wake.send(id).await.is_err(){return;}tokio::time::sleep(std::time::Duration::from_secs(1)).await;} + else {tokio::time::sleep(std::time::Duration::from_secs(60)).await;} + } + }) + }); + if let Err(error)=account(&core,id).await {tracing::debug!(account_id=id,%error,"file synchronization deferred");} + } + while watches.try_join_next().is_some() {} + } + }, + Some(id)=events.recv()=>{ + if handles.contains_key(&id)&& let Err(error)=account(&core,id).await {tracing::debug!(account_id=id,%error,"file push reconciliation deferred");} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn interrupted_scan_keeps_committed_tree_and_cursor_together() { + let root = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(crate::config::Paths::for_tests(root.path())) + .await + .unwrap(); + core.files_db + .write(|c| { + c.execute( + "INSERT INTO connections(account_id,settings_json,updated_at) VALUES(1,'{}',0)", + [], + )?; + Ok(()) + }) + .await + .unwrap(); + let space = store::space( + &core.files_db, + 1, + "personal".into(), + "Personal".into(), + "jmap", + ) + .await + .unwrap(); + begin(&core, space).await.unwrap(); + stage( + &core, + space, + vec![FileNode { + id: "a".into(), + name: "Original".into(), + ..Default::default() + }], + ) + .await + .unwrap(); + publish(&core, space, Some("s1".into())).await.unwrap(); + begin(&core, space).await.unwrap(); + stage( + &core, + space, + vec![FileNode { + id: "b".into(), + name: "Incomplete".into(), + ..Default::default() + }], + ) + .await + .unwrap(); + assert_eq!( + store::cached(&core.files_db, space, None, "".into()) + .await + .unwrap()[0] + .id, + "a" + ); + assert_eq!( + core.files_db + .read(move |c| Ok(c.query_row( + "SELECT state FROM spaces WHERE id=?1", + [space], + |r| r.get::<_, String>(0) + )?)) + .await + .unwrap(), + "s1" + ); + // A restart discards incomplete staging before accepting new pages. + begin(&core, space).await.unwrap(); + stage( + &core, + space, + vec![FileNode { + id: "c".into(), + name: "Complete".into(), + ..Default::default() + }], + ) + .await + .unwrap(); + publish(&core, space, Some("s2".into())).await.unwrap(); + let nodes = store::cached(&core.files_db, space, None, "".into()) + .await + .unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id, "c"); + } +} diff --git a/crates/flectar-mail-core/src/files/tests.rs b/crates/flectar-mail-core/src/files/tests.rs new file mode 100644 index 0000000..84e2e38 --- /dev/null +++ b/crates/flectar-mail-core/src/files/tests.rs @@ -0,0 +1,495 @@ +use super::*; +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// Real HTTP framing exercises reqwest and serialization, not an alternate +/// mock implementation of the protocol client. +async fn server( + replies: Vec<(u16, String, String)>, +) -> (String, tokio::task::JoinHandle>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let mut requests = Vec::new(); + for (status, headers, body) in replies { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let mut chunk = [0; 4096]; + loop { + let count = stream.read(&mut chunk).await.unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&chunk[..count]); + if let Some(pos) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&bytes[..pos]); + let length = head + .lines() + .find_map(|l| { + l.to_lowercase() + .strip_prefix("content-length:") + .and_then(|s| s.trim().parse::().ok()) + }) + .unwrap_or(0); + if bytes.len() >= pos + 4 + length { + break; + } + } + } + requests.push(String::from_utf8(bytes).unwrap()); + stream.write_all(format!("HTTP/1.1 {status} Test\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}",body.len()).as_bytes()).await.unwrap(); + } + requests + }); + (url, task) +} + +#[tokio::test] +async fn dav_listing_and_copy_use_decoded_names_and_no_overwrite() { + let listing = r#"/dav/file/me/HTTP/1.1 200 OK/dav/file/me/a%20%26%20b.txt"v1"3HTTP/1.1 200 OK"#; + let (base, requests) = server(vec![ + (200, "DAV: 1, 2, access-control\r\n".into(), String::new()), + (207, String::new(), listing.into()), + (201, String::new(), String::new()), + ]) + .await; + let c = dav::DavClient::connect(&format!("{base}/dav/file/me/"), "user", "secret") + .await + .unwrap(); + let list = c.list(&c.root).await.unwrap(); + assert_eq!(list.nodes.len(), 1); + assert_eq!(list.nodes[0].name, "a & b.txt"); + assert!(list.nodes[0].my_rights.may_rename); + c.relocate(&list.nodes[0], &c.root, "copy #1.txt", true) + .await + .unwrap(); + let requests = requests.await.unwrap(); + assert!(requests[1].contains("depth: 1")); + assert!(requests[2].starts_with("COPY /dav/file/me/a%20&%20b.txt")); + assert!(requests[2].contains("overwrite: F")); + assert!(requests[2].contains("if-match: \"v1\"")); + assert!(requests[2].contains("copy%20%231.txt")); +} +#[tokio::test] +async fn dav_rejects_origin_and_path_escape_before_sending_credentials() { + let (base, requests) = server(vec![(200, "DAV: 1\r\n".into(), String::new())]).await; + let c = dav::DavClient::connect(&format!("{base}/dav/file/me/"), "user", "secret") + .await + .unwrap(); + for href in [ + "https://evil.example/file", + "../other/file", + "/dav/file/other/file", + "%2e%2e/other/", + ] { + assert!(c.href(&c.root, href).is_err(), "{href}"); + } + assert!(c.child_url(&c.root, "../file", false).is_err()); + assert!(c.href(&c.root, "sub%2Ffolder").is_err()); + requests.await.unwrap(); +} +#[tokio::test] +async fn dav_lock_token_is_sent_for_writes_and_unlock() { + let (base, requests) = server(vec![ + (200, "DAV: 1, 2\r\n".into(), String::new()), + ( + 200, + "Lock-Token: \r\n".into(), + String::new(), + ), + (204, String::new(), String::new()), + (204, String::new(), String::new()), + ]) + .await; + let mut c = dav::DavClient::connect(&format!("{base}/dav/file/me/"), "user", "secret") + .await + .unwrap(); + let node = FileNode { + id: format!("{}file", c.root), + etag: Some("\"v1\"".into()), + node_type: Some("file".into()), + ..Default::default() + }; + c.lock(&node).await.unwrap(); + assert!(c.owns_lock(&node.id)); + c.patch_property(&node, "urn:example", "description", Some("A & B")) + .await + .unwrap(); + c.unlock(&node).await.unwrap(); + let requests = requests.await.unwrap(); + assert!(requests[2].contains("()")); + assert!(requests[2].contains("A & B")); + assert!(requests[3].contains("lock-token: ")); +} +#[tokio::test] +async fn jmap_discovery_scopes_shared_accounts_and_preserves_state_preconditions() { + // Session URLs need the dynamic port, so use a tiny dedicated server. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let advertised = base.clone(); + let task = tokio::spawn(async move { + let responses = [ + json!({"capabilities":{"urn:ietf:params:jmap:core":{},jmap::CAPABILITY:{}},"primaryAccounts":{jmap::CAPABILITY:"mine"},"accounts":{"mine":{"name":"Mine","isReadOnly":false,"accountCapabilities":{jmap::CAPABILITY:{"fileNodeQuerySortOptions":["nodeType","name"],"mayCreateTopLevelFileNode":true}}},"shared":{"name":"Team","isReadOnly":true,"accountCapabilities":{jmap::CAPABILITY:{}}}},"apiUrl":format!("{advertised}/jmap"),"uploadUrl":format!("{advertised}/upload/{{accountId}}"),"downloadUrl":format!("{advertised}/download/{{accountId}}/{{blobId}}/{{name}}?type={{type}}")}), + json!({"methodResponses":[["FileNode/query",{"accountId":"mine","ids":[],"total":0,"queryState":"q1"},"files"]]}), + json!({"methodResponses":[["FileNode/get",{"accountId":"mine","list":[],"state":"s1"},"files"]]}), + json!({"methodResponses":[["FileNode/set",{"accountId":"mine","created":{"new":{"id":"folder"}},"newState":"s2"},"files"]]}), + ]; + let mut requests = Vec::new(); + for response in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = vec![0; 16384]; + let count = stream.read(&mut bytes).await.unwrap(); + let request = String::from_utf8_lossy(&bytes[..count]).into_owned(); + requests.push(request); + let body = response.to_string(); + stream.write_all(format!("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",body.len(),body).as_bytes()).await.unwrap(); + } + requests + }); + let mut c = jmap::FileClient::connect(&base, "user", "secret", Some("mail-only-id")) + .await + .unwrap(); + assert_eq!(c.account_id, "mine"); + assert_eq!(c.accounts.len(), 2); + assert!(c.modern()); + let page = c.list(None, "", 0).await.unwrap(); + assert!(page.nodes.is_empty()); + c.create_folder(None, "Folder").await.unwrap(); + assert_eq!(c.state.as_deref(), Some("s2")); + c.select_account("shared").unwrap(); + assert!(c.create_folder(None, "No").await.is_err()); + let requests = task.await.unwrap(); + assert!(requests[1].contains("\"isTopLevel\":true")); + assert!(requests[3].contains("\"ifInState\":\"s1\"")); +} + +#[test] +fn attachment_index_tracks_unicode_names_metadata_and_deletion_with_untrusted_schema() { + use crate::db::testutil; + let conn = testutil::conn(); + conn.pragma_update(None, "trusted_schema", false).unwrap(); + testutil::seed_account(&conn); + let (_, message) = + testutil::seed_message(&conn, "maria@example.com", "September forecast", false); + conn.execute("INSERT INTO attachments(id,message_id,filename,mime_type) VALUES (1,?1,'Résumé final.pdf','application/pdf')",[message]).unwrap(); + let count = |query: &str| { + conn.query_row( + "SELECT count(*) FROM attachment_files_fts WHERE attachment_files_fts MATCH ?1", + [super::attachment_match_query(query)], + |r| r.get::<_, i64>(0), + ) + .unwrap() + }; + assert_eq!(count("resume maria"), 1); + conn.execute( + "UPDATE messages SET subject='October budget' WHERE id=?1", + [message], + ) + .unwrap(); + assert_eq!(count("october"), 1); + assert_eq!(count("september"), 0); + conn.execute("DELETE FROM attachments WHERE id=1", []) + .unwrap(); + assert_eq!(count("resume"), 0); +} + +#[test] +fn attachment_pages_are_scoped_stable_and_do_not_mark_mail_read() { + use crate::db::testutil; + let conn = testutil::conn(); + testutil::seed_account(&conn); + let (_, message) = testutil::seed_message(&conn, "maria@example.com", "Forecast", false); + for i in 1..=205 { + conn.execute("INSERT INTO attachments(id,message_id,filename,mime_type) VALUES (?1,?2,?3,'application/pdf')",params![i,message,format!("Résumé {i}.pdf")]).unwrap(); + } + let page = attachment_page(&conn, Some(1), "", None).unwrap(); + assert_eq!(page.len(), PAGE_SIZE + 1); + assert_eq!(page[0].id, 205); + conn.execute( + "INSERT INTO attachments(id,message_id,filename) VALUES (206,?1,'New file')", + [message], + ) + .unwrap(); + let next = attachment_page(&conn, Some(1), "", Some(page[PAGE_SIZE - 1].id)).unwrap(); + assert!(next.iter().all(|a| a.id < page[PAGE_SIZE - 1].id)); + assert!( + attachment_page(&conn, Some(99), "", None) + .unwrap() + .is_empty() + ); + assert_eq!( + attachment_page(&conn, None, &attachment_match_query("resume maria"), None) + .unwrap() + .len(), + PAGE_SIZE + 1 + ); + assert!( + attachment_page(&conn, None, &attachment_match_query("\" OR secret*"), None) + .unwrap() + .is_empty() + ); + let read: i64 = conn + .query_row("SELECT is_read FROM messages WHERE id=?1", [message], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(read, 0); +} + +#[test] +fn migration_indexes_existing_attachments_without_losing_mail() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch(include_str!("../db/migrations/001_init.sql")) + .unwrap(); + conn.pragma_update(None, "user_version", 1).unwrap(); + crate::db::testutil::seed_account(&conn); + let (_, message) = + crate::db::testutil::seed_message(&conn, "person@example.com", "Existing mail", false); + conn.execute( + "INSERT INTO attachments(message_id,filename) VALUES (?1,'old-contract.pdf')", + [message], + ) + .unwrap(); + crate::db::migrations::run(&mut conn).unwrap(); + let page = attachment_page(&conn, None, &attachment_match_query("contract"), None).unwrap(); + assert_eq!(page.len(), 1); + assert_eq!(page[0].filename, "old-contract.pdf"); + assert_eq!( + conn.query_row("PRAGMA integrity_check", [], |r| r.get::<_, String>(0)) + .unwrap(), + "ok" + ); +} + +#[tokio::test] +async fn discovery_follows_only_same_origin_redirects() { + let (base, requests) = server(vec![ + (307, "Location: /jmap/session\r\n".into(), String::new()), + ( + 200, + "Content-Type: application/json\r\n".into(), + "{\"sessionState\":\"s1\"}".into(), + ), + ]) + .await; + let t = transport::Transport::new(&base, "user", "secret").unwrap(); + assert_eq!( + t.discover(&format!("{base}/.well-known/jmap")) + .await + .unwrap()["sessionState"], + "s1" + ); + assert!(requests.await.unwrap()[1].starts_with("GET /jmap/session")); + let (base, requests) = server(vec![( + 307, + "Location: https://other.example/jmap/session\r\n".into(), + String::new(), + )]) + .await; + let t = transport::Transport::new(&base, "user", "secret").unwrap(); + assert!( + t.discover(&format!("{base}/.well-known/jmap")) + .await + .is_err() + ); + requests.await.unwrap(); +} + +#[tokio::test] +async fn discovery_resolves_templates_from_final_redirect_location() { + let (base, requests) = server(vec![ + (307, "Location: /proxy/session\r\n".into(), String::new()), + ( + 200, + "Content-Type: application/json\r\n".into(), + json!({ + "apiUrl": "api", "uploadUrl": "upload/{accountId}", + "downloadUrl": "download/{accountId}/{blobId}/{name}?type={type}", + "eventSourceUrl": "events?types={types}&closeafter={closeafter}&ping={ping}" + }) + .to_string(), + ), + ]) + .await; + let transport = transport::Transport::new(&base, "user", "secret").unwrap(); + let session = transport + .discover(&format!("{base}/.well-known/jmap")) + .await + .unwrap(); + assert_eq!(session["apiUrl"], format!("{base}/proxy/api")); + assert_eq!( + session["uploadUrl"], + format!("{base}/proxy/upload/{{accountId}}") + ); + assert_eq!( + session["downloadUrl"], + format!("{base}/proxy/download/{{accountId}}/{{blobId}}/{{name}}?type={{type}}") + ); + assert_eq!( + session["eventSourceUrl"], + format!("{base}/proxy/events?types={{types}}&closeafter={{closeafter}}&ping={{ping}}") + ); + requests.await.unwrap(); +} + +#[tokio::test] +async fn jmap_listing_preserves_query_order_when_get_reorders_records() { + let session = json!({ + "capabilities": {"urn:ietf:params:jmap:core":{}, jmap::CAPABILITY:{}}, + "primaryAccounts": {jmap::CAPABILITY:"mine"}, + "accounts": {"mine":{"isReadOnly":false,"accountCapabilities":{jmap::CAPABILITY:{"fileNodeQuerySortOptions":["name"]}}}}, + "apiUrl":"/jmap", "uploadUrl":"/upload/{accountId}", + "downloadUrl":"/download/{accountId}/{blobId}/{name}?type={type}" + }); + let query = json!({"methodResponses":[["FileNode/query",{"accountId":"mine","ids":["b","a"],"total":2,"queryState":"q1"},"files"]]}); + let get = json!({"methodResponses":[["FileNode/get",{"accountId":"mine","list":[{"id":"a","name":"Zulu"},{"id":"b","name":"Alpha"}],"state":"s1"},"files"]]}); + let (base, requests) = server( + [session, query, get] + .into_iter() + .map(|body| { + ( + 200, + "Content-Type: application/json\r\n".into(), + body.to_string(), + ) + }) + .collect(), + ) + .await; + let mut client = jmap::FileClient::connect(&base, "user", "secret", None) + .await + .unwrap(); + let page = client.list(None, "", 0).await.unwrap(); + assert_eq!( + page.nodes + .iter() + .map(|n| n.name.as_str()) + .collect::>(), + ["Alpha", "Zulu"] + ); + assert!(page.next_position.is_none()); + requests.await.unwrap(); +} + +#[test] +fn jmap_get_rejects_unrequested_and_duplicate_records() { + let ids = vec!["b".to_owned(), "a".to_owned(), "deleted".to_owned()]; + let ordered = |records: Vec| transport::ordered_records(&ids, records, String::as_str); + assert_eq!(ordered(vec!["a".into(), "b".into()]).unwrap(), ["b", "a"]); + assert!(ordered(vec!["unexpected".into()]).is_err()); + assert!(ordered(vec!["a".into(), "a".into()]).is_err()); +} + +#[test] +fn stalwart_directory_has_nullable_file_properties() { + let node:FileNode=serde_json::from_value(json!({"id":"folder","name":"Documents","parentId":null,"nodeType":"directory","blobId":null,"size":null,"type":null,"executable":null,"myRights":{"mayRead":true,"mayAddChildren":true}})).unwrap(); + assert!(node.is_directory()); + assert!(!node.executable); + assert!(node.my_rights.add()); +} + +#[tokio::test] +async fn dav_replacement_requires_both_destination_etag_and_owned_lock() { + let listing = r#"/dav/file/me/HTTP/1.1 200 OK/dav/file/me/destination.txt"target-v1"destination.txtHTTP/1.1 200 OK"#; + let (base, requests) = server(vec![ + (200, "DAV: 1, 2\r\n".into(), String::new()), + ( + 200, + "Lock-Token: \r\n".into(), + String::new(), + ), + ( + 200, + "Lock-Token: \r\n".into(), + String::new(), + ), + (207, String::new(), listing.into()), + (204, String::new(), String::new()), + ]) + .await; + let mut client = dav::DavClient::connect(&format!("{base}/dav/file/me/"), "user", "secret") + .await + .unwrap(); + let source = FileNode { + id: format!("{base}/dav/file/me/source.txt"), + name: "source.txt".into(), + etag: Some("\"source-v1\"".into()), + node_type: Some("file".into()), + ..Default::default() + }; + let target = FileNode { + id: format!("{base}/dav/file/me/destination.txt"), + name: "destination.txt".into(), + etag: Some("\"target-v1\"".into()), + node_type: Some("file".into()), + ..Default::default() + }; + client.lock(&source).await.unwrap(); + client.lock(&target).await.unwrap(); + client + .relocate_with_policy( + &source, + &client.root, + "destination.txt", + true, + CollisionPolicy::Replace, + ) + .await + .unwrap(); + let requests = requests.await.unwrap(); + let request = requests[4].to_lowercase(); + assert_eq!( + request + .lines() + .filter(|line| line.starts_with("if:")) + .count(), + 1 + ); + assert!(request.contains("()")); + assert!(request.contains("overwrite: t\r\n")); + assert!(request.contains("if-match: \"source-v1\"\r\n")); + assert!( + request.contains("([\"target-v1\"] )"), + "ETag and lock must be AND, not OR: {request}" + ); +} + +#[tokio::test] +async fn streamed_upload_pins_length_and_reports_progress_across_tasks() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("source.txt"); + let original = "bounded-data".repeat(10_000); + tokio::fs::write(&path, &original).await.unwrap(); + let observer = std::sync::Arc::new(progress::Progress::default()); + let (body, length) = progress::track(observer.clone(), upload_body(&path, MAX_TRANSFER)) + .await + .unwrap(); + use tokio::io::AsyncWriteExt; + tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap() + .write_all(b"not-in-selected-length") + .await + .unwrap(); + let (base, requests) = server(vec![(201, String::new(), String::new())]).await; + tokio::spawn(async move { + reqwest::Client::new() + .put(base) + .header("Content-Length", length) + .body(body) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + }) + .await + .unwrap(); + let request = requests.await.unwrap().pop().unwrap(); + assert!(request.ends_with(&original)); + assert!(!request.contains("not-in-selected-length")); + let observed = observer.snapshot(); + assert_eq!(observed.done, length); + assert_eq!(observed.total, length); + assert_eq!(observed.phase, 1); +} diff --git a/crates/flectar-mail-core/src/files/text_index.rs b/crates/flectar-mail-core/src/files/text_index.rs new file mode 100644 index 0000000..76f5c77 --- /dev/null +++ b/crates/flectar-mail-core/src/files/text_index.rs @@ -0,0 +1,175 @@ +//! Bounded local text extraction. Never downloads attachments just to index +//! them, executes a document, or renders active HTML/SVG content. +use crate::{Core, error::Result}; +use rusqlite::{OptionalExtension, params}; +use std::path::Path; +const MAX_TEXT_BYTES: usize = 1024 * 1024; +#[derive(PartialEq)] +struct TextSource { + message: i64, + mime: String, + blob: Option, + part: Option, + section: Option, + path: Option, +} +fn source(c: &rusqlite::Connection, id: i64) -> rusqlite::Result> { + c.query_row( + "SELECT message_id,COALESCE(mime_type,''),jmap_blob_id,part_id,imap_section,file_path FROM attachments WHERE id=?1", + [id], |r| Ok(TextSource { + message: r.get(0)?, mime: r.get(1)?, blob: r.get(2)?, + part: r.get(3)?, section: r.get(4)?, path: r.get(5)?, + }), + ).optional() +} +fn store_text( + c: &mut rusqlite::Connection, + id: i64, + expected: TextSource, + text: String, +) -> Result { + let tx = c.transaction()?; + // Reading the file yields to synchronization/account removal. Never put + // old bytes into the index of a replaced attachment or a reused row ID. + if source(&tx, id)?.as_ref() != Some(&expected) { + return Ok(false); + } + tx.execute("DELETE FROM attachment_text_fts WHERE rowid=?1", [id])?; + tx.execute( + "INSERT INTO attachment_text_fts(rowid,content) VALUES(?1,?2)", + params![id, text], + )?; + tx.commit()?; + Ok(true) +} +fn supported(mime: &str) -> bool { + mime.starts_with("text/") + || matches!( + mime, + "application/json" | "application/xml" | "application/javascript" + ) + || mime.ends_with("+json") + || mime.ends_with("+xml") +} +impl Core { + pub(crate) async fn index_attachment_text(&self, id: i64, path: &Path) -> Result { + let Some(expected) = self.db.read(move |c| Ok(source(c, id)?)).await? else { + return Ok(false); + }; + if !supported(&expected.mime) || expected.path.as_deref() != path.to_str() { + return Ok(false); + } + let bytes = match crate::file_io::read(path, MAX_TEXT_BYTES, "attachment text").await { + Ok(bytes) => bytes, + Err(_) => return Ok(false), + }; + let (encoding, skip) = + encoding_rs::Encoding::for_bom(&bytes).unwrap_or((encoding_rs::UTF_8, 0)); + let (text, _, errors) = encoding.decode(&bytes[skip..]); + if errors || text.contains('\0') { + return Ok(false); + } + let text = text.into_owned(); + self.db + .write(move |c| store_text(c, id, expected, text)) + .await + } + + pub async fn index_cached_attachment_text(&self, account: Option) -> Result { + let mut after = 0; + let mut count = 0; + loop { + let rows=self.db.read(move |c| {let mut q=c.prepare("SELECT a.id,a.file_path FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id>?1 AND a.file_path IS NOT NULL AND (?2 IS NULL OR m.account_id=?2) ORDER BY a.id LIMIT 100")?;Ok(q.query_map(params![after,account],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?)))?.collect::>>()?)}).await?; + if rows.is_empty() { + break; + } + for (id, path) in rows { + after = id; + count += usize::from(self.index_attachment_text(id, Path::new(&path)).await?); + } + } + Ok(count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn text_search_is_scoped_and_removed_with_attachment() { + let root = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(crate::config::Paths::for_tests(root.path())) + .await + .unwrap(); + core.db.write(|c|{ + crate::db::testutil::seed_account(c); + c.execute("INSERT OR IGNORE INTO folders(id,account_id,imap_name) VALUES(1,1,'INBOX')",[])?; + c.execute("INSERT INTO messages(id,account_id,folder_id,subject,from_addr,date) VALUES(1,1,1,'Meeting','sender@test.dev',0)",[])?; + c.execute("INSERT INTO attachments(id,message_id,filename,mime_type,size,is_inline) VALUES(1,1,'notes.txt','text/plain',20,0),(2,1,'document.pdf','application/pdf',20,0)",[])?; + Ok(()) + }).await.unwrap(); + let path = root.path().join("text"); + tokio::fs::write(&path, "Café project azimuth") + .await + .unwrap(); + let cached = path.to_string_lossy().into_owned(); + core.db + .write(move |c| { + c.execute("UPDATE attachments SET file_path=?1", [cached])?; + Ok(()) + }) + .await + .unwrap(); + let stale_path = root.path().join("superseded-copy"); + tokio::fs::write(&stale_path, "staleprivateword") + .await + .unwrap(); + assert!(!core.index_attachment_text(1, &stale_path).await.unwrap()); + assert!(core.index_attachment_text(1, &path).await.unwrap()); + assert!(!core.index_attachment_text(2, &path).await.unwrap()); + core.db + .write(|c| { + let stale = source(c, 1)?.unwrap(); + c.execute( + "UPDATE attachments SET jmap_blob_id='replacement' WHERE id=1", + [], + )?; + assert!(!store_text(c, 1, stale, "staleprivateword".into())?); + assert_eq!( + c.query_row("SELECT count(*) FROM attachment_text_fts", [], |r| r + .get::<_, i64>(0))?, + 0 + ); + Ok(()) + }) + .await + .unwrap(); + assert!(core.index_attachment_text(1, &path).await.unwrap()); + assert_eq!( + core.attachment_files(Some(1), "azimuth".into(), None) + .await + .unwrap() + .len(), + 1 + ); + assert!( + core.attachment_files(Some(2), "azimuth".into(), None) + .await + .unwrap() + .is_empty() + ); + core.db + .write(|c| { + c.execute("DELETE FROM attachments WHERE id=1", [])?; + Ok(()) + }) + .await + .unwrap(); + assert!( + core.attachment_files(None, "azimuth".into(), None) + .await + .unwrap() + .is_empty() + ); + } +} diff --git a/crates/flectar-mail-core/src/files/transport.rs b/crates/flectar-mail-core/src/files/transport.rs new file mode 100644 index 0000000..7c16014 --- /dev/null +++ b/crates/flectar-mail-core/src/files/transport.rs @@ -0,0 +1,176 @@ +use super::err; +use crate::error::Result; +use reqwest::{Client, Method, RequestBuilder, Response}; +use url::Url; + +#[derive(Clone)] +pub struct Transport { + http: Client, + origin: url::Origin, + user: String, + secret: String, +} +impl Transport { + pub fn new(endpoint: &str, user: &str, secret: &str) -> Result { + let url = validated_url(endpoint)?; + Ok(Self { + http: Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(120)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| err("Could not initialize file transport."))?, + origin: url.origin(), + user: user.into(), + secret: secret.into(), + }) + } + pub fn request(&self, method: &str, url: &str) -> Result { + let url = validated_url(url)?; + if url.origin() != self.origin { + return Err(err( + "Storage endpoint changed origin; credentials were not sent.", + )); + } + Ok(self + .http + .request( + Method::from_bytes(method.as_bytes()).map_err(|_| err("Invalid HTTP method."))?, + url, + ) + .basic_auth(&self.user, Some(&self.secret))) + } + /// RFC 8620 discovery can redirect; credentials stay on the original origin. + /// Mutations deliberately do not redirect or retry. + pub async fn discover(&self, endpoint: &str) -> Result { + let mut url = validated_url(endpoint)?; + for _ in 0..6 { + let response = self + .request("GET", url.as_str())? + .send() + .await + .map_err(|_| err("Could not discover file storage."))?; + if response.status().is_redirection() { + let location = response + .headers() + .get("Location") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| err("Invalid discovery redirect."))?; + url = url + .join(location) + .map_err(|_| err("Invalid discovery redirect URL."))?; + let _ = self.request("GET", url.as_str())?; + continue; + } + if !response.status().is_success() { + return Err(err(format!( + "File discovery failed (HTTP {}).", + response.status().as_u16() + ))); + } + let mut session: serde_json::Value = serde_json::from_slice( + &crate::http_body::bytes(response, 16 * 1024 * 1024, "File session").await?, + )?; + // Resolve relative templates against the final discovery location, + // including after redirects. Mail search and file push use these too. + for key in ["apiUrl", "uploadUrl", "downloadUrl", "eventSourceUrl"] { + if let Some(template) = session[key].as_str() { + let resolved = url + .join(template) + .map_err(|_| err("Invalid session endpoint."))? + .to_string() + .replace("%7B", "{") + .replace("%7D", "}"); + session[key] = resolved.into(); + } + } + return Ok(session); + } + Err(err("Too many file discovery redirects.")) + } + pub async fn send(&self, request: RequestBuilder) -> Result { + let response = request.send().await.map_err(|e| { + if e.is_timeout() { + err("File request timed out. Refresh before retrying a change.") + } else { + err("Could not reach file storage.") + } + })?; + if !response.status().is_success() { + return Err(err(match response.status().as_u16() { + 401 => "Storage authentication expired. Reconnect the account.", + 403 => "You do not have permission for this file operation.", + 404 => "File storage or the requested file was not found.", + 409 => "The destination or file hierarchy conflicts with this operation.", + 412 => "The file changed on the server. Refresh before retrying.", + 423 => "The file is locked. Refresh or release your lock before editing.", + 413 | 507 => "The file exceeds the server size limit or storage quota.", + 429 => "Too many requests. Wait before refreshing.", + _ => "The file server rejected the request. Refresh before retrying.", + })); + } + Ok(response) + } + pub async fn json(&self, request: RequestBuilder) -> Result { + let response = self.send(request).await?; + let bytes = crate::http_body::bytes(response, 16 * 1024 * 1024, "File metadata").await?; + Ok(serde_json::from_slice(&bytes)?) + } +} + +/// RFC 8620 /get may return records in any order. Preserve the /query order +/// across pages, allowing records deleted between query and get to be absent. +pub(super) fn ordered_records( + ids: &[String], + records: Vec, + id: impl Fn(&T) -> &str, +) -> Result> { + let requested = ids + .iter() + .map(String::as_str) + .collect::>(); + let mut by_id = std::collections::HashMap::with_capacity(records.len()); + for record in records { + let key = id(&record).to_owned(); + if !requested.contains(key.as_str()) || by_id.insert(key, record).is_some() { + return Err(err("JMAP returned an unexpected or duplicate record.")); + } + } + Ok(ids.iter().filter_map(|id| by_id.remove(id)).collect()) +} +pub fn validated_url(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| err("Enter a valid HTTPS storage URL."))?; + let local = match url.host() { + Some(url::Host::Domain(h)) => h == "localhost", + Some(url::Host::Ipv4(a)) => a.is_loopback(), + Some(url::Host::Ipv6(a)) => a.is_loopback(), + _ => false, + }; + if value.len() > 16384 + || !(url.scheme() == "https" || url.scheme() == "http" && local) + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(err( + "Storage requires HTTPS and a URL without credentials or fragments (HTTP is allowed on loopback).", + )); + } + Ok(url) +} +/// RFC 6570 simple expansion: encode even '/' in opaque identifiers. +pub fn expand(template: &str, values: &[(&str, &str)]) -> Result { + let mut out = template.to_owned(); + for (key, value) in values { + let encoded: String = url::form_urlencoded::byte_serialize(value.as_bytes()) + .collect::() + .replace('+', "%20"); + out = out.replace(&format!("{{{key}}}"), &encoded); + } + if out.contains(['{', '}']) { + return Err(err("Unsupported storage URL template.")); + } + validated_url(&out)?; + Ok(out) +} diff --git a/crates/flectar-mail-core/src/imap/mod.rs b/crates/flectar-mail-core/src/imap/mod.rs index 476c11a..8cfbaa4 100644 --- a/crates/flectar-mail-core/src/imap/mod.rs +++ b/crates/flectar-mail-core/src/imap/mod.rs @@ -125,6 +125,52 @@ pub fn tls_connector() -> TlsConnector { TlsConnector::from(CONFIG.clone()) } +/// Parse a bounded PEM bundle and reject private keys or non-certificate data. +pub fn trusted_certificates(pem: &str) -> Result>> { + use rustls::pki_types::pem::PemObject; + if pem.is_empty() { + return Ok(Vec::new()); + } + if pem.len() > 256 * 1024 || pem.contains("PRIVATE KEY") { + return Err(CoreError::Tls( + "Import a public PEM certificate, not a private key (maximum 256 KiB).".into(), + )); + } + let certificates = rustls::pki_types::CertificateDer::pem_slice_iter(pem.as_bytes()) + .collect::, _>>() + .map_err(|_| CoreError::Tls("The imported file is not a valid PEM certificate.".into()))?; + if certificates.is_empty() { + return Err(CoreError::Tls( + "No certificates found in the PEM file.".into(), + )); + } + let mut roots = rustls::RootCertStore::empty(); + for cert in &certificates { + roots + .add(cert.clone()) + .map_err(|_| CoreError::Tls("The imported certificate is invalid.".into()))?; + } + Ok(certificates) +} + +fn account_tls_connector(pem: &str) -> Result { + if pem.is_empty() { + return Ok(tls_connector()); + } + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + for cert in trusted_certificates(pem)? { + roots.add(cert).map_err(|e| CoreError::Tls(e.to_string()))?; + } + let config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| CoreError::Tls(e.to_string()))? + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(TlsConnector::from(Arc::new(config))) +} + #[derive(Clone)] pub enum ImapCredentials { Password { user: String, password: String }, @@ -178,11 +224,26 @@ impl async_imap::Authenticator for XOAuth2Authenticator { } pub async fn connect(host: &str, port: u16, creds: ImapCredentials) -> Result { + connect_with_settings( + host, + port, + creds, + &crate::models::MailConnectionSettings::default(), + ) + .await +} + +pub async fn connect_with_settings( + host: &str, + port: u16, + creds: ImapCredentials, + settings: &crate::models::MailConnectionSettings, +) -> Result { // Bound the whole handshake so a silent stall (server never sends the // greeting, or an AUTHENTICATE that never gets a tagged response) surfaces // as an error instead of hanging the sync actor forever. const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - tokio::time::timeout(CONNECT_TIMEOUT, connect_inner(host, port, creds)) + tokio::time::timeout(CONNECT_TIMEOUT, connect_inner(host, port, creds, settings)) .await .map_err(|_| { tracing::warn!(%host, port, "imap connect: timed out after 30s"); @@ -190,36 +251,64 @@ pub async fn connect(host: &str, port: u16, creds: ImapCredentials) -> Result Result { - tracing::debug!(%host, port, "imap connect: opening TCP"); +async fn connect_inner( + host: &str, + port: u16, + creds: ImapCredentials, + settings: &crate::models::MailConnectionSettings, +) -> Result { + use crate::models::ConnectionSecurity; + let connector = account_tls_connector(&settings.trusted_certificate_pem)?; let tcp = TcpStream::connect((host, port)) .await .map_err(|e| CoreError::Imap(format!("connect {host}:{port}: {e}")))?; tcp.set_nodelay(true).ok(); + let starttls = match settings.imap_security { + ConnectionSecurity::Starttls => true, + ConnectionSecurity::Tls => false, + ConnectionSecurity::Auto => port == 143, + }; + let tcp = if starttls { + let mut client = async_imap::Client::new(tcp); + if client + .read_response() + .await + .map_err(|e| CoreError::Imap(e.to_string()))? + .is_none() + { + return Err(CoreError::Imap( + "Server closed before the IMAP greeting.".into(), + )); + } + // Required upgrade: never send credentials if STARTTLS is rejected. + client + .run_command_and_check_ok("STARTTLS", None) + .await + .map_err(|e| { + CoreError::Tls(format!( + "IMAP STARTTLS was rejected: {e}. Check the server port and security mode." + )) + })?; + client.into_inner() + } else { + tcp + }; let server_name = rustls::pki_types::ServerName::try_from(host.to_string()) .map_err(|e| CoreError::Tls(e.to_string()))?; - tracing::debug!(%host, "imap connect: TCP up, starting TLS"); - let tls = tls_connector() - .connect(server_name, tcp) - .await - .map_err(|e| CoreError::Tls(e.to_string()))?; - tracing::debug!(%host, "imap connect: TLS up, reading greeting"); + let tls = connector.connect(server_name, tcp).await + .map_err(|e| CoreError::Tls(format!("IMAP {host}:{port}: {e}. Check SSL/TLS versus STARTTLS and use the hostname on the server certificate. For Proton Bridge, import its exported certificate.")))?; let mut client = async_imap::Client::new(tls); - // async-imap requires the caller to consume the server greeting before - // issuing any command. Skipping it leaves `* OK ...ready` in the buffer, - // which the login/AUTHENTICATE handshake then reads in place of the real - // tagged/continuation response - desyncing the exchange so it hangs until - // the socket times out (the "stuck Offline, never syncs" bug). - match client.read_response().await { - Ok(Some(greeting)) => { - tracing::debug!(%host, greeting = ?greeting.parsed(), "imap connect: greeting received, authenticating"); - } - Ok(None) => { - return Err(CoreError::Imap(format!( - "{host}: connection closed before IMAP greeting" - ))); - } - Err(e) => return Err(CoreError::Imap(format!("{host}: reading greeting: {e}"))), + // STARTTLS has already consumed the greeting; no second greeting follows. + if !starttls + && client + .read_response() + .await + .map_err(|e| CoreError::Imap(e.to_string()))? + .is_none() + { + return Err(CoreError::Imap( + "Server closed before the IMAP greeting.".into(), + )); } let session = match creds { diff --git a/crates/flectar-mail-core/src/jmap/sync.rs b/crates/flectar-mail-core/src/jmap/sync.rs index 0c9643b..90857e6 100644 --- a/crates/flectar-mail-core/src/jmap/sync.rs +++ b/crates/flectar-mail-core/src/jmap/sync.rs @@ -458,6 +458,7 @@ fn header_properties() -> Vec { EmailProperty::Subject, EmailProperty::SentAt, EmailProperty::HasAttachment, + EmailProperty::Attachments, EmailProperty::Preview, ] } @@ -780,6 +781,11 @@ async fn persist_emails( blob_id.as_deref(), )?; } + // Populate Files without downloading the message or changing $seen. + // Preserve MIME-derived rows once a raw body is cached; their part + // identifiers belong to the MIME parser, not the JMAP server. + let body_cached: bool=tx.query_row("SELECT body_state='cached' FROM messages WHERE id=?1",[local_id],|r|r.get(0))?; + if !body_cached { persist_attachment_parts(&tx, local_id, email.attachments().unwrap_or_default())?; } if !pending_mailboxes { repo::gmail::set_message_folders(&tx, local_id, &folder_ids)?; } @@ -845,6 +851,47 @@ async fn persist_emails( Ok(()) } +fn persist_attachment_parts( + db: &rusqlite::Connection, + message: i64, + parts: &[jmap_client::email::EmailBodyPart], +) -> Result<()> { + let mut retained = Vec::new(); + for part in parts { + let Some(blob) = part.blob_id() else { + continue; + }; + let Some(part_id) = part.part_id() else { + continue; + }; + let existing: Option = db + .query_row( + "SELECT id FROM attachments WHERE message_id=?1 AND part_id=?2 AND jmap_blob_id=?3", + params![message, part_id, blob], + |r| r.get(0), + ) + .optional()?; + let id = if let Some(id) = existing { + id + } else { + db.execute("INSERT INTO attachments(message_id,part_id,filename,mime_type,size,content_id,is_inline,jmap_blob_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![message,part_id,part.name(),part.content_type(),part.size() as i64,part.content_id(),part.content_disposition()==Some("inline"),blob])?; + db.last_insert_rowid() + }; + retained.push(id); + } + let old = { + let mut q = db.prepare("SELECT id FROM attachments WHERE message_id=?1")?; + q.query_map([message], |r| r.get::<_, i64>(0))? + .collect::>>()? + }; + for id in old { + if !retained.contains(&id) { + db.execute("DELETE FROM attachments WHERE id=?1", [id])?; + } + } + Ok(()) +} + async fn execute_actions(ctx: &SyncCtx, config: &AccountConfig, c: &ConnectedClient) -> Result<()> { let account_id = config.id; let actions = ctx @@ -1315,6 +1362,20 @@ async fn build_draft_message( attachments: outgoing_attachments, }; let (message_id, raw) = crate::mime::build_message(&outgoing)?; + let raw = crate::mail_security::protect_draft( + &ctx.db, + config.id, + draft_id, + raw, + outgoing + .to + .iter() + .chain(outgoing.cc) + .chain(outgoing.bcc) + .cloned() + .collect(), + ) + .await?; if raw.len() > max_upload { return Err(CoreError::Jmap(format!( "message exceeds the server or local upload limit of {} MiB", @@ -1948,11 +2009,11 @@ async fn fetch_attachment( config: &AccountConfig, attachment_id: i64, ) -> Result> { - let (message_id, part_id, raw_path) = ctx + let (message_id, part_id, raw_path, blob_id) = ctx .db .read(move |conn| { conn.query_row( - "SELECT a.message_id,a.part_id,m.raw_path FROM attachments a + "SELECT a.message_id,a.part_id,m.raw_path,a.jmap_blob_id FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=?1", params![attachment_id], |row| { @@ -1960,12 +2021,17 @@ async fn fetch_attachment( row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, )) }, ) .map_err(Into::into) }) .await?; + if let Some(blob) = blob_id { + let connected = connect(ctx, config).await?; + return download_message_bounded(&connected, &blob).await; + } let part_id = part_id.ok_or_else(|| CoreError::NotFound("attachment MIME part".into()))?; let cached = match raw_path { Some(path) => crate::file_io::read(path, MAX_JMAP_MESSAGE_BYTES, "cached JMAP message") @@ -2054,3 +2120,45 @@ mod tests { assert!(!updates.contains_key("email-1")); } } + +#[cfg(test)] +mod attachment_metadata_tests { + use super::*; + #[test] + fn header_attachment_metadata_preserves_identity_and_invalidates_changed_blobs() { + let db = crate::db::testutil::conn(); + crate::db::testutil::seed_account(&db); + db.execute("INSERT INTO messages(id,account_id,folder_id,subject,from_addr,date) VALUES(99,1,1,'File metadata','sender@test.dev',0)",[]).unwrap(); + let parts:Vec=serde_json::from_value(serde_json::json!([{"partId":"2","blobId":"blob1","name":"notes.txt","type":"text/plain","size":12,"disposition":"attachment"}])).unwrap(); + persist_attachment_parts(&db, 99, &parts).unwrap(); + let id: i64 = db + .query_row("SELECT id FROM attachments WHERE message_id=99", [], |r| { + r.get(0) + }) + .unwrap(); + db.execute( + "UPDATE attachments SET file_path='/private/cached' WHERE id=?1", + [id], + ) + .unwrap(); + persist_attachment_parts(&db, 99, &parts).unwrap(); + let same: (i64, String) = db + .query_row( + "SELECT id,file_path FROM attachments WHERE message_id=99", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(same, (id, "/private/cached".into())); + persist_attachment_parts(&db, 99, &[]).unwrap(); + assert_eq!( + db.query_row( + "SELECT count(*) FROM attachment_files_fts WHERE rowid=?1", + [id], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); + } +} diff --git a/crates/flectar-mail-core/src/lib.rs b/crates/flectar-mail-core/src/lib.rs index c867f90..ab2201b 100644 --- a/crates/flectar-mail-core/src/lib.rs +++ b/crates/flectar-mail-core/src/lib.rs @@ -13,12 +13,14 @@ pub mod embed; pub mod error; pub mod events; mod file_io; +pub mod files; pub mod googlecal; pub mod graph; pub mod graphcal; mod http_body; pub mod imap; pub mod jmap; +pub mod mail_security; pub mod mime; pub mod models; pub mod oauth; @@ -26,6 +28,7 @@ pub mod queue; pub mod route; pub mod scheduler; pub mod search; +pub mod signatures; pub mod smtp; pub mod sync; pub mod unsubscribe; @@ -220,6 +223,9 @@ pub struct Core { /// Events and provider calendar sync state. Kept physically separate from /// the mail store so mailbox backfills cannot block the calendar UI. pub calendar_db: Db, + /// Remote files, sync cursors and durable transfer operations. + pub files_db: Db, + pub(crate) file_work_lock: Arc>, pub bus: EventBus, paths: Arc, tokens: TokenProvider, @@ -234,7 +240,7 @@ pub struct Core { #[cfg(feature = "local-embeddings")] embed: Arc, /// Fired by `cancel_oauth` to abort a pending browser sign-in (the - /// loopback wait otherwise blocks the UI until its 5-minute timeout). + /// loopback wait otherwise blocks the UI until its 15-minute timeout). oauth_cancel: Arc, /// Fired by `notify_ui_ready` once the Slint UI has finished its startup /// show (the first-run intro). Account actors wait for this before @@ -251,7 +257,7 @@ impl Core { Self::start_with_platform( paths, Arc::new(SystemCredentialStore), - Arc::new(LoopbackRedirectBroker), + Arc::new(LoopbackRedirectBroker::default()), true, ) .await @@ -271,8 +277,12 @@ impl Core { paths: Paths, credentials: CredentialStoreHandle, ) -> Result { - Self::start_mail_ui_with_platform(paths, credentials, Arc::new(LoopbackRedirectBroker)) - .await + Self::start_mail_ui_with_platform( + paths, + credentials, + Arc::new(LoopbackRedirectBroker::default()), + ) + .await } pub async fn start_mail_ui_with_platform( @@ -317,11 +327,14 @@ impl Core { calendar_db_path.display() )) })?; + let files_db = Db::open_files(&paths.files_db_file())?; tracing::info!("core startup: databases opened and migrated"); let bus = EventBus::new(); let core = Core { db, calendar_db, + files_db, + file_work_lock: Arc::new(tokio::sync::Mutex::new(())), bus, paths: Arc::new(paths), tokens: TokenProvider::new(credentials.clone(), oauth_redirects.clone()), @@ -341,6 +354,7 @@ impl Core { // calendar stores, then remove calendar rows that predate the durable // operation journal and no longer have a mail account owner. core.recover_cross_store_state().await?; + core.recover_files().await?; let removed_staged_files = core.cleanup_orphaned_draft_files().await?; if removed_staged_files > 0 { tracing::info!(removed_staged_files, "removed orphaned draft staging files"); @@ -621,6 +635,7 @@ impl Core { configs: Vec, ) -> Result { for config in &configs { + imap::trusted_certificates(&config.settings.connection.trusted_certificate_pem)?; let email = config.email.trim(); if email.len() > 320 || !email.contains('@') { return Err(CoreError::Other(format!( @@ -784,26 +799,52 @@ impl Core { }, }; } + match self.check_imap_smtp(args).await { + Ok(()) => ConnectionTestResult { + ok: true, + error: None, + }, + Err(e) => ConnectionTestResult { + ok: false, + error: Some(e.to_string()), + }, + } + } + + async fn check_imap_smtp(&self, args: &AddPasswordAccountArgs) -> Result<()> { let creds = imap::ImapCredentials::Password { user: args.username.clone(), password: args.password.clone(), }; - match imap::connect(&args.imap_host, args.imap_port, creds).await { - Ok(session) => { - imap::logout(session).await; - ConnectionTestResult { - ok: true, - error: None, - } - } - Err(e) => ConnectionTestResult { - ok: false, - error: Some(e.to_client_json()), + let session = + imap::connect_with_settings(&args.imap_host, args.imap_port, creds, &args.connection) + .await?; + imap::logout(session).await; + let config = AccountConfig { + id: 0, + email: args.email.clone(), + display_name: None, + avatar_url: None, + provider: Provider::Imap, + auth_kind: AuthKind::Password, + mail_protocol: MailProtocol::Imap, + username: args.username.clone(), + jmap_url: String::new(), + jmap_account_id: None, + imap_host: args.imap_host.clone(), + imap_port: args.imap_port, + smtp_host: args.smtp_host.clone(), + smtp_port: args.smtp_port, + settings: AccountSettings { + connection: args.connection.clone(), + ..Default::default() }, - } + }; + smtp::test_connection(&config, &smtp::SmtpAuth::Password(args.password.clone())).await } pub async fn add_account_password(&self, args: AddPasswordAccountArgs) -> Result { + imap::trusted_certificates(&args.connection.trusted_certificate_pem)?; let email = normalize_account_email(&args.email) .ok_or_else(|| CoreError::Auth("enter a valid email address".into()))?; let mut args = AddPasswordAccountArgs { @@ -820,6 +861,7 @@ impl Core { imap_port: args.imap_port, smtp_host: args.smtp_host.trim().to_owned(), smtp_port: args.smtp_port, + connection: args.connection, }; if args.username.is_empty() { args.username = args.email.clone(); @@ -853,6 +895,9 @@ impl Core { args }; + credentials::check_available_async(self.credentials.clone()).await?; + self.recover_incomplete_account_credentials().await?; + // Verify credentials before storing anything. For JMAP, also pin the // exact Mail account selected from the authenticated Session so a // later reconnect can never drift to a different shared account. @@ -869,12 +914,7 @@ impl Core { .account_id, ) } else { - let probe = self.test_connection(&args).await; - if !probe.ok { - return Err(CoreError::Auth( - probe.error.unwrap_or_else(|| "connection failed".into()), - )); - } + self.check_imap_smtp(&args).await?; None }; @@ -929,6 +969,11 @@ impl Core { smtp_port: a.smtp_port, }, )?; + let mut settings = repo::accounts::get_config(&transaction, id)? + .ok_or_else(|| CoreError::NotFound("account".into()))? + .settings; + settings.connection = a.connection.clone(); + repo::accounts::set_settings(&transaction, id, &settings)?; transaction.commit()?; Ok(()) }) @@ -950,8 +995,9 @@ impl Core { let remote_account = jmap_account_id.clone(); self.db .write(move |conn| { - repo::accounts::insert( - conn, + let transaction = conn.transaction()?; + let id = repo::accounts::insert_with_sync_state( + &transaction, &repo::accounts::NewAccount { email: &a.email, display_name: a.display_name.as_deref(), @@ -967,7 +1013,15 @@ impl Core { smtp_host: &a.smtp_host, smtp_port: a.smtp_port, }, - ) + repo::accounts::CREDENTIAL_SETUP_STATE, + )?; + let settings = AccountSettings { + connection: a.connection.clone(), + ..Default::default() + }; + repo::accounts::set_settings(&transaction, id, &settings)?; + transaction.commit()?; + Ok(id) }) .await? }; @@ -983,10 +1037,7 @@ impl Core { // A newly inserted account without its password can never sync. // Roll it back atomically from the user's point of view. if is_new_account { - let _ = self - .db - .write(move |conn| repo::accounts::delete(conn, id)) - .await; + self.rollback_incomplete_account_credentials(id).await; } else if let Ok(Some(cfg)) = self .db .read(move |conn| repo::accounts::get_config(conn, id)) @@ -1002,6 +1053,16 @@ impl Core { return Err(error); } + if is_new_account + && let Err(error) = self + .db + .write(move |conn| repo::accounts::set_sync_state(conn, id, "idle")) + .await + { + self.rollback_incomplete_account_credentials(id).await; + return Err(error); + } + let cfg = self .db .read(move |conn| repo::accounts::get_config(conn, id)) @@ -1030,6 +1091,57 @@ impl Core { self.ui_ready.notify_one(); } + /// Finish the rollback for an account whose process stopped after + /// reserving its database id but before committing credentials. These rows + /// are excluded from account lists and actor startup. + async fn recover_incomplete_account_credentials(&self) -> Result<()> { + let account_ids = self + .db + .read(|conn| repo::accounts::credential_setup_ids(conn)) + .await?; + for account_id in account_ids { + self.tokens.forget_account(account_id).await; + credentials::delete_all_async(self.credentials.clone(), account_id).await?; + self.db + .write(move |conn| repo::accounts::delete(conn, account_id)) + .await?; + tracing::info!( + account_id, + "rolled back incomplete account credential setup" + ); + } + Ok(()) + } + + /// Compensate for a failed cross-store credential commit. Credentials are + /// deleted before the database id is released so a later account cannot + /// inherit an orphaned token. A cleanup failure keeps the row in its hidden + /// setup state for the next recovery attempt. + async fn rollback_incomplete_account_credentials(&self, account_id: i64) { + self.tokens.forget_account(account_id).await; + if let Err(error) = + credentials::delete_all_async(self.credentials.clone(), account_id).await + { + tracing::error!( + account_id, + %error, + "could not remove credentials after account setup failed" + ); + return; + } + if let Err(error) = self + .db + .write(move |conn| repo::accounts::delete(conn, account_id)) + .await + { + tracing::error!( + account_id, + %error, + "could not remove incomplete account row" + ); + } + } + pub async fn start_oauth( &self, provider: Provider, @@ -1085,6 +1197,14 @@ impl Core { consent_extra: &[&str], open_url: impl FnOnce(String) -> std::result::Result<(), String> + Send, ) -> Result { + let servers = match provider { + Provider::Gmail => &accounts::providers::GMAIL, + Provider::Microsoft => &accounts::providers::MICROSOFT, + Provider::Imap => return Err(CoreError::Auth("not an oauth provider".into())), + }; + credentials::check_available_async(self.credentials.clone()).await?; + self.recover_incomplete_account_credentials().await?; + let outcome = tokio::select! { r = oauth::flow::authorize_with_broker( provider, @@ -1097,12 +1217,6 @@ impl Core { return Err(CoreError::Auth("sign-in cancelled".into())); } }; - let servers = match provider { - Provider::Gmail => &accounts::providers::GMAIL, - Provider::Microsoft => &accounts::providers::MICROSOFT, - Provider::Imap => return Err(CoreError::Auth("not an oauth provider".into())), - }; - let email = outcome.email.clone(); let existing = self .db @@ -1123,16 +1237,6 @@ impl Core { let id = existing.id; let display_name = outcome.display_name.clone(); let avatar_url = outcome.avatar_url.clone(); - self.db - .write(move |conn| { - repo::accounts::update_oauth_identity( - conn, - id, - display_name.as_deref(), - avatar_url.as_deref(), - ) - }) - .await?; self.tokens .store_initial( id, @@ -1143,6 +1247,16 @@ impl Core { outcome.client_secret, ) .await?; + self.db + .write(move |conn| { + repo::accounts::update_oauth_identity( + conn, + id, + display_name.as_deref(), + avatar_url.as_deref(), + ) + }) + .await?; self.db .write(move |conn| repo::accounts::set_sync_state(conn, id, "idle")) .await?; @@ -1170,7 +1284,7 @@ impl Core { let id = self .db .write(move |conn| { - repo::accounts::insert( + repo::accounts::insert_with_sync_state( conn, &repo::accounts::NewAccount { email: &email, @@ -1187,11 +1301,13 @@ impl Core { smtp_host: servers.smtp_host, smtp_port: servers.smtp_port, }, + repo::accounts::CREDENTIAL_SETUP_STATE, ) }) .await?; - self.tokens + if let Err(error) = self + .tokens .store_initial( id, outcome.access_token, @@ -1200,7 +1316,20 @@ impl Core { outcome.client_id, outcome.client_secret, ) - .await?; + .await + { + self.rollback_incomplete_account_credentials(id).await; + return Err(error); + } + + if let Err(error) = self + .db + .write(move |conn| repo::accounts::set_sync_state(conn, id, "idle")) + .await + { + self.rollback_incomplete_account_credentials(id).await; + return Err(error); + } let cfg = self .db @@ -1239,6 +1368,7 @@ impl Core { Provider::Gmail => &[], Provider::Imap => return Err(CoreError::Auth("not an oauth provider".into())), }; + credentials::check_available_async(self.credentials.clone()).await?; let outcome = tokio::select! { // Hint the provider at the account being repaired so the browser // preselects it instead of silently reusing whatever session is @@ -1265,17 +1395,6 @@ impl Core { let display_name = outcome.display_name.clone(); let avatar_url = outcome.avatar_url.clone(); - self.db - .write(move |conn| { - repo::accounts::update_oauth_identity( - conn, - account_id, - display_name.as_deref(), - avatar_url.as_deref(), - ) - }) - .await?; - self.tokens .store_initial( account_id, @@ -1286,6 +1405,16 @@ impl Core { outcome.client_secret, ) .await?; + self.db + .write(move |conn| { + repo::accounts::update_oauth_identity( + conn, + account_id, + display_name.as_deref(), + avatar_url.as_deref(), + ) + }) + .await?; // Clear needs_reauth and wake the actor. The reauth pause loop retries // its connect on the next SyncNow; if no actor is live (e.g. reauth @@ -1411,19 +1540,34 @@ impl Core { } async fn complete_account_removal(&self, account_id: i64) -> Result<()> { + let _files_guard = self.file_work_lock.lock().await; if let Some(h) = self.handles.write().await.remove(&account_id) { h.abort(); } self.cal_handles.write().await.remove(&account_id); self.tokens.forget_account(account_id).await; self.purge_calendar_account(account_id).await?; + self.files_db + .write(move |conn| { + conn.execute("DELETE FROM connections WHERE account_id=?1", [account_id])?; + Ok(()) + }) + .await?; credentials::delete_all_async(self.credentials.clone(), account_id).await?; self.db - .write(move |conn| repo::accounts::delete(conn, account_id)) + .write(move |conn| { + conn.execute( + "DELETE FROM app_settings WHERE key = ?1", + [format!("files:{account_id}")], + )?; + repo::accounts::delete(conn, account_id) + }) .await?; for directory in [ self.paths.mail_dir(account_id), self.paths.attachments_dir(account_id), + self.paths.files_cache_dir(account_id), + self.paths.files_staging_dir(account_id), ] { match tokio::fs::remove_dir_all(directory).await { Ok(()) => {} @@ -1489,6 +1633,12 @@ impl Core { if !valid_accounts.contains(&account_id) { tracing::warn!(account_id, "removing orphaned calendar account data"); self.purge_calendar_account(account_id).await?; + self.files_db + .write(move |conn| { + conn.execute("DELETE FROM connections WHERE account_id=?1", [account_id])?; + Ok(()) + }) + .await?; } } let detached = self.detach_orphaned_calendar_message_links().await?; @@ -1633,6 +1783,12 @@ impl Core { }) .await?; + self.files_db + .write(|c| { + c.execute("DELETE FROM connections", [])?; + Ok(()) + }) + .await?; // Account id 0 owns app-level secrets such as the optional AI key. credentials::delete_all_async(self.credentials.clone(), 0).await?; let _ = tokio::fs::remove_dir_all(self.paths.draft_attachments_dir()).await; @@ -1642,7 +1798,7 @@ impl Core { Ok(()) } - /// Export coherent, integrity-checked copies of both SQLite stores plus a + /// Export coherent, integrity-checked copies of all three SQLite stores plus a /// versioned manifest. Credentials remain in the platform keyring and are /// intentionally outside this database snapshot. pub async fn create_database_snapshot( @@ -1666,7 +1822,15 @@ impl Core { .into(), )); } - db::snapshot::create(&self.db, &self.calendar_db, &destination).await + let _files_guard = self.file_work_lock.lock().await; + db::snapshot::create( + &self.db, + &self.calendar_db, + &self.files_db, + Some(self.paths.data_dir.join("file_transfers")), + &destination, + ) + .await } pub async fn sync_now(&self, account_id: Option) -> Result<()> { @@ -2087,6 +2251,9 @@ impl Core { attachments: Vec::new(), }; let (_msg_id, raw) = crate::mime::build_message(&out)?; + let raw = crate::mail_security::Gpg::default() + .protect(&raw, &cfg.settings.security, &cfg.email, out.to) + .await?; let auth = match cfg.auth_kind { AuthKind::Password => crate::smtp::SmtpAuth::Password( credentials::load_async(self.credentials.clone(), cfg.id, Slot::Password).await?, @@ -2633,7 +2800,13 @@ impl Core { access_token: self.tokens.access_token(config.id, config.provider).await?, }, }; - imap::connect(&config.imap_host, config.imap_port, credentials).await + imap::connect_with_settings( + &config.imap_host, + config.imap_port, + credentials, + &config.settings.connection, + ) + .await } pub async fn perform_action(&self, args: PerformActionArgs) -> Result { @@ -2979,7 +3152,10 @@ impl Core { repo::threads::recompute(&tx, tid)?; } repo::search::index_message(&tx, draft_id)?; - let sync_remote_account = if provider == "gmail" || mail_protocol == "jmap" { + let security = repo::accounts::get_config(&tx, args.account_id)?.ok_or_else(|| CoreError::NotFound("account".into()))?.settings.security; + // Protected drafts stay local until explicit Send. Even incomplete + // recipient lists must never cause a plaintext server upload. + let sync_remote_account = if !security.enabled() && (provider == "gmail" || mail_protocol == "jmap") { tx.execute( "UPDATE pending_actions SET state='cancelled',finished_at=?2 WHERE message_id=?1 AND kind='save_draft' AND state='pending'", @@ -3271,6 +3447,27 @@ impl Core { } pub async fn queue_send(&self, args: QueueSendArgs) -> Result { + let draft_id = args.draft_id; + let (policy, sender, recipients) = self + .db + .read(move |conn| { + let detail = repo::messages::detail(conn, draft_id)?; + let config = repo::accounts::get_config(conn, detail.account_id)? + .ok_or_else(|| CoreError::NotFound("account".into()))?; + let bcc: String = conn.query_row( + "SELECT bcc_json FROM messages WHERE id=?1 AND is_draft=1", + [draft_id], + |r| r.get(0), + )?; + let mut recipients = detail.to; + recipients.extend(detail.cc); + recipients.extend(serde_json::from_str::>(&bcc)?); + Ok((config.settings.security, config.email, recipients)) + }) + .await?; + crate::mail_security::Gpg::default() + .preflight(&policy, &sender, &recipients) + .await?; let settings = self.db.read(|conn| repo::settings::get(conn)).await?; let dispatch_at = args .send_at @@ -3287,7 +3484,7 @@ impl Core { WHERE message_id = ?1 AND kind = 'save_draft' AND state = 'pending'", rusqlite::params![draft_id, now_ms()], )?; - let payload = serde_json::json!({ "draftId": draft_id }); + let payload = serde_json::json!({ "draftId": draft_id, "mailSecurity": policy }); let aid = repo::actions::enqueue( conn, row.account_id, @@ -3343,11 +3540,11 @@ impl Core { // This check deliberately happens after acquiring the single-flight // lock: a concurrent caller may just have populated `file_path`. - let (message_id, part_id, imap_section, filename, mime_type, file_path) = self + let (message_id, part_id, imap_section, filename, mime_type, file_path, jmap_blob_id) = self .db .read(move |conn| { conn.query_row( - "SELECT message_id, part_id, imap_section, filename, mime_type, file_path + "SELECT message_id, part_id, imap_section, filename, mime_type, file_path, jmap_blob_id FROM attachments WHERE id = ?1", rusqlite::params![attachment_id], |r| { @@ -3358,6 +3555,7 @@ impl Core { r.get::<_, Option>(3)?, r.get::<_, Option>(4)?, r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, )) }, ) @@ -3368,6 +3566,12 @@ impl Core { if let Some(path) = file_path && tokio::fs::metadata(&path).await.is_ok() { + if let Err(error) = self + .index_attachment_text(attachment_id, std::path::Path::new(&path)) + .await + { + tracing::debug!(%error,"attachment text indexing deferred"); + } return Ok(path); } @@ -3381,7 +3585,10 @@ impl Core { // removed/corrupted but this row also has an IMAP section, safely fall // through to the remote section instead of making the attachment // permanently inaccessible because of a stale `raw_path`. - let legacy = match (row.raw_path.as_ref(), part_id.as_deref()) { + let legacy = match ( + row.raw_path.as_ref().filter(|_| jmap_blob_id.is_none()), + part_id.as_deref(), + ) { (Some(raw_path), Some(part_id)) => { match crate::file_io::read(raw_path, MAX_CACHED_MESSAGE_BYTES, "cached message") .await @@ -3448,6 +3655,9 @@ impl Core { Ok(()) }) .await?; + if let Err(error) = self.index_attachment_text(attachment_id, &path).await { + tracing::debug!(%error,"attachment text indexing deferred"); + } Ok(path_str) } @@ -4104,6 +4314,7 @@ impl Core { .clone() .filter(|p| !p.is_empty()) .ok_or_else(|| CoreError::CalDav("password is required".into()))?; + credentials::check_available_async(self.credentials.clone()).await?; caldav::DavAuth::Basic(user, pass) } }; @@ -4219,6 +4430,8 @@ impl Core { )); } + credentials::check_available_async(self.credentials.clone()).await?; + let outcome = tokio::select! { r = oauth::flow::authorize_with_broker( Provider::Gmail, @@ -4286,6 +4499,7 @@ impl Core { // Scope not yet consented (or refresh token stale): widen consent // in the browser, then mint the Graph token again. Err(CoreError::NeedsReauth) => { + credentials::check_available_async(self.credentials.clone()).await?; let outcome = tokio::select! { r = oauth::flow::authorize_with_broker( Provider::Microsoft, @@ -7152,7 +7366,15 @@ mod thread_read_tests { }) .await .unwrap(); - assert_eq!(mail_contract, (1, 1, false, "ok".to_owned())); + assert_eq!( + mail_contract, + ( + crate::db::migrations::LATEST_VERSION, + 1, + false, + "ok".to_owned() + ) + ); let calendar_contract = core .calendar_db @@ -7238,11 +7460,22 @@ mod account_backup_import_tests { mod local_data_reset_tests { use super::*; + async fn start_test_core(paths: &Paths) -> Core { + Core::start_mail_ui_with_credentials( + paths.clone(), + Arc::new(credentials::DevelopmentFileCredentialStore::new( + paths.data_dir.join("test-credentials.json"), + )), + ) + .await + .unwrap() + } + #[tokio::test] async fn delete_all_local_data_keeps_databases_open_and_empties_profile() { let temp = tempfile::tempdir().unwrap(); let paths = Paths::for_tests(temp.path()); - let core = Core::start_mail_ui(paths.clone()).await.unwrap(); + let core = start_test_core(&paths).await; core.db .write(|conn| { db::testutil::seed_account(conn); @@ -7325,7 +7558,7 @@ mod local_data_reset_tests { async fn startup_recovery_finishes_interrupted_removal_and_cleans_orphans() { let temp = tempfile::tempdir().unwrap(); let paths = Paths::for_tests(temp.path()); - let core = Core::start_mail_ui(paths.clone()).await.unwrap(); + let core = start_test_core(&paths).await; core.db .write(|conn| { db::testutil::seed_account(conn); diff --git a/crates/flectar-mail-core/src/mail_security.rs b/crates/flectar-mail-core/src/mail_security.rs new file mode 100644 index 0000000..ad9fc1a --- /dev/null +++ b/crates/flectar-mail-core/src/mail_security.rs @@ -0,0 +1,1141 @@ +//! OpenPGP/MIME via the installed GnuPG 2.x engine. Private keys and passphrases +//! stay with gpg-agent; account settings contain only public fingerprints. +use crate::{ + Core, + db::{Db, repo}, + error::{CoreError, Result}, + models::Address, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::PathBuf, process::Stdio, time::Duration}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +const MAX_CRYPTO_BYTES: usize = 100 * 1024 * 1024; +const MAX_STATUS_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MailSecurity { + pub signing_fingerprint: String, + pub sign_by_default: bool, + pub require_encryption: bool, + /// Explicitly verified email -> full primary fingerprint bindings. + pub recipient_keys: BTreeMap, +} +impl MailSecurity { + pub fn enabled(&self) -> bool { + self.sign_by_default || self.require_encryption + } + pub fn validate(&mut self) -> Result<()> { + self.signing_fingerprint = fingerprint(&self.signing_fingerprint, !self.enabled())?; + if self.recipient_keys.len() > 500 { + return Err(error("Too many recipient keys")); + } + let mut keys = BTreeMap::new(); + for (email, key) in &self.recipient_keys { + let email = email.trim().to_lowercase(); + if email.parse::().is_err() { + return Err(error("Invalid recipient email address")); + } + if keys.insert(email, fingerprint(key, false)?).is_some() { + return Err(error("Duplicate recipient address")); + } + } + self.recipient_keys = keys; + Ok(()) + } +} +fn error(message: &str) -> CoreError { + CoreError::Other(format!("OpenPGP: {message}")) +} +fn fingerprint(value: &str, allow_empty: bool) -> Result { + let value: String = value.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + if (allow_empty && value.is_empty()) + || (matches!(value.len(), 40 | 64) && value.bytes().all(|b| b.is_ascii_hexdigit())) + { + Ok(value.to_ascii_uppercase()) + } else { + Err(error("Use the full 40- or 64-digit key fingerprint")) + } +} + +#[derive(Clone, Default)] +pub struct Gpg { + // Test engines use isolated keyrings. Production uses the user's GnuPG home. + home: Option, +} +impl Gpg { + async fn run(&self, args: &[String], input: Option<&[u8]>) -> Result<(Vec, String)> { + if cfg!(any(target_os = "android", target_os = "ios")) { + return Err(error("OpenPGP requires desktop GnuPG")); + } + if input.is_some_and(|data| data.len() > MAX_CRYPTO_BYTES) { + return Err(error("Message exceeds the 100 MiB OpenPGP limit")); + } + let status_file = tempfile::NamedTempFile::new()?; + let mut cmd = tokio::process::Command::new("gpg"); + cmd.args([ + "--no-options", + "--batch", + "--yes", + "--no-auto-key-retrieve", + "--auto-key-locate", + "clear", + "--status-file", + ]); + cmd.arg(status_file.path()); + if let Some(home) = &self.home { + cmd.arg("--homedir").arg(home); + } + cmd.args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd + .spawn() + .map_err(|_| error("Install GnuPG 2.x and make gpg available on PATH"))?; + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let task = async { + let write = async { + if let Some(input) = input { + stdin.write_all(input).await?; + } + drop(stdin); + Ok::<_, std::io::Error>(()) + }; + let read = async { + let mut data = Vec::new(); + stdout + .take((MAX_CRYPTO_BYTES + 1) as u64) + .read_to_end(&mut data) + .await?; + Ok::<_, std::io::Error>(data) + }; + let status = async { + let mut data = Vec::new(); + stderr.take(1024 * 1024).read_to_end(&mut data).await?; + Ok::<_, std::io::Error>(data) + }; + let (_, data, _diagnostics) = tokio::try_join!(write, read, status)?; + if data.len() > MAX_CRYPTO_BYTES { + return Err(error("OpenPGP output exceeds the size limit")); + } + let exit = child.wait().await?; + let mut status_bytes = Vec::new(); + tokio::fs::File::open(status_file.path()) + .await? + .take((MAX_STATUS_BYTES + 1) as u64) + .read_to_end(&mut status_bytes) + .await?; + if status_bytes.len() > MAX_STATUS_BYTES { + return Err(error("GnuPG status exceeds the size limit")); + } + let status = String::from_utf8_lossy(&status_bytes).into_owned(); + // Expired/revoked signatures can accompany a successful GnuPG + // exit. Never downgrade a failed signature to unsigned plaintext. + if invalid_signature(&status) { + return Err(error( + "The digital signature is invalid, expired, or revoked", + )); + } + let partial_recipient_success = exit.code() == Some(2) + && args == ["--decrypt"] + && partial_recipient_decryption(&status); + if !exit.success() && !partial_recipient_success { + // Do not expose arbitrary GPG diagnostics (UIDs, filenames, user data). + return Err(error(if status.contains("NO_SECKEY") { + "The private key is unavailable" + } else if status.contains("NO_PUBKEY") { + "The sender's public key is unavailable" + } else if status.contains("BAD_PASSPHRASE") || status.contains("MISSING_PASSPHRASE") + { + "Unlock the private key using GnuPG's pinentry" + } else { + "GnuPG could not complete the operation. Check key validity and unlock the private key using pinentry" + })); + } + Ok((data, status)) + }; + tokio::time::timeout(Duration::from_secs(120), task) + .await + .map_err(|_| error("GnuPG timed out; no message was sent"))? + } + + /// GnuPG chooses its supported default key algorithms. Its pinentry asks + /// for the new passphrase; no secret enters our process or settings. + pub async fn generate_key(&self, email: &str) -> Result { + if email.parse::().is_err() || email.starts_with('-') { + return Err(error("Choose a valid account email address")); + } + let (_, status) = self + .run( + &[ + "--pinentry-mode".into(), + "ask".into(), + "--quick-generate-key".into(), + email.to_owned(), + "default".into(), + "default".into(), + "2y".into(), + ], + None, + ) + .await?; + let key = status + .lines() + .find_map(|s| s.strip_prefix("[GNUPG:] KEY_CREATED ")) + .and_then(|s| s.split_whitespace().nth(1)) + .ok_or_else(|| error("GnuPG did not confirm key creation"))?; + fingerprint(key, false) + } + + pub async fn list_keys(&self, secret: bool) -> Result { + let (data, _) = self + .run( + &[ + "--with-colons".into(), + "--fixed-list-mode".into(), + if secret { + "--list-secret-keys" + } else { + "--list-keys" + } + .into(), + ], + None, + ) + .await?; + Ok(String::from_utf8_lossy(&data).into_owned()) + } + + pub async fn import_key(&self, data: &[u8]) -> Result<()> { + if data.len() > 1024 * 1024 { + return Err(error("Key files must be smaller than 1 MiB")); + } + self.run(&["--import".into()], Some(data)).await?; + Ok(()) + } + + pub async fn export_public_key(&self, key: &str) -> Result> { + let key = fingerprint(key, false)?; + let (data, _) = self + .run(&["--armor".into(), "--export".into(), key], None) + .await?; + if data.is_empty() { + return Err(error("Public key not found")); + } + Ok(data) + } + + /// Validate exact primary fingerprints, non-expired/revoked keys, UID email + /// binding, and usable signing/encryption capability. GPG chooses subkeys. + async fn check_key( + &self, + key: &str, + email: &str, + secret: bool, + capability: char, + ) -> Result<()> { + let key = fingerprint(key, false)?; + let (data, _) = self + .run( + &[ + "--with-colons".into(), + "--fixed-list-mode".into(), + if secret { + "--list-secret-keys" + } else { + "--list-keys" + } + .into(), + key.clone(), + ], + None, + ) + .await?; + let listing = String::from_utf8_lossy(&data); + validate_listing(&listing, &key, email, capability) + } + + pub async fn preflight( + &self, + policy: &MailSecurity, + sender: &str, + recipients: &[Address], + ) -> Result<()> { + let mut checked = policy.clone(); + checked.validate()?; + if !checked.enabled() { + return Ok(()); + } + if checked.sign_by_default { + self.check_key(&checked.signing_fingerprint, sender, true, 'S') + .await?; + } + if checked.require_encryption { + self.check_key(&checked.signing_fingerprint, sender, true, 'E') + .await?; + for recipient in recipients { + if recipient.email.eq_ignore_ascii_case(sender) { + continue; + } + let key = checked + .recipient_keys + .get(&recipient.email.to_lowercase()) + .ok_or_else(|| { + error(&format!( + "Verify a public key for {} before sending. Encryption is required", + recipient.email + )) + })?; + self.check_key(key, &recipient.email, false, 'E').await?; + } + } + Ok(()) + } + + pub(crate) async fn protect( + &self, + raw: &[u8], + policy: &MailSecurity, + sender: &str, + recipients: &[Address], + ) -> Result> { + if !policy.enabled() { + return Ok(raw.to_vec()); + } + let mut normalized = policy.clone(); + normalized.validate()?; + let policy = &normalized; + self.preflight(policy, sender, recipients).await?; + let (headers, mut entity) = split_entity(raw)?; + if policy.sign_by_default { + let (signature, status) = self + .run( + &[ + "--armor".into(), + "--digest-algo".into(), + "SHA256".into(), + "--local-user".into(), + policy.signing_fingerprint.clone(), + "--detach-sign".into(), + ], + Some(&entity), + ) + .await?; + if !status.contains("[GNUPG:] SIG_CREATED ") { + return Err(error("GnuPG did not confirm the signature")); + } + entity = multipart("signed", "application/pgp-signature", Some("pgp-sha256"), &entity, + &format!("Content-Type: application/pgp-signature; name=\"signature.asc\"\r\nContent-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n{}", String::from_utf8_lossy(&signature)).into_bytes()); + } + if policy.require_encryption { + // Trust is explicit in our email/fingerprint bindings, never inferred + // from a matching UID. Hidden recipients prevent Bcc key-id leaks. + let mut args = vec![ + "--armor".into(), + "--trust-model".into(), + "always".into(), + "--throw-keyids".into(), + "--recipient".into(), + policy.signing_fingerprint.clone(), + ]; + for recipient in recipients { + if let Some(key) = policy.recipient_keys.get(&recipient.email.to_lowercase()) { + args.extend(["--recipient".into(), key.clone()]); + } + } + args.push("--encrypt".into()); + let (ciphertext, status) = self.run(&args, Some(&entity)).await?; + if !status.contains("[GNUPG:] END_ENCRYPTION") { + return Err(error("GnuPG did not confirm encryption")); + } + entity = multipart("encrypted", "application/pgp-encrypted", None, + b"Content-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n", + &format!("Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\nContent-Disposition: inline; filename=\"encrypted.asc\"\r\n\r\n{}", String::from_utf8_lossy(&ciphertext)).into_bytes()); + } + Ok([headers, entity].concat()) + } +} + +fn validate_listing(listing: &str, key: &str, email: &str, capability: char) -> Result<()> { + let mut primary = false; + let mut exact = false; + let mut valid = false; + let mut uid = false; + for line in listing.lines() { + let fields: Vec<_> = line.split(':').collect(); + if fields.len() < 10 { + continue; + } + match fields[0] { + "pub" | "sec" => { + primary = true; + valid = !matches!(fields[1], "r" | "e" | "d" | "i") + && fields + .get(11) + .is_some_and(|value| value.contains(capability) && !value.contains('D')) + && fields[6].parse::().map_or(true, |expiry| { + expiry == 0 || expiry > chrono::Utc::now().timestamp() + }); + } + "sub" | "ssb" => primary = false, + "fpr" if primary => { + exact = fields[9] == key; + primary = false; + } + "uid" if !matches!(fields[1], "r" | "e" | "i") => { + let value = decode_colon(fields[9]); + if value.contains(['\r', '\n', '\0']) { + continue; + } + let parsed = mail_parser::MessageParser::default() + .parse(&format!("From: {value}\r\n\r\n").into_bytes()) + .and_then(|m| { + m.from() + .and_then(|a| a.first()) + .and_then(|a| a.address()) + .map(str::to_owned) + }); + uid |= parsed.is_some_and(|address| address.eq_ignore_ascii_case(email)); + } + _ => {} + } + } + if exact && valid && uid { + Ok(()) + } else { + Err(error( + "Key must be valid, match the email address, and support the requested operation", + )) + } +} + +pub fn decode_colon(value: &str) -> String { + let bytes = value.as_bytes(); + let mut result = Vec::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i..].starts_with(b"\\x") + && i + 3 < bytes.len() + && let Ok(hex) = std::str::from_utf8(&bytes[i + 2..i + 4]) + && let Ok(byte) = u8::from_str_radix(hex, 16) + { + result.push(byte); + i += 4; + continue; + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&result).into_owned() +} + +/// Split transport headers from the complete canonical MIME entity. +fn split_entity(raw: &[u8]) -> Result<(Vec, Vec)> { + let at = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| error("Malformed MIME headers"))?; + let header = std::str::from_utf8(&raw[..at]).map_err(|_| error("Invalid MIME headers"))?; + let mut outer = String::new(); + let mut content = String::new(); + let mut is_content = false; + for line in header.split("\r\n") { + if !line.starts_with([' ', '\t']) { + is_content = line.to_ascii_lowercase().starts_with("content-"); + } + let target = if is_content { &mut content } else { &mut outer }; + target.push_str(line); + target.push_str("\r\n"); + } + content.push_str("\r\n"); + let entity = [content.as_bytes(), &raw[at + 4..]].concat(); + Ok((outer.into_bytes(), entity)) +} +fn multipart( + kind: &str, + protocol: &str, + micalg: Option<&str>, + first: &[u8], + second: &[u8], +) -> Vec { + let boundary = format!("flectar-{:032x}", rand::random::()); + let alg = micalg + .map(|s| format!("; micalg=\"{s}\"")) + .unwrap_or_default(); + let mut out = format!("Content-Type: multipart/{kind}; protocol=\"{protocol}\"{alg}; boundary=\"{boundary}\"\r\n\r\n--{boundary}\r\n").into_bytes(); + out.extend(first); + out.extend(format!("\r\n--{boundary}\r\n").as_bytes()); + out.extend(second); + out.extend(format!("\r\n--{boundary}--\r\n").as_bytes()); + out +} + +/// Every transport calls this immediately before uploading MIME. Queue-time +/// policy survives setting changes and application restarts without downgrade. +pub async fn protect_draft( + db: &Db, + account_id: i64, + draft_id: i64, + raw: Vec, + recipients: Vec
, +) -> Result> { + let (policy, sender) = db.read(move |conn| { + let config = repo::accounts::get_config(conn, account_id)?.ok_or_else(|| error("Account not found"))?; + use rusqlite::OptionalExtension; + let payload: Option = conn.query_row("SELECT payload FROM pending_actions WHERE message_id=?1 AND kind='send' AND state IN ('pending','inflight') ORDER BY id DESC LIMIT 1", [draft_id], |r| r.get(0)).optional()?; + let policy = if let Some(payload) = payload { + let payload: serde_json::Value = serde_json::from_str(&payload)?; + match payload.get("mailSecurity") { Some(value) => serde_json::from_value(value.clone())?, None => config.settings.security } + } else { config.settings.security }; + Ok((policy, config.email)) + }).await?; + Gpg::default() + .protect(&raw, &policy, &sender, &recipients) + .await +} + +impl Core { + pub async fn set_mail_security(&self, account_id: i64, mut policy: MailSecurity) -> Result<()> { + policy.validate()?; + let config = self + .db + .read(move |conn| repo::accounts::get_config(conn, account_id)) + .await? + .ok_or_else(|| error("Account not found"))?; + Gpg::default() + .preflight(&policy, &config.email, &[]) + .await?; + self.db + .write(move |conn| { + let mut config = repo::accounts::get_config(conn, account_id)? + .ok_or_else(|| error("Account not found"))?; + config.settings.security = policy; + repo::accounts::set_settings(conn, account_id, &config.settings)?; + Ok(()) + }) + .await + } +} + +pub fn without_bcc(raw: &[u8]) -> Result> { + let at = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| error("Malformed message"))?; + let mut result = Vec::new(); + let mut skip = false; + for line in raw[..at].split(|b| *b == b'\n') { + let line = line.strip_suffix(b"\r").unwrap_or(line); + if !line.starts_with(b" ") && !line.starts_with(b"\t") { + skip = line + .get(..4) + .is_some_and(|v| v.eq_ignore_ascii_case(b"bcc:")); + } + if !skip { + result.extend(line); + result.extend(b"\r\n"); + } + } + result.extend(b"\r\n"); + result.extend(&raw[at + 4..]); + Ok(result) +} + +pub struct OpenedMessage { + pub text: String, + pub status: String, + /// Authenticated inner MIME, retained only in memory until explicit export. + pub mime: Vec, +} +impl Gpg { + pub async fn open_message( + &self, + raw: &[u8], + expected_fingerprint: Option<&str>, + ) -> Result { + use mail_parser::MimeHeaders; + if raw.len() > MAX_CRYPTO_BYTES { + return Err(error("Message exceeds the 100 MiB OpenPGP limit")); + } + let mut entity = raw.to_vec(); + let mut encrypted = false; + let mut signature = None; + for _ in 0..8 { + if entity.starts_with(b"-----BEGIN PGP MESSAGE-----") { + let (data, status) = self.run(&["--decrypt".into()], Some(&entity)).await?; + if !authenticated_decryption(&status) { + return Err(error( + "Message integrity could not be verified; legacy unauthenticated encryption is not accepted", + )); + } + if let Some(fpr) = valid_signature(&status) { + signature = Some(fpr); + } + encrypted = true; + entity = data; + continue; + } + let parsed = mail_parser::MessageParser::default() + .parse(entity.as_slice()) + .ok_or_else(|| error("Malformed OpenPGP MIME message"))?; + let root = parsed + .parts + .first() + .ok_or_else(|| error("Missing MIME part"))?; + let kind = root + .content_type() + .and_then(|ct| ct.subtype()) + .unwrap_or_default(); + let protocol = root + .content_type() + .and_then(|ct| ct.attribute("protocol")) + .unwrap_or_default(); + if kind.eq_ignore_ascii_case("encrypted") + && protocol.eq_ignore_ascii_case("application/pgp-encrypted") + { + let parts = root + .sub_parts() + .filter(|p| p.len() == 2) + .ok_or_else(|| error("Encrypted MIME must have two parts"))?; + let control = &parsed.parts[parts[0] as usize]; + if !String::from_utf8_lossy(control.contents()) + .lines() + .any(|line| line.trim() == "Version: 1") + { + return Err(error("Unsupported encrypted MIME version")); + } + entity = parsed.parts[parts[1] as usize].contents().to_vec(); + continue; + } + if kind.eq_ignore_ascii_case("signed") + && protocol.eq_ignore_ascii_case("application/pgp-signature") + { + let parts = root + .sub_parts() + .filter(|p| p.len() == 2) + .ok_or_else(|| error("Signed MIME must have two parts"))?; + let signed = &parsed.parts[parts[0] as usize]; + let data = &entity[signed.offset_header as usize..signed.offset_end as usize]; + let sig = &parsed.parts[parts[1] as usize]; + let file = tempfile::NamedTempFile::new()?; + tokio::fs::write(file.path(), sig.contents()).await?; + let (_, status) = self + .run( + &[ + "--verify".into(), + file.path().to_string_lossy().into_owned(), + "-".into(), + ], + Some(data), + ) + .await?; + signature = valid_signature(&status); + if signature.is_none() { + return Err(error("The digital signature could not be verified")); + } + entity = data.to_vec(); + break; + } + break; + } + if !encrypted && signature.is_none() { + return Err(error( + "No supported OpenPGP/MIME content found. For signed mail, download the original .eml and open it from OpenPGP settings", + )); + } + let parsed = mail_parser::MessageParser::default() + .parse(entity.as_slice()) + .ok_or_else(|| error("Decrypted MIME could not be parsed"))?; + let text = parsed + .body_text(0) + .map(|s| s.into_owned()) + .unwrap_or_default(); + let attachment_count = parsed.attachments().count(); + let protection = if encrypted { + "Decrypted; integrity verified." + } else { + "Not encrypted." + }; + let signature_status = match signature { + Some(fpr) + if expected_fingerprint + .is_some_and(|expected| expected.eq_ignore_ascii_case(&fpr)) => + { + format!("Valid signature from the verified key {fpr}.") + } + Some(fpr) => format!( + "Valid signature from {fpr}. The sender's identity has not been verified against this key." + ), + None => "No verified digital signature; sender identity is unverified.".to_owned(), + }; + Ok(OpenedMessage { + text, + status: format!( + "{protection} {signature_status} {attachment_count} attachment(s); export the decrypted message to open them. Outer subject and addresses are not authenticated by this signature." + ), + mime: entity, + }) + } +} +fn invalid_signature(status: &str) -> bool { + status.lines().any(|s| { + ["BADSIG", "ERRSIG", "EXPKEYSIG", "REVKEYSIG", "EXPSIG"] + .iter() + .any(|bad| { + s.strip_prefix("[GNUPG:] ") + .and_then(|s| s.split_whitespace().next()) + == Some(*bad) + }) + }) +} +fn valid_signature(status: &str) -> Option { + if invalid_signature(status) { + return None; + } + status + .lines() + .find_map(|s| s.strip_prefix("[GNUPG:] VALIDSIG ")) + .and_then(|s| { + let fields: Vec<_> = s.split_whitespace().collect(); + fingerprint(fields.get(9).or_else(|| fields.first()).copied()?, false).ok() + }) +} +impl Core { + pub async fn open_openpgp_message(&self, message_id: i64) -> Result { + let (detail, path, expected) = self + .db + .read(move |conn| { + let detail = repo::messages::detail(conn, message_id)?; + let path = repo::messages::get_row(conn, message_id)?.and_then(|r| r.raw_path); + let config = repo::accounts::get_config(conn, detail.account_id)? + .ok_or_else(|| error("Account not found"))?; + let expected = config + .settings + .security + .recipient_keys + .get(&detail.from.email.to_lowercase()) + .cloned(); + Ok((detail, path, expected)) + }) + .await?; + let raw = if let Some(path) = path { + crate::file_io::read(path, MAX_CRYPTO_BYTES, "OpenPGP message").await? + } else if let Some(attachment) = detail.attachments.iter().find(|a| { + a.filename + .as_deref() + .is_some_and(|n| n.eq_ignore_ascii_case("encrypted.asc")) + }) { + let path = self.get_attachment(attachment.id).await?; + crate::file_io::read(path, MAX_CRYPTO_BYTES, "OpenPGP payload").await? + } else { + return Err(error( + "Original MIME is unavailable. Download the original .eml from your provider and use Open message file in OpenPGP settings", + )); + }; + Gpg::default().open_message(&raw, expected.as_deref()).await + } +} + +fn authenticated_decryption(status: &str) -> bool { + let has = |tag: &str| status.lines().any(|line| line == format!("[GNUPG:] {tag}")); + let aead = status + .lines() + .filter_map(|s| s.strip_prefix("[GNUPG:] DECRYPTION_INFO ")) + .any(|s| s.split_whitespace().nth(2).is_some_and(|v| v != "0")); + has("DECRYPTION_OKAY") + && has("END_DECRYPTION") + && (has("GOODMDC") || aead) + && !has("BADMDC") + && !has("DECRYPTION_FAILED") +} + +fn partial_recipient_decryption(status: &str) -> bool { + // Trying another anonymous recipient's ECDH key can make GnuPG exit 2 + // despite authenticated decryption. Ubuntu's GnuPG 2.4 also appends the + // generic GPG_ERR_GENERAL exit status (source GPG = 2, code = 1). + // Accept only this fallback status, never a specific operation failure, + // and require a single complete, authenticated decryption with no bad + // signature or malformed input. + let last = status.lines().last(); + authenticated_decryption(status) + && !invalid_signature(status) + && status + .lines() + .any(|line| line == "[GNUPG:] NO_SECKEY 0000000000000000") + && ["[GNUPG:] BEGIN_DECRYPTION", "[GNUPG:] END_DECRYPTION"] + .iter() + .all(|tag| status.lines().filter(|line| line == tag).count() == 1) + && status.lines().all(|line| { + match line + .strip_prefix("[GNUPG:] ") + .and_then(|event| event.split_ascii_whitespace().next()) + { + Some("FAILURE") => { + line == "[GNUPG:] FAILURE gpg-exit 33554433" && last == Some(line) + } + Some("ERROR" | "BADARMOR" | "NODATA" | "UNEXPECTED") => false, + _ => true, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use mail_parser::MimeHeaders; + + const ANONYMOUS_DECRYPTION: &str = "\ +[GNUPG:] NO_SECKEY 0000000000000000 +[GNUPG:] BEGIN_DECRYPTION +[GNUPG:] DECRYPTION_INFO 2 9 0 +[GNUPG:] DECRYPTION_OKAY +[GNUPG:] GOODMDC +[GNUPG:] END_DECRYPTION +"; + + #[test] + fn accepts_authenticated_anonymous_recipient_despite_generic_exit_failure() { + // GnuPG 2.2 and upstream 2.4 can exit 2 without a FAILURE status. + assert!(partial_recipient_decryption(ANONYMOUS_DECRYPTION)); + // Ubuntu 24.04 backports the generic exit status to GnuPG 2.4.4. + let ubuntu = format!("{ANONYMOUS_DECRYPTION}[GNUPG:] FAILURE gpg-exit 33554433\n"); + assert!(partial_recipient_decryption(&ubuntu)); + assert!(partial_recipient_decryption( + &ubuntu.replace("DECRYPTION_INFO 2 9 0", "DECRYPTION_INFO 0 9 2") + )); + } + + #[test] + fn anonymous_recipient_fallback_rejects_incomplete_or_failed_decryption() { + for tag in [ + "NO_SECKEY 0000000000000000", + "BEGIN_DECRYPTION", + "DECRYPTION_OKAY", + "GOODMDC", + "END_DECRYPTION", + ] { + let incomplete = ANONYMOUS_DECRYPTION.replace(&format!("[GNUPG:] {tag}\n"), ""); + assert!( + !partial_recipient_decryption(&format!( + "{incomplete}[GNUPG:] FAILURE gpg-exit 33554433\n" + )), + "{tag}" + ); + } + for failure in [ + "ERROR pkdecrypt_failed 33554449", + "FAILURE decrypt 33554433", + "FAILURE gpg-exit 33554449", + "FAILURE gpg-exit", + "BADMDC", + "DECRYPTION_FAILED", + "BADARMOR", + "NODATA 3", + "UNEXPECTED 0", + "BADSIG key uid", + "ERRSIG key 22 8 00 0 9", + "EXPKEYSIG key uid", + "REVKEYSIG key uid", + "EXPSIG key uid", + ] { + let status = format!( + "{ANONYMOUS_DECRYPTION}[GNUPG:] {failure}\n[GNUPG:] FAILURE gpg-exit 33554433\n" + ); + assert!(!partial_recipient_decryption(&status), "{failure}"); + } + assert!(!partial_recipient_decryption( + &ANONYMOUS_DECRYPTION.repeat(2) + )); + assert!(!partial_recipient_decryption(&format!( + "[GNUPG:] FAILURE gpg-exit 33554433\n{ANONYMOUS_DECRYPTION}" + ))); + } + + #[test] + fn validates_fingerprints_and_bcc_folding() { + assert!(!authenticated_decryption( + "[GNUPG:] DECRYPTION_OKAY\n[GNUPG:] END_DECRYPTION\n" + )); + assert!(!authenticated_decryption( + "[GNUPG:] DECRYPTION_OKAY\n[GNUPG:] GOODMDC\n[GNUPG:] END_DECRYPTION\n[GNUPG:] BADMDC\n" + )); + assert!(fingerprint("DEADBEEF", false).is_err()); + assert!(fingerprint(&"a".repeat(40), false).is_ok()); + assert!(fingerprint(&format!("--{}", "a".repeat(38)), false).is_err()); + let raw = b"From: a@example.org\r\nBcc: hidden@example.org,\r\n another@example.org\r\nSubject: x\r\nContent-Type: text/plain\r\n\r\nBcc: body text stays\r\n"; + let stripped = String::from_utf8(without_bcc(raw).unwrap()).unwrap(); + assert!(!stripped.contains("hidden@example.org")); + assert!(!stripped.contains("another@example.org")); + assert!(stripped.contains("Bcc: body text stays")); + let (header, entity) = split_entity(raw).unwrap(); + assert!(!String::from_utf8_lossy(&header).contains("Content-Type")); + assert!(entity.starts_with(b"Content-Type: text/plain\r\n\r\n")); + } + #[test] + fn rejects_revoked_expired_wrong_identity_and_untrusted_bindings() { + let fpr = "A".repeat(40); + let listing = format!( + "pub:u:255:22:KEY:0:0::u:::scESC:\nfpr:::::::::{fpr}:\nuid:u::::::::Alice :\n" + ); + assert!(validate_listing(&listing, &fpr, "alice@example.org", 'E').is_ok()); + assert!(validate_listing(&listing, &fpr, "mallory@example.org", 'E').is_err()); + assert!( + validate_listing( + &listing.replace("pub:u", "pub:r"), + &fpr, + "alice@example.org", + 'E' + ) + .is_err() + ); + assert!( + validate_listing( + &listing.replace(":KEY:0:0:", ":KEY:0:1:"), + &fpr, + "alice@example.org", + 'E' + ) + .is_err() + ); + assert!( + valid_signature(&format!( + "[GNUPG:] REVKEYSIG key uid\n[GNUPG:] VALIDSIG {fpr} 0 0 0 0 0 0 0 0 {fpr}\n" + )) + .is_none() + ); + } + async fn generate(gpg: &Gpg, uid: &str) -> String { + let args: Vec = [ + "--pinentry-mode", + "loopback", + "--passphrase", + "", + "--quick-generate-key", + uid, + "ed25519", + "sign", + "1d", + ] + .into_iter() + .map(str::to_owned) + .collect(); + let (_, status) = gpg.run(&args, None).await.unwrap(); + let fpr = status + .lines() + .find_map(|l| l.strip_prefix("[GNUPG:] KEY_CREATED ")) + .and_then(|s| s.split_whitespace().nth(1)) + .unwrap() + .to_owned(); + let args: Vec = [ + "--pinentry-mode", + "loopback", + "--passphrase", + "", + "--quick-add-key", + &fpr, + "cv25519", + "encrypt", + "1d", + ] + .into_iter() + .map(str::to_owned) + .collect(); + gpg.run(&args, None).await.unwrap(); + fpr + } + #[cfg(unix)] + #[tokio::test] + async fn generation_uses_pinentry_and_creates_signing_and_encryption_keys() { + use std::os::unix::fs::PermissionsExt; + let home = tempfile::tempdir().unwrap(); + std::fs::set_permissions(home.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + // Test-only pinentry implements the Assuan dialog protocol. The app + // still uses its normal production command, with no passphrase args. + let pinentry = home.path().join("test-pinentry"); + std::fs::write(&pinentry, "#!/bin/sh\nprintf 'OK ready\\n'\nwhile IFS= read -r line; do\n case \"$line\" in\n GETPIN*) printf 'D test-only-passphrase\\nOK\\n' ;;\n BYE*) printf 'OK\\n'; exit 0 ;;\n *) printf 'OK\\n' ;;\n esac\ndone\n").unwrap(); + std::fs::set_permissions(&pinentry, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::write( + home.path().join("gpg-agent.conf"), + format!("pinentry-program {}\n", pinentry.display()), + ) + .unwrap(); + let gpg = Gpg { + home: Some(home.path().to_owned()), + }; + let key = gpg.generate_key("generated@example.org").await.unwrap(); + gpg.check_key(&key, "generated@example.org", true, 'S') + .await + .unwrap(); + gpg.check_key(&key, "generated@example.org", true, 'E') + .await + .unwrap(); + assert!( + gpg.export_public_key(&key) + .await + .unwrap() + .starts_with(b"-----BEGIN PGP PUBLIC KEY BLOCK-----") + ); + } + + /// Requires GnuPG, and never touches the developer's normal keyring. + #[tokio::test] + async fn openpgp_mime_roundtrip_and_tampering() { + let home = tempfile::tempdir().unwrap(); + let gpg = Gpg { + home: Some(home.path().to_owned()), + }; + let alice = generate(&gpg, "Alice ").await; + let bob = generate(&gpg, "Bob ").await; + let recipients = vec![Address { + name: None, + email: "bob@example.org".into(), + }]; + let raw = mail_builder::MessageBuilder::new() + .from("alice@example.org") + .to("bob@example.org") + .subject("Visible subject") + .text_body("Private message: café") + .attachment("text/plain", "secret.txt", b"private attachment".to_vec()) + .write_to_vec() + .unwrap(); + let mut policy = MailSecurity { + signing_fingerprint: alice.clone(), + sign_by_default: true, + require_encryption: true, + recipient_keys: BTreeMap::from([("bob@example.org".into(), bob.clone())]), + }; + let protected = gpg + .protect(&raw, &policy, "alice@example.org", &recipients) + .await + .unwrap(); + let wire = String::from_utf8_lossy(&protected); + assert!(wire.contains("multipart/encrypted")); + assert!(wire.contains("Visible subject")); + assert!(!wire.contains("Private message")); + assert!(!wire.contains("private attachment")); + let mut corrupted = protected.clone(); + let armor = b"-----BEGIN PGP MESSAGE-----"; + let start = corrupted + .windows(armor.len()) + .position(|w| w == armor) + .unwrap() + + armor.len(); + let pos = (start..corrupted.len()) + .find(|&i| corrupted[i].is_ascii_alphanumeric()) + .unwrap(); + corrupted[pos] = if corrupted[pos] == b'A' { b'B' } else { b'A' }; + assert!(gpg.open_message(&corrupted, Some(&alice)).await.is_err()); + let opened = gpg.open_message(&protected, Some(&alice)).await.unwrap(); + assert!(opened.text.contains("café")); + assert!(opened.status.contains("verified key")); + let parsed = mail_parser::MessageParser::default() + .parse(opened.mime.as_slice()) + .unwrap(); + let attachment = parsed.attachments().next().unwrap(); + assert_eq!(attachment.attachment_name(), Some("secret.txt")); + assert_eq!(attachment.contents(), b"private attachment"); + // An independently imported recipient secret key can decrypt too. + let (secret, _) = gpg + .run( + &["--armor".into(), "--export-secret-keys".into(), bob], + None, + ) + .await + .unwrap(); + let other_home = tempfile::tempdir().unwrap(); + let receiver = Gpg { + home: Some(other_home.path().to_owned()), + }; + assert!(receiver.open_message(&protected, None).await.is_err()); + receiver.import_key(&secret).await.unwrap(); + receiver + .import_key(&gpg.export_public_key(&alice).await.unwrap()) + .await + .unwrap(); + assert!( + receiver + .open_message(&protected, None) + .await + .unwrap() + .status + .contains("has not been verified") + ); + policy.require_encryption = false; + let signed = gpg + .protect(&raw, &policy, "alice@example.org", &recipients) + .await + .unwrap(); + assert!(gpg.open_message(&signed, Some(&alice)).await.is_ok()); + let tampered = String::from_utf8(signed) + .unwrap() + .replace("Private message", "Altered message"); + assert!( + gpg.open_message(tampered.as_bytes(), Some(&alice)) + .await + .is_err() + ); + policy.require_encryption = true; + policy.recipient_keys.clear(); + assert!( + gpg.protect(&raw, &policy, "alice@example.org", &recipients) + .await + .is_err() + ); + policy + .recipient_keys + .insert("bob@example.org".into(), alice); + assert!( + gpg.protect(&raw, &policy, "alice@example.org", &recipients) + .await + .is_err() + ); + assert!(gpg.open_message(&raw, None).await.is_err()); + } + + #[tokio::test] + async fn expired_signer_never_releases_successfully_decrypted_plaintext() { + let home = tempfile::tempdir().unwrap(); + let gpg = Gpg { + home: Some(home.path().to_owned()), + }; + let key = generate(&gpg, "Alice ").await; + let (encrypted, _) = gpg + .run( + &[ + "--armor".into(), + "--trust-model".into(), + "always".into(), + "--local-user".into(), + key.clone(), + "--recipient".into(), + key, + "--sign".into(), + "--encrypt".into(), + ], + Some(b"Content-Type: text/plain\r\n\r\nPrivate message"), + ) + .await + .unwrap(); + // The test key expires after one day. GnuPG still decrypts at this + // later time and reports EXPKEYSIG rather than a decryption failure. + let future = (chrono::Utc::now().timestamp() + 2 * 24 * 60 * 60).to_string(); + let error = gpg + .run( + &["--faked-system-time".into(), future, "--decrypt".into()], + Some(&encrypted), + ) + .await + .expect_err("an expired signature must fail closed"); + assert!( + error + .to_string() + .contains("signature is invalid, expired, or revoked"), + "{error}" + ); + } +} diff --git a/crates/flectar-mail-core/src/models.rs b/crates/flectar-mail-core/src/models.rs index 6a55c3a..b0e932d 100644 --- a/crates/flectar-mail-core/src/models.rs +++ b/crates/flectar-mail-core/src/models.rs @@ -191,6 +191,44 @@ impl MailHistory { } } +/// Explicit wire security. Auto infers the mode from conventional server ports. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionSecurity { + #[default] + Auto, + Tls, + Starttls, +} + +impl ConnectionSecurity { + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Tls => "tls", + Self::Starttls => "starttls", + } + } + pub fn parse(value: &str) -> Option { + match value { + "auto" => Some(Self::Auto), + "tls" => Some(Self::Tls), + "starttls" => Some(Self::Starttls), + _ => None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct MailConnectionSettings { + pub imap_security: ConnectionSecurity, + pub smtp_security: ConnectionSecurity, + /// Additional trust anchors for this account only; normal hostname and + /// validity checks still apply. Contains public certificates, never keys. + pub trusted_certificate_pem: String, +} + /// Typed contents of `accounts.settings_json`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -200,12 +238,18 @@ pub struct AccountSettings { // value never triggers an unbounded mailbox download. #[serde(default = "default_mail_history")] pub mail_history: MailHistory, + #[serde(default)] + pub security: crate::mail_security::MailSecurity, + #[serde(default)] + pub connection: MailConnectionSettings, } impl Default for AccountSettings { fn default() -> Self { Self { mail_history: default_mail_history(), + security: Default::default(), + connection: MailConnectionSettings::default(), } } } @@ -538,6 +582,8 @@ pub struct AddPasswordAccountArgs { pub imap_port: u16, pub smtp_host: String, pub smtp_port: u16, + #[serde(default)] + pub connection: MailConnectionSettings, } #[derive(Debug, Clone, Serialize)] diff --git a/crates/flectar-mail-core/src/oauth/flow.rs b/crates/flectar-mail-core/src/oauth/flow.rs index ecb5e8c..4511142 100644 --- a/crates/flectar-mail-core/src/oauth/flow.rs +++ b/crates/flectar-mail-core/src/oauth/flow.rs @@ -5,7 +5,9 @@ use crate::error::{CoreError, Result}; use crate::models::Provider; use crate::oauth::providers::for_provider; -use crate::oauth::redirect::{LoopbackRedirectBroker, OAuthRedirectBrokerHandle}; +use crate::oauth::redirect::{ + LoopbackRedirectBroker, OAUTH_REDIRECT_TIMEOUT, OAuthRedirectBrokerHandle, +}; use crate::oauth::tokens::post_form; use base64::Engine; use sha2::Digest; @@ -79,7 +81,7 @@ pub async fn authorize_with( provider, extra_scopes, login_hint, - std::sync::Arc::new(LoopbackRedirectBroker), + std::sync::Arc::new(LoopbackRedirectBroker::default()), open_url, ) .await @@ -168,7 +170,7 @@ pub async fn authorize_with_broker( .map_err(|error| CoreError::Auth(format!("could not open sign-in browser: {error}")))?; let code = redirect_session - .wait(std::time::Duration::from_secs(300)) + .wait(OAUTH_REDIRECT_TIMEOUT) .await .inspect_err(|e| tracing::warn!(?provider, error = %e, "oauth: no usable callback"))?; tracing::info!(?provider, "oauth: authorization code received"); diff --git a/crates/flectar-mail-core/src/oauth/loopback.rs b/crates/flectar-mail-core/src/oauth/loopback.rs index d55edb8..515612e 100644 --- a/crates/flectar-mail-core/src/oauth/loopback.rs +++ b/crates/flectar-mail-core/src/oauth/loopback.rs @@ -65,7 +65,7 @@ impl LoopbackServer { } /// Wait (with timeout) for the browser redirect carrying ?code=. - pub async fn wait_for_code(self, timeout: std::time::Duration) -> Result { + pub async fn wait_for_code(&self, timeout: std::time::Duration) -> Result { let fut = async { loop { let (mut stream, _) = match &self.v6 { diff --git a/crates/flectar-mail-core/src/oauth/redirect.rs b/crates/flectar-mail-core/src/oauth/redirect.rs index 1d43086..73c54df 100644 --- a/crates/flectar-mail-core/src/oauth/redirect.rs +++ b/crates/flectar-mail-core/src/oauth/redirect.rs @@ -5,10 +5,13 @@ use crate::{ }; use async_trait::async_trait; use std::{ - sync::Arc, + sync::{Arc, Mutex}, time::{Duration, Instant}, }; +/// Give people enough time to finish account selection, consent, and MFA. +pub const OAUTH_REDIRECT_TIMEOUT: Duration = Duration::from_secs(15 * 60); + /// Access token returned by a platform authorization service such as Google /// Play Services. Native mobile authorization does not expose a reusable /// refresh token to the application; the platform must be asked for a fresh @@ -43,6 +46,16 @@ pub trait OAuthRedirectBroker: Send + Sync { ) -> Option> { None } + + /// Forward a browser callback that could not reach the local listener. + /// + /// This is useful when the browser and application run in different + /// network namespaces, as can happen with containers and remote desktops. + fn submit_redirect(&self, _uri: &str) -> Result<()> { + Err(CoreError::Auth( + "pasted browser callbacks are not supported on this platform".into(), + )) + } } pub type OAuthRedirectBrokerHandle = Arc; @@ -107,11 +120,23 @@ pub fn parse_redirect_uri(uri: &str) -> Result { } #[derive(Default)] -pub struct LoopbackRedirectBroker; +pub struct LoopbackRedirectBroker { + pending: Arc>>, +} + +struct PendingRedirect { + redirect_uri: String, + expected_state: String, + guard: OAuthRedirectGuard, + sender: Option>, +} struct LoopbackRedirectSession { redirect_uri: String, server: LoopbackServer, + manual_redirect: tokio::sync::oneshot::Receiver, + expected_state: String, + pending: Arc>>, } #[async_trait] @@ -119,7 +144,7 @@ impl OAuthRedirectBroker for LoopbackRedirectBroker { async fn begin( &self, provider: Provider, - _expected_state: &str, + expected_state: &str, ) -> Result> { let server = LoopbackServer::bind().await?; let redirect_uri = match provider { @@ -127,11 +152,53 @@ impl OAuthRedirectBroker for LoopbackRedirectBroker { Provider::Microsoft => server.localhost_redirect_uri(), Provider::Imap => return Err(CoreError::Auth("provider does not use oauth".into())), }; + let (sender, manual_redirect) = tokio::sync::oneshot::channel(); + let mut pending = self + .pending + .lock() + .map_err(|_| CoreError::Auth("OAuth callback state is unavailable".into()))?; + if pending.is_some() { + return Err(CoreError::Auth( + "another browser authorization is already in progress".into(), + )); + } + *pending = Some(PendingRedirect { + redirect_uri: redirect_uri.clone(), + expected_state: expected_state.to_owned(), + guard: OAuthRedirectGuard::new(expected_state, OAUTH_REDIRECT_TIMEOUT), + sender: Some(sender), + }); Ok(Box::new(LoopbackRedirectSession { redirect_uri, server, + manual_redirect, + expected_state: expected_state.to_owned(), + pending: self.pending.clone(), })) } + + fn submit_redirect(&self, uri: &str) -> Result<()> { + let mut pending = self + .pending + .lock() + .map_err(|_| CoreError::Auth("OAuth callback state is unavailable".into()))?; + let pending = pending + .as_mut() + .ok_or_else(|| CoreError::Auth("no browser authorization is in progress".into()))?; + if !same_redirect_endpoint(uri, &pending.redirect_uri) { + return Err(CoreError::Auth( + "paste the complete localhost address from the final browser page".into(), + )); + } + let code = pending.guard.accept(uri)?; + let sender = pending + .sender + .take() + .ok_or_else(|| CoreError::Auth("OAuth callback is expired or already used".into()))?; + sender + .send(code) + .map_err(|_| CoreError::Auth("browser authorization is no longer active".into())) + } } #[async_trait] @@ -140,11 +207,45 @@ impl OAuthRedirectSession for LoopbackRedirectSession { &self.redirect_uri } - async fn wait(self: Box, timeout: Duration) -> Result { - self.server.wait_for_code(timeout).await + async fn wait(mut self: Box, timeout: Duration) -> Result { + tokio::select! { + result = self.server.wait_for_code(timeout) => result, + result = tokio::time::timeout(timeout, &mut self.manual_redirect) => { + result + .map_err(|_| CoreError::Auth("sign-in timed out".into()))? + .map_err(|_| CoreError::Auth("browser authorization is no longer active".into())) + } + } } } +impl Drop for LoopbackRedirectSession { + fn drop(&mut self) { + let Ok(mut pending) = self.pending.lock() else { + return; + }; + if pending + .as_ref() + .is_some_and(|pending| pending.expected_state == self.expected_state) + { + *pending = None; + } + } +} + +fn same_redirect_endpoint(candidate: &str, expected: &str) -> bool { + let Ok(candidate) = url::Url::parse(candidate.trim()) else { + return false; + }; + let Ok(expected) = url::Url::parse(expected) else { + return false; + }; + candidate.scheme() == expected.scheme() + && candidate.host_str() == expected.host_str() + && candidate.port_or_known_default() == expected.port_or_known_default() + && candidate.path() == expected.path() +} + #[cfg(test)] mod tests { use super::*; @@ -198,4 +299,44 @@ mod tests { .contains("expired") ); } + + #[tokio::test] + async fn loopback_broker_accepts_a_callback_pasted_from_the_error_page() { + let broker = LoopbackRedirectBroker::default(); + let session = broker.begin(Provider::Gmail, "right").await.unwrap(); + let callback = format!( + "{}?state=right&iss=https%3A%2F%2Faccounts.google.com&code=a%2Fb", + session.redirect_uri() + ); + + broker.submit_redirect(&callback).unwrap(); + assert_eq!( + session.wait(Duration::from_secs(1)).await.unwrap(), + AuthCode { + code: "a/b".into(), + state: Some("right".into()), + } + ); + } + + #[tokio::test] + async fn pasted_callback_must_match_the_active_listener_and_state() { + let broker = LoopbackRedirectBroker::default(); + let session = broker.begin(Provider::Gmail, "right").await.unwrap(); + assert!( + broker + .submit_redirect("http://127.0.0.1:1/?state=right&code=nope") + .unwrap_err() + .to_string() + .contains("complete localhost address") + ); + let wrong_state = format!("{}?state=wrong&code=nope", session.redirect_uri()); + assert!( + broker + .submit_redirect(&wrong_state) + .unwrap_err() + .to_string() + .contains("state mismatch") + ); + } } diff --git a/crates/flectar-mail-core/src/oauth/tokens.rs b/crates/flectar-mail-core/src/oauth/tokens.rs index 362c552..4de72f1 100644 --- a/crates/flectar-mail-core/src/oauth/tokens.rs +++ b/crates/flectar-mail-core/src/oauth/tokens.rs @@ -36,7 +36,18 @@ pub struct TokenProvider { const PLATFORM_GOOGLE_PREFIX: &str = "flectar-platform-google-v1:"; const MAX_OAUTH_TOKEN_BYTES: usize = 256 * 1024; +const MAX_OAUTH_REGISTRATION_BYTES: usize = 64 * 1024; const MAX_TOKEN_LIFETIME_SECONDS: i64 = 366 * 24 * 60 * 60; +const OAUTH_CREDENTIAL_VERSION: u8 = 1; + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct OAuthCredentialBundle { + version: u8, + refresh_token: String, + client_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + client_secret: Option, +} pub(super) fn validate_token_fields( access_token: &str, @@ -70,6 +81,29 @@ fn token_expiry_ms(expires_in: Option) -> i64 { crate::models::now_ms().saturating_add(usable_seconds.saturating_mul(1000)) } +fn validate_oauth_credentials(credentials: &OAuthCredentialBundle) -> Result<()> { + let valid = |value: &str, max_bytes: usize| { + !value.trim().is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control) + }; + if credentials.version != OAUTH_CREDENTIAL_VERSION + || !valid(&credentials.refresh_token, MAX_OAUTH_TOKEN_BYTES) + || !valid(&credentials.client_id, MAX_OAUTH_REGISTRATION_BYTES) + || credentials + .client_secret + .as_deref() + .is_some_and(|secret| !valid(secret, MAX_OAUTH_REGISTRATION_BYTES)) + { + return Err(CoreError::Auth( + "stored OAuth credentials are invalid; reconnect the account".into(), + )); + } + Ok(()) +} + +fn is_missing_credential(error: &CoreError) -> bool { + matches!(error, CoreError::Auth(message) if message == "no stored credential") +} + pub fn platform_google_marker(scopes: &[&str]) -> std::result::Result { if scopes.is_empty() || scopes.iter().any(|scope| scope.contains('\n')) { return Err("invalid platform OAuth scopes"); @@ -147,24 +181,40 @@ impl TokenProvider { validate_token_fields(&access_token, expires_in, refresh_token.as_deref())?; let refresh_lock = self.refresh_lock(account_id).await; let _refresh_guard = refresh_lock.lock().await; - credentials::store_async( - self.credentials.clone(), - account_id, - Slot::OAuthClientId, - client_id, - ) - .await?; - credentials::store_async( - self.credentials.clone(), - account_id, - Slot::OAuthClientSecret, - client_secret.unwrap_or_default(), - ) - .await?; - if let Some(rt) = refresh_token { - credentials::store_async(self.credentials.clone(), account_id, Slot::RefreshToken, rt) - .await?; - } + let client_secret = client_secret.filter(|secret| !secret.trim().is_empty()); + let bundle = match refresh_token { + Some(refresh_token) => OAuthCredentialBundle { + version: OAUTH_CREDENTIAL_VERSION, + refresh_token, + client_id, + client_secret, + }, + None => { + let existing = self + .oauth_credentials(account_id, None) + .await + .map_err(|error| { + if is_missing_credential(&error) { + CoreError::Auth( + "OAuth provider did not return a refresh token; reconnect and grant offline access" + .into(), + ) + } else { + error + } + })?; + if existing.client_id != client_id || existing.client_secret != client_secret { + return Err(CoreError::Auth( + "OAuth provider did not return a refresh token for the active app registration; reconnect and grant offline access" + .into(), + )); + } + existing + } + }; + validate_oauth_credentials(&bundle)?; + self.store_oauth_credentials(account_id, &bundle).await?; + self.remove_legacy_oauth_credentials(account_id).await; let expires_at_ms = token_expiry_ms(expires_in); self.cache.lock().await.insert( account_id, @@ -228,19 +278,16 @@ impl TokenProvider { let _refresh_guard = refresh_lock.lock().await; let cfg = for_provider(provider).ok_or_else(|| CoreError::Auth("not an oauth account".into()))?; - let (client_id, client_secret) = self.registration(account_id, provider).await?; - let refresh_token = - credentials::load_async(self.credentials.clone(), account_id, Slot::RefreshToken) - .await?; + let mut oauth = self.oauth_credentials(account_id, Some(provider)).await?; let mut form = vec![ ("grant_type".to_string(), "refresh_token".to_string()), - ("refresh_token".to_string(), refresh_token), - ("client_id".to_string(), client_id), + ("refresh_token".to_string(), oauth.refresh_token.clone()), + ("client_id".to_string(), oauth.client_id.clone()), // offline_access keeps a (rotated) refresh token coming back. ("scope".to_string(), format!("{scope} offline_access")), ]; - if let Some(cs) = client_secret { + if let Some(cs) = oauth.client_secret.clone() { form.push(("client_secret".to_string(), cs)); } @@ -267,8 +314,8 @@ impl TokenProvider { // Do NOT overwrite Slot::AccessToken (that is the mail token, a // different audience). Only persist the rotated refresh token. if let Some(rt) = tok.refresh_token { - credentials::store_async(self.credentials.clone(), account_id, Slot::RefreshToken, rt) - .await?; + oauth.refresh_token = rt; + self.store_oauth_credentials(account_id, &oauth).await?; } Ok(tok.access_token) } @@ -276,13 +323,10 @@ impl TokenProvider { async fn refresh(&self, account_id: i64, provider: Provider) -> Result { let cfg = for_provider(provider).ok_or_else(|| CoreError::Auth("not an oauth account".into()))?; - let (client_id, client_secret) = self.registration(account_id, provider).await?; - let refresh_token = - credentials::load_async(self.credentials.clone(), account_id, Slot::RefreshToken) - .await?; + let mut oauth = self.oauth_credentials(account_id, Some(provider)).await?; if provider == Provider::Gmail - && let Some(scopes) = platform_google_scopes(&refresh_token) + && let Some(scopes) = platform_google_scopes(&oauth.refresh_token) { let scope_refs = scopes.iter().map(String::as_str).collect::>(); let result = self @@ -301,8 +345,8 @@ impl TokenProvider { let mut form = vec![ ("grant_type".to_string(), "refresh_token".to_string()), - ("refresh_token".to_string(), refresh_token), - ("client_id".to_string(), client_id), + ("refresh_token".to_string(), oauth.refresh_token.clone()), + ("client_id".to_string(), oauth.client_id.clone()), ]; // The Microsoft refresh token may cover both Outlook and Graph // resources. Ask explicitly for the mail scopes so the resulting @@ -310,7 +354,7 @@ impl TokenProvider { if provider == Provider::Microsoft { form.push(("scope".to_string(), cfg.scopes.join(" "))); } - if let Some(cs) = client_secret { + if let Some(cs) = oauth.client_secret.clone() { form.push(("client_secret".to_string(), cs)); } @@ -334,8 +378,8 @@ impl TokenProvider { )?; if let Some(rt) = tok.refresh_token { - credentials::store_async(self.credentials.clone(), account_id, Slot::RefreshToken, rt) - .await?; + oauth.refresh_token = rt; + self.store_oauth_credentials(account_id, &oauth).await?; } Ok(CachedToken { access_token: tok.access_token, @@ -343,28 +387,110 @@ impl TokenProvider { }) } - async fn registration( + async fn oauth_credentials( &self, account_id: i64, - provider: Provider, - ) -> Result<(String, Option)> { - match credentials::load_async(self.credentials.clone(), account_id, Slot::OAuthClientId) - .await + provider: Option, + ) -> Result { + match credentials::load_async(self.credentials.clone(), account_id, Slot::OAuthBundle).await + { + Ok(encoded) => { + let bundle: OAuthCredentialBundle = + serde_json::from_str(&encoded).map_err(|_| { + CoreError::Auth( + "stored OAuth credentials are invalid; reconnect the account".into(), + ) + })?; + validate_oauth_credentials(&bundle)?; + return Ok(bundle); + } + Err(error) if is_missing_credential(&error) => {} + Err(error) => return Err(error), + } + + // Migrate accounts created before OAuth credentials were committed as + // one value. Missing legacy registration entries use the app's current + // provider registration, matching the previous behavior. + let refresh_token = + credentials::load_async(self.credentials.clone(), account_id, Slot::RefreshToken) + .await?; + let (client_id, fallback_client_secret) = match credentials::load_async( + self.credentials.clone(), + account_id, + Slot::OAuthClientId, + ) + .await + { + Ok(client_id) if !client_id.trim().is_empty() => (client_id, None), + Ok(_) => { + let provider = provider.ok_or_else(|| { + CoreError::Auth("OAuth account registration is missing".into()) + })?; + crate::oauth::providers::resolve_credentials(provider)? + } + Err(error) if is_missing_credential(&error) => { + let provider = provider.ok_or_else(|| { + CoreError::Auth("OAuth account registration is missing".into()) + })?; + crate::oauth::providers::resolve_credentials(provider)? + } + Err(error) => return Err(error), + }; + let client_secret = match credentials::load_async( + self.credentials.clone(), + account_id, + Slot::OAuthClientSecret, + ) + .await { - Ok(client_id) if !client_id.trim().is_empty() => { - let client_secret = credentials::load_async( - self.credentials.clone(), + Ok(secret) if !secret.trim().is_empty() => Some(secret), + Ok(_) => fallback_client_secret, + Err(error) if is_missing_credential(&error) => fallback_client_secret, + Err(error) => return Err(error), + }; + let bundle = OAuthCredentialBundle { + version: OAUTH_CREDENTIAL_VERSION, + refresh_token, + client_id, + client_secret, + }; + validate_oauth_credentials(&bundle)?; + self.store_oauth_credentials(account_id, &bundle).await?; + self.remove_legacy_oauth_credentials(account_id).await; + Ok(bundle) + } + + async fn store_oauth_credentials( + &self, + account_id: i64, + bundle: &OAuthCredentialBundle, + ) -> Result<()> { + let encoded = serde_json::to_string(bundle)?; + credentials::store_async( + self.credentials.clone(), + account_id, + Slot::OAuthBundle, + encoded, + ) + .await + } + + async fn remove_legacy_oauth_credentials(&self, account_id: i64) { + for slot in [ + Slot::RefreshToken, + Slot::OAuthClientId, + Slot::OAuthClientSecret, + ] { + if let Err(error) = + credentials::delete_async(self.credentials.clone(), account_id, slot).await + { + tracing::warn!( account_id, - Slot::OAuthClientSecret, - ) - .await - .ok() - .filter(|secret| !secret.trim().is_empty()); - Ok((client_id, client_secret)) + slot = slot.as_str(), + %error, + "could not remove migrated OAuth credential" + ); } - // Accounts created before per-account OAuth registrations were - // introduced continue to use the configured/bundled registration. - _ => crate::oauth::providers::resolve_credentials(provider), } } @@ -459,6 +585,187 @@ fn token_error_is_transient(body: &str) -> bool { mod tests { use super::*; + #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[tokio::test] + async fn initial_oauth_tokens_persist_in_development_container_store() { + let directory = tempfile::tempdir().unwrap(); + let credentials: CredentialStoreHandle = + Arc::new(credentials::DevelopmentFileCredentialStore::new( + directory.path().join("credentials.json"), + )); + let provider = TokenProvider::new( + credentials.clone(), + Arc::new(crate::oauth::redirect::LoopbackRedirectBroker::default()), + ); + + provider + .store_initial( + 17, + "access-token".into(), + Some(3600), + Some("refresh-token".into()), + "oauth-client".into(), + None, + ) + .await + .unwrap(); + + let encoded = credentials::load_async(credentials.clone(), 17, Slot::OAuthBundle) + .await + .unwrap(); + let bundle: OAuthCredentialBundle = serde_json::from_str(&encoded).unwrap(); + assert_eq!(bundle.version, OAUTH_CREDENTIAL_VERSION); + assert_eq!(bundle.refresh_token, "refresh-token"); + assert_eq!(bundle.client_id, "oauth-client"); + assert!(bundle.client_secret.is_none()); + assert!( + credentials::load_async(credentials.clone(), 17, Slot::RefreshToken) + .await + .is_err() + ); + assert_eq!( + provider.access_token(17, Provider::Gmail).await.unwrap(), + "access-token" + ); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[tokio::test] + async fn legacy_oauth_entries_migrate_to_one_atomic_bundle() { + let directory = tempfile::tempdir().unwrap(); + let credentials: CredentialStoreHandle = + Arc::new(credentials::DevelopmentFileCredentialStore::new( + directory.path().join("credentials.json"), + )); + credentials::store_async( + credentials.clone(), + 23, + Slot::RefreshToken, + "legacy-refresh".into(), + ) + .await + .unwrap(); + credentials::store_async( + credentials.clone(), + 23, + Slot::OAuthClientId, + "legacy-client".into(), + ) + .await + .unwrap(); + credentials::store_async( + credentials.clone(), + 23, + Slot::OAuthClientSecret, + "legacy-secret".into(), + ) + .await + .unwrap(); + let provider = TokenProvider::new( + credentials.clone(), + Arc::new(crate::oauth::redirect::LoopbackRedirectBroker::default()), + ); + + let bundle = provider + .oauth_credentials(23, Some(Provider::Gmail)) + .await + .unwrap(); + assert_eq!(bundle.refresh_token, "legacy-refresh"); + assert_eq!(bundle.client_id, "legacy-client"); + assert_eq!(bundle.client_secret.as_deref(), Some("legacy-secret")); + assert!( + credentials::load_async(credentials.clone(), 23, Slot::OAuthBundle) + .await + .is_ok() + ); + for slot in [ + Slot::RefreshToken, + Slot::OAuthClientId, + Slot::OAuthClientSecret, + ] { + assert!( + credentials::load_async(credentials.clone(), 23, slot) + .await + .is_err() + ); + } + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[tokio::test] + async fn missing_refresh_token_never_creates_or_mixes_registrations() { + let directory = tempfile::tempdir().unwrap(); + let credentials: CredentialStoreHandle = + Arc::new(credentials::DevelopmentFileCredentialStore::new( + directory.path().join("credentials.json"), + )); + let provider = TokenProvider::new( + credentials.clone(), + Arc::new(crate::oauth::redirect::LoopbackRedirectBroker::default()), + ); + + assert!( + provider + .store_initial( + 31, + "access-one".into(), + Some(3600), + None, + "client-one".into(), + None, + ) + .await + .is_err() + ); + assert!( + credentials::load_async(credentials.clone(), 31, Slot::OAuthBundle) + .await + .is_err() + ); + + provider + .store_initial( + 31, + "access-two".into(), + Some(3600), + Some("refresh-one".into()), + "client-one".into(), + None, + ) + .await + .unwrap(); + provider + .store_initial( + 31, + "access-three".into(), + Some(3600), + None, + "client-one".into(), + None, + ) + .await + .unwrap(); + assert!( + provider + .store_initial( + 31, + "access-four".into(), + Some(3600), + None, + "different-client".into(), + None, + ) + .await + .is_err() + ); + let bundle = provider + .oauth_credentials(31, Some(Provider::Gmail)) + .await + .unwrap(); + assert_eq!(bundle.refresh_token, "refresh-one"); + assert_eq!(bundle.client_id, "client-one"); + } + #[test] fn provider_tokens_and_lifetimes_are_hard_bounded() { assert!(validate_token_fields("access", Some(3600), Some("refresh")).is_ok()); diff --git a/crates/flectar-mail-core/src/queue/mod.rs b/crates/flectar-mail-core/src/queue/mod.rs index 12c8df4..457687a 100644 --- a/crates/flectar-mail-core/src/queue/mod.rs +++ b/crates/flectar-mail-core/src/queue/mod.rs @@ -429,6 +429,19 @@ async fn send_action( attachments, }; let (msg_id, raw) = crate::mime::build_message(&out)?; + let raw = crate::mail_security::protect_draft( + &ctx.db, + config.id, + draft_id, + raw, + out.to + .iter() + .chain(out.cc) + .chain(out.bcc) + .cloned() + .collect(), + ) + .await?; // Persist before SMTP. If the relay accepts the message but the response // is lost, action recovery rebuilds the exact same Message-ID instead of // producing an avoidable duplicate with a fresh identity. diff --git a/crates/flectar-mail-core/src/signatures.rs b/crates/flectar-mail-core/src/signatures.rs new file mode 100644 index 0000000..c751ef4 --- /dev/null +++ b/crates/flectar-mail-core/src/signatures.rs @@ -0,0 +1,170 @@ +//! Account-scoped signature validation and plain-text composition. +use crate::{ + Core, + db::repo, + error::{CoreError, Result}, + models::{Signature, SignatureDefaults}, +}; + +pub const MAX_SIGNATURE_BYTES: usize = 32 * 1024; + +pub fn plain_text(signature: &Signature) -> String { + mail_parser::decoders::html::html_to_text(&signature.html) + .trim() + .to_owned() +} + +pub fn text_html(text: &str) -> String { + format!( + "
{}
", + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('\n', "
") + ) +} + +/// Insert above the quotation, preserving the standard `-- ` delimiter. +pub fn insert(body: &str, signature: &str) -> String { + if signature.trim().is_empty() { + return body.to_owned(); + } + let at = body + .find("\n\nOn ") + .or_else(|| body.find("\n\n---------- Forwarded message")) + .unwrap_or(body.len()); + format!( + "{}\n\n-- \n{}{}", + &body[..at], + signature.trim(), + &body[at..] + ) +} + +impl Core { + pub async fn save_signature(&self, mut signature: Signature) -> Result { + signature.name = signature.name.trim().to_owned(); + if signature.name.is_empty() + || signature.name.len() > 100 + || signature.html.len() > MAX_SIGNATURE_BYTES + { + return Err(CoreError::Other( + "Use a signature name of 1–100 bytes and a body under 32 KiB".into(), + )); + } + // No active HTML, images or remote requests in signature settings. + signature.html = ammonia::Builder::default() + .rm_tags(&["img"]) + .clean(&signature.html) + .to_string(); + if signature.id.is_empty() { + signature.id = format!("sig-{:032x}", rand::random::()); + } + self.db + .write(move |conn| { + if repo::accounts::get(conn, signature.account_id)?.is_none() { + return Err(CoreError::NotFound("account".into())); + } + let mut settings = repo::settings::get(conn)?; + if let Some(existing) = settings + .signature_list + .iter_mut() + .find(|s| s.id == signature.id) + { + if existing.account_id != signature.account_id { + return Err(CoreError::Other( + "Signature belongs to another account".into(), + )); + } + *existing = signature.clone(); + } else { + if settings + .signature_list + .iter() + .filter(|s| s.account_id == signature.account_id) + .count() + >= 20 + { + return Err(CoreError::Other( + "An account can have up to 20 signatures".into(), + )); + } + settings.signature_list.push(signature.clone()); + } + repo::settings::set(conn, &settings)?; + Ok(signature) + }) + .await + } + + pub async fn delete_signature(&self, account_id: i64, id: String) -> Result<()> { + self.db + .write(move |conn| { + let mut settings = repo::settings::get(conn)?; + settings + .signature_list + .retain(|s| !(s.account_id == account_id && s.id == id)); + if let Some(defaults) = settings.signature_defaults.get_mut(&account_id.to_string()) + { + if defaults.new_id.as_ref() == Some(&id) { + defaults.new_id = None; + } + if defaults.reply_id.as_ref() == Some(&id) { + defaults.reply_id = None; + } + } + repo::settings::set(conn, &settings) + }) + .await + } + + pub async fn set_signature_defaults( + &self, + account_id: i64, + defaults: SignatureDefaults, + ) -> Result<()> { + self.db + .write(move |conn| { + if repo::accounts::get(conn, account_id)?.is_none() { + return Err(CoreError::NotFound("account".into())); + } + let mut settings = repo::settings::get(conn)?; + for id in [&defaults.new_id, &defaults.reply_id].into_iter().flatten() { + if !settings + .signature_list + .iter() + .any(|s| s.account_id == account_id && &s.id == id) + { + return Err(CoreError::Other( + "Choose a signature belonging to this account".into(), + )); + } + } + settings + .signature_defaults + .insert(account_id.to_string(), defaults); + repo::settings::set(conn, &settings) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn inserts_before_history_and_escapes_text() { + assert_eq!( + insert("Reply\n\nOn Monday, Pat wrote:\n> Hello", "Me"), + "Reply\n\n-- \nMe\n\nOn Monday, Pat wrote:\n> Hello" + ); + let s = Signature { + id: "s".into(), + account_id: 1, + name: "Work".into(), + html: text_html("Zoë \nEngineer"), + }; + assert_eq!(plain_text(&s), "Zoë \nEngineer"); + assert_eq!(insert("Body", ""), "Body"); + } +} diff --git a/crates/flectar-mail-core/src/smtp/mod.rs b/crates/flectar-mail-core/src/smtp/mod.rs index e46e67e..59a3353 100644 --- a/crates/flectar-mail-core/src/smtp/mod.rs +++ b/crates/flectar-mail-core/src/smtp/mod.rs @@ -16,30 +16,33 @@ fn build_transport( ) -> Result> { use lettre::transport::smtp::client::{Tls, TlsParameters}; - let mut builder = if cfg.smtp_port == 465 { - // Implicit TLS - AsyncSmtpTransport::::relay(&cfg.smtp_host) - .map_err(|e| CoreError::Smtp(e.to_string()))? - .port(cfg.smtp_port) - } else { - // STARTTLS (587 and friends) - AsyncSmtpTransport::::starttls_relay(&cfg.smtp_host) - .map_err(|e| CoreError::Smtp(e.to_string()))? - .port(cfg.smtp_port) + use crate::models::ConnectionSecurity; + let implicit = match cfg.settings.connection.smtp_security { + ConnectionSecurity::Tls => true, + ConnectionSecurity::Starttls => false, + ConnectionSecurity::Auto => cfg.smtp_port == 465, }; - - if crate::imap::tls_insecure() { - let params = TlsParameters::builder(cfg.smtp_host.clone()) + let mut params = TlsParameters::builder(cfg.smtp_host.clone()); + for cert in crate::imap::trusted_certificates(&cfg.settings.connection.trusted_certificate_pem)? + { + params = params.add_root_certificate( + lettre::transport::smtp::client::Certificate::from_der(cert.as_ref().to_vec()) + .map_err(|e| CoreError::Tls(e.to_string()))?, + ); + } + if crate::imap::tls_insecure() && cfg.settings.connection.trusted_certificate_pem.is_empty() { + params = params .dangerous_accept_invalid_certs(true) - .dangerous_accept_invalid_hostnames(true) - .build() - .map_err(|e| CoreError::Smtp(e.to_string()))?; - builder = if cfg.smtp_port == 465 { - builder.tls(Tls::Wrapper(params)) - } else { - builder.tls(Tls::Required(params)) - }; + .dangerous_accept_invalid_hostnames(true); } + let params = params.build().map_err(|e| CoreError::Tls(e.to_string()))?; + let builder = AsyncSmtpTransport::::builder_dangerous(&cfg.smtp_host) + .port(cfg.smtp_port) + .tls(if implicit { + Tls::Wrapper(params) + } else { + Tls::Required(params) + }); let builder = match auth { SmtpAuth::Password(pw) => builder @@ -77,8 +80,8 @@ pub async fn send_raw( .map_err(|e| CoreError::Smtp(format!("bad recipient {r}: {e}")))?, ); } - let envelope = - Envelope::new(Some(from_addr), tos).map_err(|e| CoreError::Smtp(e.to_string()))?; + let envelope = Envelope::new(Some(from_addr), tos) + .map_err(|e| CoreError::Smtp(format!("Invalid message envelope: {e}")))?; let transport = build_transport(cfg, auth)?; tracing::debug!( @@ -92,14 +95,17 @@ pub async fn send_raw( recipients = recipients.len(), "smtp send_raw: connecting to relay", ); - transport.send_raw(&envelope, raw).await.map_err(|e| { - let msg = e.to_string(); - if msg.contains("535") || msg.to_lowercase().contains("auth") { - CoreError::Auth(format!("smtp auth: {msg}")) - } else { - CoreError::Smtp(msg) - } - })?; + transport + .send_raw(&envelope, &crate::mail_security::without_bcc(raw)?) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("535") || msg.to_lowercase().contains("auth") { + CoreError::Auth(format!("smtp auth: {msg}")) + } else { + CoreError::Smtp(msg) + } + })?; Ok(()) } @@ -109,7 +115,13 @@ pub async fn test_connection(cfg: &AccountConfig, auth: &SmtpAuth) -> Result<()> let ok = transport .test_connection() .await - .map_err(|e| CoreError::Smtp(e.to_string()))?; + .map_err(|e| { + if e.is_tls() { + CoreError::Tls(format!("SMTP {}:{}: {e}. Check SSL/TLS versus STARTTLS and the server certificate. For Proton Bridge, import its exported certificate.", cfg.smtp_host, cfg.smtp_port)) + } else { + CoreError::Smtp(format!("{}:{}: {e}", cfg.smtp_host, cfg.smtp_port)) + } + })?; if ok { Ok(()) } else { diff --git a/crates/flectar-mail-core/src/sync/engine.rs b/crates/flectar-mail-core/src/sync/engine.rs index f6ab545..9488563 100644 --- a/crates/flectar-mail-core/src/sync/engine.rs +++ b/crates/flectar-mail-core/src/sync/engine.rs @@ -1091,7 +1091,14 @@ pub(super) async fn emit_sync_status(ctx: &SyncCtx, account_id: i64) { async fn connect(ctx: &SyncCtx, config: &AccountConfig) -> Result { let creds = imap_credentials(ctx, config).await?; - match imap::connect(&config.imap_host, config.imap_port, creds).await { + match imap::connect_with_settings( + &config.imap_host, + config.imap_port, + creds, + &config.settings.connection, + ) + .await + { Err(error) if config.auth_kind == AuthKind::Oauth2 && matches!(&error, CoreError::NeedsReauth | CoreError::Auth(_)) => @@ -1107,7 +1114,13 @@ async fn connect(ctx: &SyncCtx, config: &AccountConfig) -> Result { ); ctx.tokens.invalidate(config.id).await; let creds = imap_credentials(ctx, config).await?; - imap::connect(&config.imap_host, config.imap_port, creds).await + imap::connect_with_settings( + &config.imap_host, + config.imap_port, + creds, + &config.settings.connection, + ) + .await } result => result, } diff --git a/crates/flectar-mail-core/src/sync/gmail.rs b/crates/flectar-mail-core/src/sync/gmail.rs index 7f40b9d..5f16ec0 100644 --- a/crates/flectar-mail-core/src/sync/gmail.rs +++ b/crates/flectar-mail-core/src/sync/gmail.rs @@ -2605,6 +2605,20 @@ async fn prepare_draft( attachments, }; let (message_id, raw) = crate::mime::build_message(&outgoing)?; + let raw = crate::mail_security::protect_draft( + &ctx.db, + config.id, + draft_id, + raw, + outgoing + .to + .iter() + .chain(outgoing.cc) + .chain(outgoing.bcc) + .cloned() + .collect(), + ) + .await?; let bare_message_id = message_id.trim_matches(['<', '>']).to_owned(); if stored_message_id.as_deref() != Some(bare_message_id.as_str()) { let stable_id = bare_message_id.clone(); diff --git a/crates/flectar-mail-core/tests/account_mail_preferences.rs b/crates/flectar-mail-core/tests/account_mail_preferences.rs new file mode 100644 index 0000000..eba11ce --- /dev/null +++ b/crates/flectar-mail-core/tests/account_mail_preferences.rs @@ -0,0 +1,140 @@ +use flectar_mail_core::{ + Core, + config::Paths, + db::repo, + models::{Signature, SignatureDefaults}, +}; + +#[tokio::test] +async fn signature_ownership_defaults_sanitization_and_persistence() { + let temp = tempfile::tempdir().unwrap(); + let paths = Paths::for_tests(temp.path()); + let core = Core::start(paths.clone()).await.unwrap(); + core.db.write(|conn| { + conn.execute_batch("INSERT INTO accounts(id,email,provider,auth_kind,username,imap_host,imap_port,smtp_host,smtp_port,created_at) VALUES (1,'alice@example.org','imap','password','alice','imap.example.org',993,'smtp.example.org',465,0),(2,'bob@example.org','imap','password','bob','imap.example.org',993,'smtp.example.org',465,0)")?; + Ok(()) + }).await.unwrap(); + let saved = core + .save_signature(Signature { + id: String::new(), + account_id: 1, + name: " Work ".into(), + html: "Alice" + .into(), + }) + .await + .unwrap(); + assert_eq!(saved.name, "Work"); + assert!(!saved.html.contains("script")); + assert!(!saved.html.contains("img")); + assert!(saved.html.contains("Alice")); + let defaults = SignatureDefaults { + new_id: Some(saved.id.clone()), + reply_id: Some(saved.id.clone()), + }; + core.set_signature_defaults(1, defaults.clone()) + .await + .unwrap(); + assert!(core.set_signature_defaults(2, defaults).await.is_err()); + assert!( + core.save_signature(Signature { + account_id: 2, + ..saved.clone() + }) + .await + .is_err() + ); + core.delete_signature(2, saved.id.clone()).await.unwrap(); + assert_eq!(core.get_settings().await.unwrap().signature_list.len(), 1); + drop(core); + let core = Core::start(paths).await.unwrap(); + let settings = core.get_settings().await.unwrap(); + assert_eq!( + settings.signature_defaults["1"].new_id, + Some(saved.id.clone()) + ); + assert_eq!(settings.signature_list[0].html, saved.html); + core.delete_signature(1, saved.id).await.unwrap(); + let settings = core.get_settings().await.unwrap(); + assert!(settings.signature_list.is_empty()); + assert!(settings.signature_defaults["1"].new_id.is_none()); + assert!(settings.signature_defaults["1"].reply_id.is_none()); + let old = core + .db + .read(|conn| repo::accounts::get_config(conn, 1)) + .await + .unwrap() + .unwrap(); + assert!(!old.settings.security.enabled()); +} + +#[tokio::test] +async fn protected_drafts_stay_local_and_queued_policy_cannot_downgrade() { + use flectar_mail_core::{ + mail_security::{MailSecurity, protect_draft}, + models::{Address, SaveDraftArgs}, + }; + let temp = tempfile::tempdir().unwrap(); + let core = Core::start(Paths::for_tests(temp.path())).await.unwrap(); + let policy = MailSecurity { + require_encryption: true, + signing_fingerprint: "INVALID-IMPORTED-FINGERPRINT".into(), + ..Default::default() + }; + let stored = policy.clone(); + core.db.write(move |conn| { + conn.execute_batch("INSERT INTO accounts(id,email,provider,auth_kind,username,imap_host,imap_port,smtp_host,smtp_port,created_at) VALUES(1,'alice@example.org','gmail','oauth2','alice','imap.example.org',993,'smtp.example.org',465,0)")?; + let mut config = repo::accounts::get_config(conn, 1)?.unwrap(); config.settings.security = stored; + repo::accounts::set_settings(conn, 1, &config.settings) + }).await.unwrap(); + let draft = core + .save_draft(SaveDraftArgs { + draft_id: None, + account_id: 1, + to: vec![Address { + name: None, + email: "bob@example.org".into(), + }], + cc: vec![], + bcc: vec![], + subject: "Private".into(), + body_text: "Local only".into(), + body_html: None, + mode: "new".into(), + in_reply_to_message_id: None, + attachments: vec![], + }) + .await + .unwrap(); + let pending: i64 = core.db.read(move |conn| Ok(conn.query_row("SELECT COUNT(*) FROM pending_actions WHERE message_id=?1 AND kind='save_draft' AND state='pending'", [draft], |r| r.get(0))?)).await.unwrap(); + assert_eq!(pending, 0); + // Pin protected intent then turn the account default off. Dispatch must + // still reject the invalid imported fingerprint before invoking GnuPG, + // instead of returning plaintext or touching the user's keyring. + core.db + .write(move |conn| { + repo::actions::enqueue( + conn, + 1, + "send", + Some(draft), + None, + &serde_json::json!({"draftId":draft,"mailSecurity":policy}), + None, + )?; + let mut config = repo::accounts::get_config(conn, 1)?.unwrap(); + config.settings.security = Default::default(); + repo::accounts::set_settings(conn, 1, &config.settings) + }) + .await + .unwrap(); + let result = protect_draft( + &core.db, + 1, + draft, + b"From: alice@example.org\r\nContent-Type: text/plain\r\n\r\nPrivate".to_vec(), + vec![], + ) + .await; + assert!(result.is_err()); +} diff --git a/crates/flectar-mail-core/tests/dovecot_e2e.rs b/crates/flectar-mail-core/tests/dovecot_e2e.rs index 1148072..e2c4dda 100644 --- a/crates/flectar-mail-core/tests/dovecot_e2e.rs +++ b/crates/flectar-mail-core/tests/dovecot_e2e.rs @@ -200,6 +200,7 @@ async fn full_sync_triage_and_search() { // 1. Add the account (verifies IMAP login on the way in). let account = core .add_account_password(AddPasswordAccountArgs { + connection: Default::default(), email: "testuser@example.com".into(), display_name: Some("Test User".into()), username: "testuser".into(), diff --git a/crates/flectar-mail-core/tests/files_stalwart.rs b/crates/flectar-mail-core/tests/files_stalwart.rs new file mode 100644 index 0000000..9bdc44c --- /dev/null +++ b/crates/flectar-mail-core/tests/files_stalwart.rs @@ -0,0 +1,600 @@ +//! Opt-in interoperability against a disposable Stalwart account. Only creates +//! and removes resources under a unique test folder. Never point at production. +use flectar_mail_core::files::{dav::DavClient, jmap::FileClient}; +use serde_json::json; + +#[tokio::test] +#[ignore = "requires a disposable local Stalwart server; see .devcontainer/docs/pending-changes/files.md"] +async fn stalwart_file_round_trip() { + let base = std::env::var("FLECTAR_FILES_TEST_URL").unwrap(); + assert!( + base.starts_with("http://127.0.0.1:"), + "Only a disposable loopback server is allowed" + ); + let user = std::env::var("FLECTAR_FILES_TEST_USER").unwrap(); + let secret = std::env::var("FLECTAR_FILES_TEST_PASSWORD").unwrap(); + let mut jmap = FileClient::connect(&base, &user, &secret, None) + .await + .unwrap(); + let root = jmap.list(None, "", 0).await.unwrap(); + let parent = root + .nodes + .iter() + .find(|n| n.role.as_deref() == Some("home")) + .map(|n| n.id.clone()); + let folder = format!("flectar-test-{}", chrono::Utc::now().timestamp_millis()); + let created = jmap + .create_folder(parent.as_deref(), &folder) + .await + .unwrap(); + let folder_id = created["created"]["new"]["id"].as_str().unwrap().to_owned(); + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("résumé & budget.txt"); + std::fs::write(&source, b"Hello from Flectar").unwrap(); + jmap.upload(Some(&folder_id), "résumé & budget.txt", &source, None) + .await + .unwrap(); + let listing = jmap.list(Some(&folder_id), "", 0).await.unwrap(); + assert_eq!(listing.nodes.len(), 1); + let node = listing.nodes[0].clone(); + assert_eq!(node.media_type.as_deref(), Some("text/plain")); + assert_eq!(jmap.download(&node).await.unwrap(), b"Hello from Flectar"); + let downloaded = temp.path().join("jmap-download.txt"); + jmap.download_to(&node, &downloaded).await.unwrap(); + assert_eq!(std::fs::read(&downloaded).unwrap(), b"Hello from Flectar"); + assert!(jmap.download_to(&node, &downloaded).await.is_err()); + jmap.update(&node.id, json!({"name":"renamed.txt"})) + .await + .unwrap(); + jmap.copy(&node.id, Some(&folder_id), "copy.txt") + .await + .unwrap(); + let listing = jmap.list(Some(&folder_id), "", 0).await.unwrap(); + assert_eq!(listing.nodes.len(), 2); + let dav_root = std::env::var("FLECTAR_FILES_TEST_DAV").unwrap(); + let mut dav = DavClient::connect(&dav_root, &user, &secret).await.unwrap(); + let listing = dav.list(&dav.root).await.unwrap(); + let dir = listing + .nodes + .iter() + .find(|n| n.name == folder) + .expect("JMAP-created folder must be visible over DAV") + .clone(); + let listing = dav.list(&dir.id).await.unwrap(); + assert_eq!(listing.nodes.len(), 2); + let file = listing + .nodes + .iter() + .find(|n| n.name == "renamed.txt") + .unwrap() + .clone(); + assert_eq!(dav.download(&file).await.unwrap(), b"Hello from Flectar"); + dav.lock(&file).await.unwrap(); + dav.patch_property( + &file, + "urn:flectar:files", + "description", + Some("Test & value"), + ) + .await + .unwrap(); + dav.unlock(&file).await.unwrap(); + assert!( + dav.relocate(&file, &dir.id, "stale.txt", false) + .await + .is_err(), + "metadata edit changes the ETag" + ); + let refreshed = dav.list(&dir.id).await.unwrap(); + let file = refreshed + .nodes + .iter() + .find(|n| n.name == "renamed.txt") + .unwrap(); + dav.relocate(file, &dir.id, "via-dav.txt", false) + .await + .unwrap(); + dav.create_folder(&dir.id, "nested").await.unwrap(); + let nested = dav.child_url(&dir.id, "nested", true).unwrap(); + dav.upload(&nested, "uploaded.txt", &source, None) + .await + .unwrap(); + let listing = dav.list(&nested).await.unwrap(); + assert_eq!(listing.nodes.len(), 1); + dav.delete(&listing.nodes[0]).await.unwrap(); + let listing = jmap.list(Some(&folder_id), "", 0).await.unwrap(); + assert!(listing.nodes.iter().any(|n| n.name == "via-dav.txt")); + // Discover the recipient ID: account IDs depend on provisioning order. + let bob_own_account = FileClient::connect(&base, "bob@files.test", &secret, None) + .await + .unwrap() + .account_id; + let recipient_path = format!( + "shareWith/{}", + bob_own_account.replace('~', "~0").replace('/', "~1") + ); + // Share with the second disposable account and verify the returned rights. + jmap.update(&folder_id,json!({recipient_path.clone():{"mayRead":true,"mayAddChildren":false,"mayRename":false,"mayDelete":false,"mayModifyContent":false,"mayShare":false}})).await.unwrap(); + let mut bob = FileClient::connect(&base, "bob@files.test", &secret, None) + .await + .unwrap(); + assert!( + bob.accounts.iter().any(|a| a.id == jmap.account_id), + "shared account discovery" + ); + bob.select_account(&jmap.account_id).unwrap(); + let shared = bob.list(Some(&folder_id), "", 0).await.unwrap(); + assert!(!shared.nodes.is_empty()); + assert!( + shared + .nodes + .iter() + .all(|n| n.my_rights.may_read && !n.my_rights.modify()) + ); + assert!( + bob.create_folder(Some(&folder_id), "forbidden") + .await + .is_err() + ); + let notifications = bob.share_notifications().await.unwrap(); + let ids = notifications["list"] + .as_array() + .unwrap() + .iter() + .filter(|n| n["objectId"] == folder_id) + .filter_map(|n| n["id"].as_str().map(str::to_owned)) + .collect::>(); + if !ids.is_empty() { + bob.dismiss_share_notifications(&ids, notifications["state"].as_str().unwrap()) + .await + .unwrap(); + } + let bob_dav = DavClient::connect(&dav_root, "bob@files.test", &secret) + .await + .unwrap(); + let shared = bob_dav.list(&dir.id).await.unwrap(); + assert!(!shared.current.my_rights.add()); + // Stalwart 0.16.21 DAV does not expose inherited child grants. Apply a + // direct grant to exercise DAV sharing independently of that limitation. + jmap.list(Some(&folder_id), "", 0).await.unwrap(); + jmap.update(&node.id, json!({recipient_path:{"mayRead":true}})) + .await + .unwrap(); + let shared = bob_dav.list(&dir.id).await.unwrap(); + let file = shared + .nodes + .iter() + .find(|n| n.name == "via-dav.txt") + .unwrap(); + assert_eq!(bob_dav.download(file).await.unwrap(), b"Hello from Flectar"); + let shared_state = bob.list(Some(&folder_id), "", 0).await.unwrap().state; + bob.select_account(&bob_own_account).unwrap(); + bob.list(None, "", 0).await.unwrap(); + let copied = bob + .copy_from_account( + &jmap.account_id, + &shared_state, + &node.id, + None, + &format!("{folder}.txt"), + ) + .await + .unwrap(); + let copied_id = copied["created"][&node.id]["id"].as_str().unwrap(); + bob.delete(copied_id, false).await.unwrap(); + let copied_tree = bob + .copy_from_account( + &jmap.account_id, + &shared_state, + &folder_id, + None, + &format!("{folder}-tree"), + ) + .await + .unwrap(); + assert_eq!(copied_tree["created"].as_object().unwrap().len(), 4); + let copied_folder_id = copied_tree["created"][&folder_id]["id"].as_str().unwrap(); + let children = bob.list(Some(copied_folder_id), "", 0).await.unwrap(); + assert_eq!(children.nodes.len(), 3); + let copied_file = children + .nodes + .iter() + .find(|n| n.name == "via-dav.txt") + .unwrap(); + assert_eq!( + bob.download(copied_file).await.unwrap(), + b"Hello from Flectar" + ); + bob.delete(copied_folder_id, true).await.unwrap(); + let refreshed_file = dav + .list(&dir.id) + .await + .unwrap() + .nodes + .into_iter() + .find(|n| n.name == "via-dav.txt") + .unwrap(); + dav.share(&refreshed_file, "/dav/pal/bob@files.test/", false, true) + .await + .unwrap(); + let refreshed = dav.list(&dav.root).await.unwrap(); + let shared_folder = refreshed.nodes.iter().find(|n| n.name == folder).unwrap(); + dav.share(shared_folder, "/dav/pal/bob@files.test/", false, true) + .await + .unwrap(); + assert!( + bob_dav.list(&dir.id).await.is_err(), + "revoked access is enforced" + ); + jmap.list(None, "", 0).await.unwrap(); + let changes = jmap.changes(&root.state).await.unwrap(); + assert!(changes["created"].as_array().unwrap().len() >= 3); + // A locked destination may be replaced only with both validators and + // the owned lock in the same DAV condition list. + dav.upload(&dir.id, "replace-target.txt", &source, None) + .await + .unwrap(); + let files = dav.list(&dir.id).await.unwrap().nodes; + let source_node = files.iter().find(|n| n.name == "via-dav.txt").unwrap(); + let target = files + .iter() + .find(|n| n.name == "replace-target.txt") + .unwrap(); + dav.lock(target).await.unwrap(); + dav.relocate_with_policy( + source_node, + &dir.id, + "replace-target.txt", + true, + flectar_mail_core::files::CollisionPolicy::Replace, + ) + .await + .unwrap(); + let files = dav.list(&dir.id).await.unwrap().nodes; + let target = files + .iter() + .find(|n| n.name == "replace-target.txt") + .unwrap(); + dav.unlock(target).await.unwrap(); + jmap.list(None, "", 0).await.unwrap(); + let dates=jmap.set(json!({"create":{ + "older":{"name":"older-source.txt","parentId":folder_id,"blobId":node.blob_id,"modified":"2000-01-01T00:00:00Z"}, + "newer":{"name":"newer-target.txt","parentId":folder_id,"blobId":node.blob_id,"modified":"2020-01-01T00:00:00Z"} + }})).await.unwrap(); + let older = dates["created"]["older"]["id"].as_str().unwrap(); + let newer = dates["created"]["newer"]["id"].as_str().unwrap(); + jmap.collision = flectar_mail_core::files::CollisionPolicy::Newest; + assert!( + jmap.copy(older, Some(&folder_id), "newer-target.txt") + .await + .is_err(), + "Keep newest must preserve the newer destination" + ); + assert_eq!( + jmap.get(&[newer.into()]).await.unwrap()["list"][0]["modified"], + "2020-01-01T00:00:00Z" + ); + jmap.collision = flectar_mail_core::files::CollisionPolicy::Reject; + jmap.list(None, "", 0).await.unwrap(); + let result = jmap.delete(&folder_id, false).await; + assert!(result.is_err(), "nonrecursive delete must reject children"); + jmap.list(None, "", 0).await.unwrap(); + jmap.delete(&folder_id, true).await.unwrap(); + assert!(dav.list(&dir.id).await.is_err()); +} + +struct TestSecrets(String); +impl flectar_mail_core::accounts::credentials::CredentialStore for TestSecrets { + fn store( + &self, + _: i64, + _: flectar_mail_core::accounts::credentials::Slot, + _: &str, + ) -> flectar_mail_core::error::Result<()> { + Ok(()) + } + fn load( + &self, + _: i64, + _: flectar_mail_core::accounts::credentials::Slot, + ) -> flectar_mail_core::error::Result { + Ok(self.0.clone()) + } + fn delete( + &self, + _: i64, + _: flectar_mail_core::accounts::credentials::Slot, + ) -> flectar_mail_core::error::Result<()> { + Ok(()) + } + fn delete_all(&self, _: i64) -> flectar_mail_core::error::Result<()> { + Ok(()) + } +} +#[tokio::test] +#[ignore = "requires a disposable local Stalwart server; see .devcontainer/docs/pending-changes/files.md"] +async fn stalwart_durable_sync_and_offline_queue() { + use flectar_mail_core::{ + Core, + config::Paths, + files::{ + self, ConnectionSettings, + service::{Entry, FilesService}, + }, + }; + let base = std::env::var("FLECTAR_FILES_TEST_URL").unwrap(); + assert!(base.starts_with("http://127.0.0.1:")); + let user = std::env::var("FLECTAR_FILES_TEST_USER").unwrap(); + let secret = std::env::var("FLECTAR_FILES_TEST_PASSWORD").unwrap(); + let root = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui_with_credentials( + Paths::for_tests(root.path()), + std::sync::Arc::new(TestSecrets(secret.clone())), + ) + .await + .unwrap(); + let email = user.clone(); + let endpoint = base.clone(); + core.db.write(move|c|{c.execute("INSERT INTO accounts(id,email,provider,auth_kind,username,imap_host,imap_port,smtp_host,smtp_port,created_at,mail_protocol,jmap_url) VALUES(1,?1,'imap','password',?1,'localhost',993,'localhost',587,0,'jmap',?2)",rusqlite::params![email,endpoint])?;Ok(())}).await.unwrap(); + core.save_file_connection_settings( + 1, + ConnectionSettings { + endpoint: base.clone(), + webdav: false, + }, + ) + .await + .unwrap(); + let mut remote = FileClient::connect(&base, &user, &secret, None) + .await + .unwrap(); + remote.list(None, "", 0).await.unwrap(); + let name = format!("durable-{}", chrono::Utc::now().timestamp_millis()); + let result = remote.create_folder(None, &name).await.unwrap(); + let folder = result["created"]["new"]["id"].as_str().unwrap().to_owned(); + let source = root.path().join("queued.txt"); + std::fs::write(&source, b"durable queue").unwrap(); + let push_client = remote.clone(); + let push = tokio::spawn(async move { + tokio::time::timeout( + std::time::Duration::from_secs(35), + push_client.wait_for_change(), + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + remote + .upload(Some(&folder), "initial.txt", &source, None) + .await + .unwrap(); + push.await + .unwrap() + .expect("File push timed out") + .expect("File push rejected"); + files::sync::account(&core, 1).await.unwrap(); + let mut service = FilesService { + attachments: true, + ..Default::default() + }; + service + .execute(&core, "load", "", "", -1, None) + .await + .unwrap(); + service + .execute(&core, "account", "1", "", -1, None) + .await + .unwrap(); + // The primary Connect storage action is available from attachment scope. + assert!(service.attachments); + service + .execute(&core, "connect", &base, "jmap", -1, None) + .await + .unwrap(); + assert!(!service.attachments); + let index = service + .entries + .iter() + .position(|n| matches!(n,Entry::Remote(n) if n.id==folder)) + .unwrap() as i32; + service + .execute(&core, "enter", "", "", index, None) + .await + .unwrap(); + // Simulate discovery being unavailable without changing the saved endpoint. + let closed = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + service.settings.endpoint = format!("http://{}", closed.local_addr().unwrap()); + drop(closed); + service.client = None; + service + .execute(&core, "refresh", "", "", -1, None) + .await + .unwrap(); + assert!(service.offline); + assert_eq!(service.parent(), Some(folder.clone())); + assert_eq!( + service.entries.len(), + 1, + "offline folder history must survive reconnect failure" + ); + service.settings.endpoint = base.clone(); + service + .execute(&core, "upload", "", "", -1, Some(source)) + .await + .unwrap(); + let jobs = files::store::operations(&core.files_db, 1).await.unwrap(); + let job = &jobs[0]; + assert_eq!(job.state, "queued"); + service + .execute(&core, "retry-operation", &job.id.to_string(), "", -1, None) + .await + .unwrap(); + assert_eq!( + files::store::operations(&core.files_db, 1).await.unwrap()[0].state, + "completed" + ); + files::sync::account(&core, 1).await.unwrap(); + let space = service.cached_space.unwrap(); + let nodes = files::store::cached(&core.files_db, space, Some(folder.clone()), "".into()) + .await + .unwrap(); + assert_eq!(nodes.len(), 2); + let filtered = remote.list(Some(&folder), "queued", 0).await.unwrap(); + assert_eq!( + filtered.nodes.len(), + 1, + "Stalwart filename search must filter results" + ); + let node = filtered.nodes[0].clone(); + let client = files::FileClient::Jmap(remote.clone()); + let cached = files::cache::content(&core, 1, space, &node, Some(&client), false) + .await + .unwrap(); + assert_eq!(std::fs::read(&cached).unwrap(), b"durable queue"); + assert_eq!( + files::cache::content(&core, 1, space, &node, None, true) + .await + .unwrap(), + cached + ); + remote.delete(&node.id, false).await.unwrap(); + files::sync::account(&core, 1).await.unwrap(); + assert!(!cached.exists()); + assert_eq!( + files::store::cached(&core.files_db, space, Some(folder.clone()), "".into()) + .await + .unwrap() + .len(), + 1 + ); + // Same database, explicit adapter switch, independent DAV collection cursors. + let dav = std::env::var("FLECTAR_FILES_TEST_DAV").unwrap(); + core.save_file_connection_settings( + 1, + ConnectionSettings { + endpoint: dav, + webdav: true, + }, + ) + .await + .unwrap(); + files::sync::account(&core, 1).await.unwrap(); + files::sync::account(&core, 1).await.unwrap(); + let count = core + .files_db + .read(|c| { + Ok(c.query_row( + "SELECT count(*) FROM nodes WHERE name='initial.txt'", + [], + |r| r.get::<_, i64>(0), + )?) + }) + .await + .unwrap(); + assert!(count > 0); + remote.list(None, "", 0).await.unwrap(); + remote.delete(&folder, true).await.unwrap(); + check_server_attachment_search(&core, &base, &user, &secret).await; +} + +async fn mail_call( + http: &reqwest::Client, + base: &str, + user: &str, + secret: &str, + method: &str, + args: serde_json::Value, +) -> serde_json::Value { + let response:serde_json::Value=http.post(format!("{base}/jmap/" )).basic_auth(user,Some(secret)).json(&json!({"using":["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],"methodCalls":[[method,args,"test"]]})).send().await.unwrap().json().await.unwrap(); + assert_eq!(response["methodResponses"][0][0], method, "{response}"); + response["methodResponses"][0][1].clone() +} +async fn check_server_attachment_search( + core: &flectar_mail_core::Core, + base: &str, + user: &str, + secret: &str, +) { + let http = reqwest::Client::new(); + let session: serde_json::Value = http + .get(format!("{base}/jmap/session")) + .basic_auth(user, Some(secret)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let account = session["primaryAccounts"]["urn:ietf:params:jmap:mail"] + .as_str() + .unwrap(); + let mailbox=mail_call(&http,base,user,secret,"Mailbox/set",json!({"accountId":account,"create":{"new":{"name":format!("Attachment test {}",chrono::Utc::now().timestamp_millis())}}})).await; + let mailbox = mailbox["created"]["new"]["id"].as_str().unwrap(); + let subject = format!("flectarsearch{}", chrono::Utc::now().timestamp_millis()); + let mime = format!( + "From: sender@files.test\r\nTo: {user}\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=filesboundary\r\n\r\n--filesboundary\r\nContent-Type: text/plain\r\n\r\nMail body\r\n--filesboundary\r\nContent-Type: text/plain; name=attachment.txt\r\nContent-Disposition: attachment; filename=attachment.txt\r\n\r\nquasarmetadata text\r\n--filesboundary--\r\n" + ); + let upload: serde_json::Value = http + .post(format!("{base}/jmap/upload/{account}/")) + .basic_auth(user, Some(secret)) + .header("Content-Type", "message/rfc822") + .body(mime) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let imported=mail_call(&http,base,user,secret,"Email/import",json!({"accountId":account,"emails":{"new":{"blobId":upload["blobId"],"mailboxIds":{mailbox:true},"keywords":{},"receivedAt":"2000-01-01T00:00:00Z"}}})).await; + let email = imported["created"]["new"]["id"].as_str().unwrap(); + let client = core.connect_attachment_search(1).await.unwrap(); + let page = client.search(&subject, 0).await.unwrap(); + assert_eq!(page.files.len(), 1); + assert_eq!(page.files[0].filename, "attachment.txt"); + assert!(page.files[0].date < 1_000_000_000_000); + let path = core.attachment_file_content(&page.files[0]).await.unwrap(); + assert!( + String::from_utf8(std::fs::read(path).unwrap()) + .unwrap() + .contains("quasarmetadata") + ); + let fetched = mail_call( + &http, + base, + user, + secret, + "Email/get", + json!({"accountId":account,"ids":[email],"properties":["keywords"]}), + ) + .await; + assert!( + fetched["list"][0]["keywords"].get("$seen").is_none(), + "file access must not mark email read" + ); + let count = core + .db + .read(|c| Ok(c.query_row("SELECT count(*) FROM messages", [], |r| r.get::<_, i64>(0))?)) + .await + .unwrap(); + assert_eq!( + count, 0, + "remote attachment search must not change Mail's history window" + ); + mail_call( + &http, + base, + user, + secret, + "Email/set", + json!({"accountId":account,"destroy":[email]}), + ) + .await; + mail_call( + &http, + base, + user, + secret, + "Mailbox/set", + json!({"accountId":account,"destroy":[mailbox]}), + ) + .await; +} diff --git a/crates/flectar-mail-core/tests/first_sync.rs b/crates/flectar-mail-core/tests/first_sync.rs index c88f731..cceda12 100644 --- a/crates/flectar-mail-core/tests/first_sync.rs +++ b/crates/flectar-mail-core/tests/first_sync.rs @@ -73,6 +73,7 @@ async fn first_sync_drains_bodies_fast_and_updates_live() { let started = Instant::now(); core.add_account_password(AddPasswordAccountArgs { + connection: Default::default(), email: "senduser@example.com".into(), display_name: Some("Send User".into()), username: "senduser".into(), diff --git a/crates/flectar-mail-core/tests/fixtures/tls/README.md b/crates/flectar-mail-core/tests/fixtures/tls/README.md new file mode 100644 index 0000000..d88b81a --- /dev/null +++ b/crates/flectar-mail-core/tests/fixtures/tls/README.md @@ -0,0 +1,4 @@ +These self-signed certificates and the accompanying private key are public test +fixtures, never production credentials. They permit deterministic local TLS +handshake tests. `server.pem` names localhost and 127.0.0.1; `wrong-host.pem` names +wrong.example. Their long expiration is intentional to avoid time-sensitive CI. diff --git a/crates/flectar-mail-core/tests/fixtures/tls/server-key.pem b/crates/flectar-mail-core/tests/fixtures/tls/server-key.pem new file mode 100644 index 0000000..03460d0 --- /dev/null +++ b/crates/flectar-mail-core/tests/fixtures/tls/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDwAhdQ/bT7CpDa +8RBkOwdywgJCSeaZU24WDCB7J7+78uHgJoYIpmZ6tpnlcByHNuETSIviiOcjhMr1 +ON4fEfOYQxjF/VfoQDmrlvVyDi7nTd8IzlvG0DlCneaNlzBqnAJ5xfHds565pxCG +kBDS1nTKPxvLiNhXcm18C3VcVjDD7acS+DDrHpAuwhzv5OgPlLgQP/2VuHI9ba26 +q7FXajv5fhzp7DvKjNc8fxA29ZvbnfBHw/oBiTFEe8NA0ol0AukB2EKogh009tAY +ptxGvKpcHR16ExXPOrfX1CYgp+NcenVvPk7FmucPTv77TfmBTIUblPSfnYNgi9Kp +cGVDWDoXAgMBAAECggEAPHZqZU7kzvFkeKOOG5H7WF+jrgtr+mhYLVm0vRHQbYrM +eGl8DqXM31EtB+3sYevjW7ZE9zbB8+K6NnizDGS7Vzpf/VDLWokDfkSzZJ5KFp85 +dsB1wmomYq/gqv7RhanzvbWuLsnXTLHXqe6VLmg0V9b6K5Y32h/LkRpZ4H8eHd09 +6/WuU8CBMayv78QmmclBzNxJt/GiQSmyFFjIXPY5hfcI1D7cVVLZufov4fJCDOQV +3JAQKYv6EkQA9NgNGHwnGFgVTuw+LK3RF2g0wya8jcCsyDzGfeYbtgV33TGaLcxs +9xSZI2eOHKSSTwed8PTnsiHWH/WuW0A7GQFSPhNloQKBgQD/x2W8gkn2CWygk6Zm +J85R3PSNeK/mYUA/MLSaLVJvmwx1Jk9jTYE3c8YrFhIE/XWG2oEeL0Lw9BHmzOKf +2DkyQNLi+NwTIejL9ZCDcuyZDRNBbgM6raNB5yzVtUkq/3KV6aduzI7Qy1WdS/8L +xRkF2zCPC+7aKPNF/wyLluRnfwKBgQDwNzQk75AFYX+DLQMlTlUO/oMAaAd9JfRh +4rTukwbbx1jlVUdOk9IM7obQSWNLdgTkWKuyhSWKPeYDJGCEKV38ha1+F8Lc8Dqd +QL7EkkMf+DffUff/L+jfONxwSfqX/yZImLL/uoRrS8dIgneB9hYmayIfB2OjptM3 +xJaYxqG5aQKBgCUapNyCvf3uAg8NPC9HiPuBJQy8vC+cXfiMtTZEatf70wPPieaq +4JrNtaqcagLFTd5BJkfdrUVM8qfbguiTMkDi/HSmiy5PVfj4Z31aF45gQjHUpq1F +cRBnW66ovLN7GQE8Quikzfbl+EZ4NshbUDT0RggKQBzvodK+m/BH9Fw7AoGBAKCE +MvrvfiyqTvvHy09Vfa8sUTunSEHPZ/M0+7RkWPPpzLRKfQC79GOhzEJIl+pn7bLs +sab0I/axkA7PcxM9VUmVxi35EMR0XqaQCinulp45TpSTAIXsbM8k/uCl/qcs31cp +eos6BpxwIbWJQPR+DaUUd9OGK4XCx2d/nSZvrMfpAoGAUlsK0xEkjaAXB7kXjhnz +iV497M5YMdYhCzvaJgXotTU1+3aRaMgw7EsbOj1j+SOMtVoklyTGLVQs3orcScK7 +ZuR+OltB/VtIGvPH03g2mzjrXLV3zjfgKccbH9vpAbtpTrBSFssgGec2QSdzMK0O +fcCTI/IOx0jAzD6BHXIO854= +-----END PRIVATE KEY----- diff --git a/crates/flectar-mail-core/tests/fixtures/tls/server.pem b/crates/flectar-mail-core/tests/fixtures/tls/server.pem new file mode 100644 index 0000000..8a462ed --- /dev/null +++ b/crates/flectar-mail-core/tests/fixtures/tls/server.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJDCCAgygAwIBAgIUZKDDvOME4AolHtU3h+AWlKS8pP8wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDkxMDAyMjg1OVoYDzIxMjYw +ODE3MDIyODU5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDwAhdQ/bT7CpDa8RBkOwdywgJCSeaZU24WDCB7J7+7 +8uHgJoYIpmZ6tpnlcByHNuETSIviiOcjhMr1ON4fEfOYQxjF/VfoQDmrlvVyDi7n +Td8IzlvG0DlCneaNlzBqnAJ5xfHds565pxCGkBDS1nTKPxvLiNhXcm18C3VcVjDD +7acS+DDrHpAuwhzv5OgPlLgQP/2VuHI9ba26q7FXajv5fhzp7DvKjNc8fxA29Zvb +nfBHw/oBiTFEe8NA0ol0AukB2EKogh009tAYptxGvKpcHR16ExXPOrfX1CYgp+Nc +enVvPk7FmucPTv77TfmBTIUblPSfnYNgi9KpcGVDWDoXAgMBAAGjbDBqMB0GA1Ud +DgQWBBRBzAPAlB5114l5/msgsCRp5oroqjAfBgNVHSMEGDAWgBRBzAPAlB5114l5 +/msgsCRp5oroqjAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwDAYDVR0TAQH/ +BAIwADANBgkqhkiG9w0BAQsFAAOCAQEA6vWV6G9gi0DoviHLpQ+uSmvtinPDh8q6 +Sr9b0hEvYHMR9ixd2biGO+JEAXOwfnSrAYuu6Fg+Hxszk0J1if1lL+SZNRVJYfWt +yUaXQxO40Ss7IAiCU66XCoBnXqZAvWUei10IvOZrMScl5bZt2R+ZCdSRsGghwr8K +7I8aHIstPmz12ZHHCLAracPe/tCL18DNhgUDvQUg06GO8+N9DP3rDaY2XrHOD3O1 +c2wY/Wa6N9qYgZC2qwXa1FfS1tGLC/UXo2dGAKNrRy4nl0zVKThH0KxwiF2vsRou +3JB4BhY/Wt/+quGsrwFEWwcGk5um4fqdc3hEoT1z/af2hawz/Jd3JA== +-----END CERTIFICATE----- diff --git a/crates/flectar-mail-core/tests/fixtures/tls/wrong-host.pem b/crates/flectar-mail-core/tests/fixtures/tls/wrong-host.pem new file mode 100644 index 0000000..3c63116 --- /dev/null +++ b/crates/flectar-mail-core/tests/fixtures/tls/wrong-host.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKjCCAhKgAwIBAgIUC2VyDx/p9Nun6JZB+CTn4b9CEgYwDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNd3JvbmcuZXhhbXBsZTAgFw0yNjA5MTAwMjMyNDRaGA8y +MTI2MDgxNzAyMzI0NFowGDEWMBQGA1UEAwwNd3JvbmcuZXhhbXBsZTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAPACF1D9tPsKkNrxEGQ7B3LCAkJJ5plT +bhYMIHsnv7vy4eAmhgimZnq2meVwHIc24RNIi+KI5yOEyvU43h8R85hDGMX9V+hA +OauW9XIOLudN3wjOW8bQOUKd5o2XMGqcAnnF8d2znrmnEIaQENLWdMo/G8uI2Fdy +bXwLdVxWMMPtpxL4MOsekC7CHO/k6A+UuBA//ZW4cj1trbqrsVdqO/l+HOnsO8qM +1zx/EDb1m9ud8EfD+gGJMUR7w0DSiXQC6QHYQqiCHTT20Bim3Ea8qlwdHXoTFc86 +t9fUJiCn41x6dW8+TsWa5w9O/vtN+YFMhRuU9J+dg2CL0qlwZUNYOhcCAwEAAaNq +MGgwHQYDVR0OBBYEFEHMA8CUHnXXiXn+ayCwJGnmiuiqMB8GA1UdIwQYMBaAFEHM +A8CUHnXXiXn+ayCwJGnmiuiqMBgGA1UdEQQRMA+CDXdyb25nLmV4YW1wbGUwDAYD +VR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAmWch56GAHTR4MBpGOKwzxKwJ +zbeP88mAMf3evwtuhVcn06ddYKCS/V1NGBtDz/JE5zHGaQlE7+qgan45q3geNBg6 +PblxGKHsNqUKYa+Sc9v8KcHPpV5Oo7UKhJ1HRKlfhQ0LBUCGZOg0cVkFD4wQovr9 +W0qpjbpLrcH6X7LE4d2cQvDVi8KE0vu+E7BHWZ2A8DqiUSuFoql48vmJLxBVcD0L +zsVX4OpgdxP98FIkRXqSEtlr4u/AyFZzcSduCDZhvOEpzK6VfwyyJjXrMjSStykO +BmWdXvRhIKs76UB2BGfyPcfjQDN9E7r5QMwRUkKjUWttsKtSBjSP+ckr9ep5IQ== +-----END CERTIFICATE----- diff --git a/crates/flectar-mail-core/tests/mail_connections.rs b/crates/flectar-mail-core/tests/mail_connections.rs new file mode 100644 index 0000000..0ed923b --- /dev/null +++ b/crates/flectar-mail-core/tests/mail_connections.rs @@ -0,0 +1,364 @@ +//! Local protocol fixtures exercise actual TLS handshakes without external accounts. +use flectar_mail_core::models::*; +use flectar_mail_core::{imap, smtp}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; +use std::sync::Arc; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpListener, +}; + +const CERT: &str = include_str!("fixtures/tls/server.pem"); +const KEY: &str = include_str!("fixtures/tls/server-key.pem"); + +fn acceptor() -> tokio_rustls::TlsAcceptor { + acceptor_for(CERT) +} + +fn acceptor_for(cert: &str) -> tokio_rustls::TlsAcceptor { + let config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from_pem_slice(cert.as_bytes()).unwrap()], + PrivateKeyDer::from_pem_slice(KEY.as_bytes()).unwrap(), + ) + .unwrap(); + tokio_rustls::TlsAcceptor::from(Arc::new(config)) +} +fn settings(mode: ConnectionSecurity) -> MailConnectionSettings { + MailConnectionSettings { + imap_security: mode, + smtp_security: mode, + trusted_certificate_pem: CERT.into(), + } +} +async fn line(stream: &mut BufReader) -> String { + let mut line = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + stream.read_line(&mut line), + ) + .await + .unwrap() + .unwrap(); + line +} +async fn imap_server(starttls: bool, reject: bool) -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let task = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let tcp = if starttls { + let mut plain = BufReader::new(tcp); + plain.write_all(b"* OK test IMAP ready\r\n").await.unwrap(); + let command = line(&mut plain).await; + assert!( + command.ends_with(" STARTTLS\r\n"), + "credentials sent before upgrade" + ); + let tag = command.split_whitespace().next().unwrap(); + if reject { + plain + .write_all(format!("{tag} NO TLS unavailable\r\n").as_bytes()) + .await + .unwrap(); + assert!( + line(&mut plain).await.is_empty(), + "client sent plaintext credentials after rejected STARTTLS" + ); + return; + } + plain + .write_all(format!("{tag} OK upgrade\r\n").as_bytes()) + .await + .unwrap(); + plain.into_inner() + } else { + tcp + }; + let Ok(tls) = acceptor().accept(tcp).await else { + return; + }; + let mut stream = BufReader::new(tls); + if !starttls { + stream.write_all(b"* OK test IMAP ready\r\n").await.unwrap(); + } + let login = line(&mut stream).await; + if login.is_empty() { + return; + } + assert!(login.contains(" LOGIN ")); + let tag = login.split_whitespace().next().unwrap(); + stream + .write_all(format!("{tag} OK logged in\r\n").as_bytes()) + .await + .unwrap(); + let logout = line(&mut stream).await; + let tag = logout.split_whitespace().next().unwrap(); + stream + .write_all(format!("* BYE closing\r\n{tag} OK logout\r\n").as_bytes()) + .await + .unwrap(); + }); + (port, task) +} +fn credentials() -> imap::ImapCredentials { + imap::ImapCredentials::Password { + user: "bridge-user".into(), + password: "bridge-password".into(), + } +} + +#[tokio::test] +async fn imap_starttls_on_custom_port() { + let (port, task) = imap_server(true, false).await; + let session = imap::connect_with_settings( + "127.0.0.1", + port, + credentials(), + &settings(ConnectionSecurity::Starttls), + ) + .await + .unwrap(); + imap::logout(session).await; + task.await.unwrap(); +} +#[tokio::test] +async fn imap_implicit_tls_on_custom_port() { + let (port, task) = imap_server(false, false).await; + let session = imap::connect_with_settings( + "127.0.0.1", + port, + credentials(), + &settings(ConnectionSecurity::Tls), + ) + .await + .unwrap(); + imap::logout(session).await; + task.await.unwrap(); +} +#[tokio::test] +async fn rejected_starttls_never_sends_credentials() { + let (port, task) = imap_server(true, true).await; + let error = imap::connect_with_settings( + "127.0.0.1", + port, + credentials(), + &settings(ConnectionSecurity::Starttls), + ) + .await + .err() + .unwrap(); + assert!(error.to_string().contains("STARTTLS was rejected")); + task.await.unwrap(); +} +#[tokio::test] +async fn untrusted_certificate_is_rejected() { + let (port, task) = imap_server(false, false).await; + let mut config = settings(ConnectionSecurity::Tls); + config.trusted_certificate_pem.clear(); + assert!( + imap::connect_with_settings("127.0.0.1", port, credentials(), &config) + .await + .is_err() + ); + task.await.unwrap(); +} + +async fn smtp_connection(mode: ConnectionSecurity) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let task = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let tcp = if mode == ConnectionSecurity::Starttls { + let mut plain = BufReader::new(tcp); + plain.write_all(b"220 localhost ESMTP\r\n").await.unwrap(); + assert!(line(&mut plain).await.starts_with("EHLO ")); + plain + .write_all(b"250-localhost\r\n250 STARTTLS\r\n") + .await + .unwrap(); + assert_eq!(line(&mut plain).await, "STARTTLS\r\n"); + plain.write_all(b"220 Ready\r\n").await.unwrap(); + plain.into_inner() + } else { + tcp + }; + let mut stream = BufReader::new(acceptor().accept(tcp).await.unwrap()); + if mode == ConnectionSecurity::Tls { + stream.write_all(b"220 localhost ESMTP\r\n").await.unwrap(); + } + assert!(line(&mut stream).await.starts_with("EHLO ")); + stream + .write_all(b"250-localhost\r\n250 AUTH PLAIN\r\n") + .await + .unwrap(); + assert!(line(&mut stream).await.starts_with("AUTH PLAIN ")); + stream.write_all(b"235 Authenticated\r\n").await.unwrap(); + assert_eq!(line(&mut stream).await, "NOOP\r\n"); + stream.write_all(b"250 OK\r\n").await.unwrap(); + assert_eq!(line(&mut stream).await, "QUIT\r\n"); + stream.write_all(b"221 Goodbye\r\n").await.unwrap(); + }); + let config = AccountConfig { + id: 1, + email: "user@example.com".into(), + display_name: None, + avatar_url: None, + provider: Provider::Imap, + auth_kind: AuthKind::Password, + mail_protocol: MailProtocol::Imap, + username: "bridge-user".into(), + jmap_url: String::new(), + jmap_account_id: None, + imap_host: "127.0.0.1".into(), + imap_port: 993, + smtp_host: "127.0.0.1".into(), + smtp_port: port, + settings: AccountSettings { + connection: settings(mode), + ..Default::default() + }, + }; + smtp::test_connection(&config, &smtp::SmtpAuth::Password("bridge-password".into())) + .await + .unwrap(); + task.await.unwrap(); +} +#[tokio::test] +async fn smtp_starttls_on_custom_port() { + smtp_connection(ConnectionSecurity::Starttls).await; +} +#[tokio::test] +async fn smtp_implicit_tls_on_custom_port() { + smtp_connection(ConnectionSecurity::Tls).await; +} + +#[test] +fn rejects_invalid_certificate_imports() { + assert!(imap::trusted_certificates(KEY).is_err()); + assert!(imap::trusted_certificates(&format!("{CERT}\n{KEY}")).is_err()); + assert!(imap::trusted_certificates("not a certificate").is_err()); + assert!(imap::trusted_certificates(&"x".repeat(256 * 1024 + 1)).is_err()); + assert_eq!(imap::trusted_certificates(CERT).unwrap().len(), 1); +} +#[test] +fn settings_round_trip_and_legacy_defaults() { + let legacy: AccountSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(legacy.connection, MailConnectionSettings::default()); + let configured = AccountSettings { + connection: settings(ConnectionSecurity::Starttls), + ..legacy + }; + let restored: AccountSettings = + serde_json::from_str(&serde_json::to_string(&configured).unwrap()).unwrap(); + assert_eq!(configured.connection, restored.connection); +} + +#[tokio::test] +async fn imported_certificate_still_checks_hostname() { + const WRONG: &str = include_str!("fixtures/tls/wrong-host.pem"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let task = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + assert!(acceptor_for(WRONG).accept(tcp).await.is_err()); + }); + let mut config = settings(ConnectionSecurity::Tls); + config.trusted_certificate_pem = WRONG.into(); + let error = imap::connect_with_settings("127.0.0.1", port, credentials(), &config) + .await + .err() + .unwrap(); + assert!(error.to_string().contains("not valid for name"), "{error}"); + task.await.unwrap(); +} + +#[tokio::test] +async fn smtp_failure_prevents_saving_account() { + use flectar_mail_core::{ + Core, accounts::credentials::DevelopmentFileCredentialStore, config::Paths, + }; + let temp = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui_with_credentials( + Paths::for_tests(temp.path()), + Arc::new(DevelopmentFileCredentialStore::new( + temp.path().join("test-credentials.json"), + )), + ) + .await + .unwrap(); + let (imap_port, imap_task) = imap_server(true, false).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let smtp_port = listener.local_addr().unwrap().port(); + let smtp_task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + stream + .write_all(b"554 Sending unavailable\r\n") + .await + .unwrap(); + }); + let error = core + .add_account_password(AddPasswordAccountArgs { + email: "user@example.com".into(), + display_name: None, + username: "bridge-user".into(), + password: "bridge-password".into(), + mail_protocol: MailProtocol::Imap, + jmap_url: String::new(), + imap_host: "127.0.0.1".into(), + imap_port, + smtp_host: "127.0.0.1".into(), + smtp_port, + connection: settings(ConnectionSecurity::Starttls), + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("smtp"), "{error}"); + assert!(core.list_accounts().await.unwrap().is_empty()); + imap_task.await.unwrap(); + smtp_task.await.unwrap(); +} + +#[tokio::test] +async fn backup_import_preserves_transport_settings_and_rejects_private_keys() { + use flectar_mail_core::{Core, config::Paths}; + let temp = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(temp.path())) + .await + .unwrap(); + let mut config = PortableAccountConfig { + email: "user@example.com".into(), + display_name: None, + provider: Provider::Imap, + auth_kind: AuthKind::Password, + mail_protocol: MailProtocol::Imap, + username: "bridge-user".into(), + jmap_url: String::new(), + imap_host: "127.0.0.1".into(), + imap_port: 1143, + smtp_host: "127.0.0.1".into(), + smtp_port: 1025, + settings: AccountSettings { + connection: settings(ConnectionSecurity::Starttls), + ..Default::default() + }, + }; + core.import_account_configs(vec![config.clone()]) + .await + .unwrap(); + assert_eq!( + core.list_account_configs().await.unwrap()[0] + .settings + .connection, + config.settings.connection + ); + config.email = "other@example.com".into(); + config.settings.connection.trusted_certificate_pem = KEY.into(); + assert!(core.import_account_configs(vec![config]).await.is_err()); + assert_eq!(core.list_accounts().await.unwrap().len(), 1); +} diff --git a/crates/flectar-mail-core/tests/send_e2e.rs b/crates/flectar-mail-core/tests/send_e2e.rs index 6eb4d5c..f914a95 100644 --- a/crates/flectar-mail-core/tests/send_e2e.rs +++ b/crates/flectar-mail-core/tests/send_e2e.rs @@ -89,6 +89,7 @@ async fn reply_send_reaches_smtp_and_sent_folder() { let tmp = tempfile::tempdir().unwrap(); let core = Core::start(Paths::for_tests(tmp.path())).await.unwrap(); core.add_account_password(AddPasswordAccountArgs { + connection: Default::default(), email: "senduser@example.com".into(), display_name: Some("Send User".into()), username: "senduser".into(), diff --git a/lang/es/LC_MESSAGES/flectar-mail.po b/lang/es/LC_MESSAGES/flectar-mail.po index 2acc7ca..a3ab58e 100644 --- a/lang/es/LC_MESSAGES/flectar-mail.po +++ b/lang/es/LC_MESSAGES/flectar-mail.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: flectar-mail 0.1.0\n" -"POT-Creation-Date: 2026-09-08 14:26+0000\n" +"POT-Creation-Date: 2026-09-11 13:50+0000\n" "PO-Revision-Date: 2026-08-31 02:35+0000\n" "Last-Translator: Flectar contributors\n" "Language-Team: Spanish\n" @@ -12,723 +12,723 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: ui/app.slint:57 +#: ui/app.slint:65 msgid "Jan" msgstr "ene" -#: ui/app.slint:57 +#: ui/app.slint:65 msgid "January" msgstr "enero" -#: ui/app.slint:58 +#: ui/app.slint:66 msgid "Feb" msgstr "feb" -#: ui/app.slint:58 +#: ui/app.slint:66 msgid "February" msgstr "febrero" -#: ui/app.slint:59 +#: ui/app.slint:67 msgid "Mar" msgstr "mar" -#: ui/app.slint:59 +#: ui/app.slint:67 msgid "March" msgstr "marzo" -#: ui/app.slint:60 +#: ui/app.slint:68 msgid "Apr" msgstr "abr" -#: ui/app.slint:60 +#: ui/app.slint:68 msgid "April" msgstr "abril" -#: ui/app.slint:61 +#: ui/app.slint:69 msgid "May" msgstr "mayo" -#: ui/app.slint:62 +#: ui/app.slint:70 msgid "Jun" msgstr "jun" -#: ui/app.slint:62 +#: ui/app.slint:70 msgid "June" msgstr "junio" -#: ui/app.slint:63 +#: ui/app.slint:71 msgid "Jul" msgstr "jul" -#: ui/app.slint:63 +#: ui/app.slint:71 msgid "July" msgstr "julio" -#: ui/app.slint:64 +#: ui/app.slint:72 msgid "Aug" msgstr "ago" -#: ui/app.slint:64 +#: ui/app.slint:72 msgid "August" msgstr "agosto" -#: ui/app.slint:65 +#: ui/app.slint:73 msgid "Sep" msgstr "sept" -#: ui/app.slint:65 +#: ui/app.slint:73 msgid "September" msgstr "septiembre" -#: ui/app.slint:66 +#: ui/app.slint:74 msgid "Oct" msgstr "oct" -#: ui/app.slint:66 +#: ui/app.slint:74 msgid "October" msgstr "octubre" -#: ui/app.slint:67 +#: ui/app.slint:75 msgid "Nov" msgstr "nov" -#: ui/app.slint:67 +#: ui/app.slint:75 msgid "November" msgstr "noviembre" -#: ui/app.slint:68 +#: ui/app.slint:76 msgid "Dec" msgstr "dic" -#: ui/app.slint:68 +#: ui/app.slint:76 msgid "December" msgstr "diciembre" -#: ui/app.slint:73 ui/components/calendar.slint:635 +#: ui/app.slint:81 ui/components/calendar.slint:635 msgid "Mon" msgstr "Lun" -#: ui/app.slint:74 ui/components/calendar.slint:635 +#: ui/app.slint:82 ui/components/calendar.slint:635 msgid "Tue" msgstr "Mar" -#: ui/app.slint:75 ui/components/calendar.slint:635 +#: ui/app.slint:83 ui/components/calendar.slint:635 msgid "Wed" msgstr "Mié" -#: ui/app.slint:76 ui/components/calendar.slint:635 +#: ui/app.slint:84 ui/components/calendar.slint:635 msgid "Thu" msgstr "Jue" -#: ui/app.slint:77 ui/components/calendar.slint:635 +#: ui/app.slint:85 ui/components/calendar.slint:635 msgid "Fri" msgstr "Vie" -#: ui/app.slint:78 ui/components/calendar.slint:635 +#: ui/app.slint:86 ui/components/calendar.slint:635 msgid "Sat" msgstr "Sáb" -#: ui/app.slint:79 ui/components/calendar.slint:635 +#: ui/app.slint:87 ui/components/calendar.slint:635 msgid "Sun" msgstr "Dom" -#: ui/app.slint:82 +#: ui/app.slint:90 msgid "All day" msgstr "Todo el día" -#: ui/app.slint:83 +#: ui/app.slint:91 msgid "(untitled event)" msgstr "(evento sin título)" -#: ui/app.slint:84 +#: ui/app.slint:92 msgid "No conversation history" msgstr "Sin historial de conversaciones" -#: ui/app.slint:85 +#: ui/app.slint:93 msgid "Conversation history available" msgstr "Historial de conversaciones disponible" -#: ui/app.slint:86 +#: ui/app.slint:94 msgid "New address-book entry" msgstr "Nueva entrada de contactos" -#: ui/app.slint:87 ui/app.slint:668 ui/components/composer-fields.slint:63 +#: ui/app.slint:95 ui/app.slint:686 ui/components/composer-fields.slint:63 #: ui/dialogs/compose-dialog.slint:34 msgid "No connected account" msgstr "No hay ninguna cuenta conectada" -#: ui/app.slint:89 +#: ui/app.slint:97 msgid "{n} interaction" msgid_plural "{n} interactions" msgstr[0] "{n} interacción" msgstr[1] "{n} interacciones" -#: ui/app.slint:92 +#: ui/app.slint:100 msgid "Last contacted {} {}, {}" msgstr "Último contacto: {1} de {0} de {2}" -#: ui/app.slint:94 ui/app.slint:291 +#: ui/app.slint:102 ui/app.slint:299 msgid "Loading local data…" msgstr "Cargando datos locales…" -#: ui/app.slint:95 ui/app.slint:292 +#: ui/app.slint:103 ui/app.slint:300 msgid "Local data ready." msgstr "Datos locales listos." -#: ui/app.slint:96 +#: ui/app.slint:104 msgid "Local data is unavailable: {}" msgstr "Los datos locales no están disponibles: {}" -#: ui/app.slint:97 +#: ui/app.slint:105 msgid "Could not display local mail: {}" msgstr "No se pudo mostrar el correo local: {}" -#: ui/app.slint:128 +#: ui/app.slint:136 msgid "An unexpected interface message occurred." msgstr "Se produjo un mensaje inesperado de la interfaz." -#: ui/app.slint:132 +#: ui/app.slint:140 msgid "Notification preferences are active for this session." msgstr "Las preferencias de notificaciones están activas durante esta sesión." -#: ui/app.slint:133 +#: ui/app.slint:141 msgid "Notification preferences saved." msgstr "Preferencias de notificaciones guardadas." -#: ui/app.slint:134 +#: ui/app.slint:142 msgid "Could not save notifications: {}" msgstr "No se pudieron guardar las notificaciones: {}" -#: ui/app.slint:135 +#: ui/app.slint:143 msgid "Sync interval is active for this session." msgstr "El intervalo de sincronización está activo durante esta sesión." -#: ui/app.slint:136 +#: ui/app.slint:144 msgid "Sync interval set to {} minutes." msgstr "Intervalo de sincronización establecido en {} minutos." -#: ui/app.slint:137 +#: ui/app.slint:145 msgid "Could not save sync interval: {}" msgstr "No se pudo guardar el intervalo de sincronización: {}" -#: ui/app.slint:138 +#: ui/app.slint:146 msgid "Message-opening preference updated for this session." msgstr "" "La preferencia de apertura de mensajes se actualizó durante esta sesión." -#: ui/app.slint:139 +#: ui/app.slint:147 msgid "Message-opening preference saved." msgstr "Preferencia de apertura de mensajes guardada." -#: ui/app.slint:140 +#: ui/app.slint:148 msgid "Could not save message-opening preference: {}" msgstr "No se pudo guardar la preferencia de apertura de mensajes: {}" -#: ui/app.slint:141 +#: ui/app.slint:149 msgid "Theme updated for this session." msgstr "El tema se actualizó durante esta sesión." -#: ui/app.slint:142 +#: ui/app.slint:150 msgid "Theme preference saved." msgstr "Preferencia de tema guardada." -#: ui/app.slint:143 +#: ui/app.slint:151 msgid "Could not save theme: {}" msgstr "No se pudo guardar el tema: {}" -#: ui/app.slint:144 +#: ui/app.slint:152 msgid "Theme palette updated for this session." msgstr "La paleta del tema se actualizó durante esta sesión." -#: ui/app.slint:145 +#: ui/app.slint:153 msgid "Theme palette saved." msgstr "Paleta del tema guardada." -#: ui/app.slint:146 +#: ui/app.slint:154 msgid "Could not save theme palette: {}" msgstr "No se pudo guardar la paleta del tema: {}" -#: ui/app.slint:147 +#: ui/app.slint:155 msgid "Custom theme updated for this session." msgstr "El tema personalizado se actualizó durante esta sesión." -#: ui/app.slint:148 +#: ui/app.slint:156 msgid "Custom theme saved." msgstr "Tema personalizado guardado." -#: ui/app.slint:149 +#: ui/app.slint:157 msgid "Could not save custom theme: {}" msgstr "No se pudo guardar el tema personalizado: {}" -#: ui/app.slint:150 +#: ui/app.slint:158 msgid "Language updated for this session." msgstr "El idioma se actualizó durante esta sesión." -#: ui/app.slint:151 +#: ui/app.slint:159 msgid "Language preference saved." msgstr "Preferencia de idioma guardada." -#: ui/app.slint:152 +#: ui/app.slint:160 msgid "Could not save language: {}" msgstr "No se pudo guardar el idioma: {}" -#: ui/app.slint:153 +#: ui/app.slint:161 msgid "Sidebar icon style updated for this session." msgstr "" "El estilo de los iconos de la barra lateral se actualizó durante esta sesión." -#: ui/app.slint:154 +#: ui/app.slint:162 msgid "Sidebar icon preference saved." msgstr "Preferencia de iconos de la barra lateral guardada." -#: ui/app.slint:155 +#: ui/app.slint:163 msgid "Could not save sidebar icon preference: {}" msgstr "No se pudo guardar la preferencia de iconos de la barra lateral: {}" -#: ui/app.slint:156 +#: ui/app.slint:164 msgid "Avatar preference updated for this session." msgstr "La preferencia de avatares se actualizó durante esta sesión." -#: ui/app.slint:157 +#: ui/app.slint:165 msgid "Avatar preference saved." msgstr "Preferencia de avatares guardada." -#: ui/app.slint:158 +#: ui/app.slint:166 msgid "Could not save avatar preference: {}" msgstr "No se pudo guardar la preferencia de avatares: {}" -#: ui/app.slint:159 +#: ui/app.slint:167 msgid "Workspace layout updated for this session." msgstr "" "La disposición del espacio de trabajo se actualizó durante esta sesión." -#: ui/app.slint:160 +#: ui/app.slint:168 msgid "Workspace layout preference saved." msgstr "Preferencia de disposición del espacio de trabajo guardada." -#: ui/app.slint:161 +#: ui/app.slint:169 msgid "Could not save workspace layout: {}" msgstr "No se pudo guardar la disposición del espacio de trabajo: {}" -#: ui/app.slint:166 +#: ui/app.slint:174 msgid "Opened {}" msgstr "Se abrió {}" -#: ui/app.slint:167 +#: ui/app.slint:175 msgid "Could not open link: {}" msgstr "No se pudo abrir el enlace: {}" -#: ui/app.slint:168 +#: ui/app.slint:176 msgid "Copied HTML source" msgstr "Código fuente HTML copiado" -#: ui/app.slint:169 +#: ui/app.slint:177 msgid "Copied selected text" msgstr "Texto seleccionado copiado" -#: ui/app.slint:170 +#: ui/app.slint:178 msgid "Copied email text" msgstr "Texto del correo copiado" -#: ui/app.slint:171 +#: ui/app.slint:179 msgid "Copied link" msgstr "Enlace copiado" -#: ui/app.slint:172 +#: ui/app.slint:180 msgid "Blitz software selection render failed: {}" msgstr "Falló la renderización por software de la selección en Blitz: {}" -#: ui/app.slint:173 +#: ui/app.slint:181 msgid "Blitz software tile render failed: {}" msgstr "Falló la renderización por software de los mosaicos en Blitz: {}" -#: ui/app.slint:174 +#: ui/app.slint:182 msgid "Message ready." msgstr "Mensaje listo." -#: ui/app.slint:175 -msgid "System tray behavior is unavailable on mobile." +#: ui/app.slint:183 +msgid "System tray behavior is unavailable in this package." msgstr "" -"El comportamiento de la bandeja del sistema no está disponible en " -"dispositivos móviles." +"El comportamiento de la bandeja del sistema no está disponible en este " +"paquete." -#: ui/app.slint:176 +#: ui/app.slint:184 msgid "Close-to-tray is active for this session." msgstr "Cerrar a la bandeja está activo durante esta sesión." -#: ui/app.slint:177 +#: ui/app.slint:185 msgid "Saving close behavior…" msgstr "Guardando el comportamiento de cierre…" -#: ui/app.slint:178 +#: ui/app.slint:186 msgid "Closing the window will now keep Flectar Mail in the tray." msgstr "Al cerrar la ventana, Flectar Mail permanecerá en la bandeja." -#: ui/app.slint:179 +#: ui/app.slint:187 msgid "Closing the window will now quit Flectar Mail." msgstr "Al cerrar la ventana, Flectar Mail se cerrará por completo." -#: ui/app.slint:180 +#: ui/app.slint:188 msgid "Could not save close behavior: {}" msgstr "No se pudo guardar el comportamiento de cierre: {}" -#: ui/app.slint:185 +#: ui/app.slint:193 msgid "Database snapshot exported to {}." msgstr "Instantánea de la base de datos exportada a {}." -#: ui/app.slint:186 +#: ui/app.slint:194 msgid "Could not export database snapshot: {}" msgstr "No se pudo exportar la instantánea de la base de datos: {}" -#: ui/app.slint:187 +#: ui/app.slint:195 msgid "Account order saved." msgstr "Orden de las cuentas guardado." -#: ui/app.slint:188 +#: ui/app.slint:196 msgid "Could not save account order: {}" msgstr "No se pudo guardar el orden de las cuentas: {}" -#: ui/app.slint:189 +#: ui/app.slint:197 msgid "Mail history updated. Background sync is adjusting now." msgstr "" "Historial de correo actualizado. La sincronización en segundo plano se está " "ajustando." -#: ui/app.slint:190 +#: ui/app.slint:198 msgid "Could not update mail history: {}" msgstr "No se pudo actualizar el historial de correo: {}" -#: ui/app.slint:191 +#: ui/app.slint:199 msgid "Connected {}. Initial sync is running in the background." msgstr "Se conectó {}. La sincronización inicial se ejecuta en segundo plano." -#: ui/app.slint:192 +#: ui/app.slint:200 msgid "Account setup failed: {}" msgstr "Falló la configuración de la cuenta: {}" -#: ui/app.slint:193 +#: ui/app.slint:201 msgid "OAuth failed: {}" msgstr "Falló OAuth: {}" -#: ui/app.slint:194 +#: ui/app.slint:202 msgid "Reconnected {}. Synchronization is resuming." msgstr "Se volvió a conectar {}. La sincronización se está reanudando." -#: ui/app.slint:195 +#: ui/app.slint:203 msgid "Reconnection failed: {}" msgstr "Falló la reconexión: {}" -#: ui/app.slint:196 +#: ui/app.slint:204 msgid "Calendar connected and initial sync started." msgstr "Calendario conectado y sincronización inicial iniciada." -#: ui/app.slint:197 +#: ui/app.slint:205 msgid "Could not connect calendar: {}" msgstr "No se pudo conectar el calendario: {}" -#: ui/app.slint:198 +#: ui/app.slint:206 msgid "Calendar sync enabled." msgstr "Sincronización del calendario activada." -#: ui/app.slint:199 +#: ui/app.slint:207 msgid "Calendar sync paused." msgstr "Sincronización del calendario en pausa." -#: ui/app.slint:200 +#: ui/app.slint:208 msgid "Could not update calendar sync: {}" msgstr "No se pudo actualizar la sincronización del calendario: {}" -#: ui/app.slint:201 +#: ui/app.slint:209 msgid "CalDAV connected and initial sync started." msgstr "CalDAV conectado y sincronización inicial iniciada." -#: ui/app.slint:202 +#: ui/app.slint:210 msgid "Could not connect CalDAV: {}" msgstr "No se pudo conectar CalDAV: {}" -#: ui/app.slint:207 +#: ui/app.slint:215 msgid "Connect an account before creating a backup." msgstr "Conecta una cuenta antes de crear una copia de seguridad." -#: ui/app.slint:208 +#: ui/app.slint:216 msgid "Export Flectar Mail account backup" msgstr "Exportar copia de seguridad de cuentas de Flectar Mail" -#: ui/app.slint:209 +#: ui/app.slint:217 msgid "Import Flectar Mail account backup" msgstr "Importar copia de seguridad de cuentas de Flectar Mail" -#: ui/app.slint:210 +#: ui/app.slint:218 msgid "Flectar Mail backup" msgstr "Copia de seguridad de Flectar Mail" -#: ui/app.slint:211 +#: ui/app.slint:219 msgid "Choose where to export the database snapshot" msgstr "Elige dónde exportar la instantánea de la base de datos" -#: ui/app.slint:212 +#: ui/app.slint:220 msgid "The platform host does not provide document export." msgstr "La plataforma no permite exportar documentos." -#: ui/app.slint:213 +#: ui/app.slint:221 msgid "The platform host does not provide document import." msgstr "La plataforma no permite importar documentos." -#: ui/app.slint:214 +#: ui/app.slint:222 msgid "Backup exported. Passwords and OAuth tokens were not included." msgstr "" "Copia de seguridad exportada. No se incluyeron contraseñas ni tokens de " "OAuth." -#: ui/app.slint:215 +#: ui/app.slint:223 msgid "Could not export backup: {}" msgstr "No se pudo exportar la copia de seguridad: {}" -#: ui/app.slint:216 +#: ui/app.slint:224 msgid "Local data storage is unavailable. Retry startup." msgstr "" "El almacenamiento de datos locales no está disponible. Vuelve a intentar el " "inicio." -#: ui/app.slint:217 +#: ui/app.slint:225 msgid "Creating verified database snapshot…" msgstr "Creando una instantánea verificada de la base de datos…" -#: ui/app.slint:218 +#: ui/app.slint:226 msgid "Backup imported. All {} account configurations already existed." msgstr "" "Copia de seguridad importada. Las {} configuraciones de cuenta ya existían." -#: ui/app.slint:219 +#: ui/app.slint:227 msgid "" "Imported {} account configurations. Open Accounts and sign in to each one." msgstr "" "Se importaron {} configuraciones de cuenta. Abre Cuentas e inicia sesión en " "cada una." -#: ui/app.slint:220 +#: ui/app.slint:228 msgid "Could not import backup: {}" msgstr "No se pudo importar la copia de seguridad: {}" -#: ui/app.slint:221 +#: ui/app.slint:229 msgid "All local Flectar Mail data was deleted." msgstr "Se eliminaron todos los datos locales de Flectar Mail." -#: ui/app.slint:222 +#: ui/app.slint:230 msgid "Could not delete all local data: {}" msgstr "No se pudieron eliminar todos los datos locales: {}" -#: ui/app.slint:223 +#: ui/app.slint:231 msgid "Deleting local accounts and cached data…" msgstr "Eliminando las cuentas locales y los datos en caché…" -#: ui/app.slint:228 +#: ui/app.slint:236 msgid "Background mail refresh failed: {}" msgstr "Falló la actualización del correo en segundo plano: {}" -#: ui/app.slint:229 +#: ui/app.slint:237 msgid "No messages in this folder" msgstr "No hay mensajes en esta carpeta" -#: ui/app.slint:230 +#: ui/app.slint:238 msgid "No messages match this search" msgstr "Ningún mensaje coincide con esta búsqueda" -#: ui/app.slint:231 +#: ui/app.slint:239 msgid "Mail core is ready for account synchronization and message actions." msgstr "" "El núcleo de correo está listo para sincronizar cuentas y realizar acciones " "con mensajes." -#: ui/app.slint:232 +#: ui/app.slint:240 msgid "Showing {} of {} message" msgstr "Mostrando {} de {} mensaje" -#: ui/app.slint:233 +#: ui/app.slint:241 msgid "Showing {} of {} message · more available" msgstr "Mostrando {} de {} mensaje · hay más disponibles" -#: ui/app.slint:234 +#: ui/app.slint:242 msgid "Showing {} of {} message · current results complete" msgstr "Mostrando {} de {} mensaje · resultados actuales completos" -#: ui/app.slint:235 +#: ui/app.slint:243 msgid "Showing {} of {} messages" msgstr "Mostrando {} de {} mensajes" -#: ui/app.slint:236 +#: ui/app.slint:244 msgid "Showing {} of {} messages · more available" msgstr "Mostrando {} de {} mensajes · hay más disponibles" -#: ui/app.slint:237 +#: ui/app.slint:245 msgid "Showing {} of {} messages · current results complete" msgstr "Mostrando {} de {} mensajes · resultados actuales completos" -#: ui/app.slint:238 +#: ui/app.slint:246 msgid "Showing {} of {} message matching \"{}\"" msgstr "Mostrando {} de {} mensaje que coincide con «{}»" -#: ui/app.slint:239 +#: ui/app.slint:247 msgid "Showing {} of {} message matching \"{}\" · more available" msgstr "Mostrando {} de {} mensaje que coincide con «{}» · hay más disponibles" -#: ui/app.slint:240 +#: ui/app.slint:248 msgid "Showing {} of {} message matching \"{}\" · current results complete" msgstr "" "Mostrando {} de {} mensaje que coincide con «{}» · resultados actuales " "completos" -#: ui/app.slint:241 +#: ui/app.slint:249 msgid "Showing {} of {} messages matching \"{}\"" msgstr "Mostrando {} de {} mensajes que coinciden con «{}»" -#: ui/app.slint:242 +#: ui/app.slint:250 msgid "Showing {} of {} messages matching \"{}\" · more available" msgstr "" "Mostrando {} de {} mensajes que coinciden con «{}» · hay más disponibles" -#: ui/app.slint:243 +#: ui/app.slint:251 msgid "Showing {} of {} messages matching \"{}\" · current results complete" msgstr "" "Mostrando {} de {} mensajes que coinciden con «{}» · resultados actuales " "completos" -#: ui/app.slint:244 +#: ui/app.slint:252 msgid "Rendered with software fallback after GPU error: {}" msgstr "Renderizado por software tras un error de la GPU: {}" -#: ui/app.slint:245 +#: ui/app.slint:253 msgid "Blitz render failed (GPU: {}; software: {})" msgstr "Falló la renderización de Blitz (GPU: {}; software: {})" -#: ui/app.slint:250 +#: ui/app.slint:258 msgid "Add an email address, then save the contact." msgstr "Añade una dirección de correo y guarda el contacto." -#: ui/app.slint:251 +#: ui/app.slint:259 msgid "Enter a valid email address before saving." msgstr "Introduce una dirección de correo válida antes de guardar." -#: ui/app.slint:252 +#: ui/app.slint:260 msgid "Could not save: contact storage is unavailable." msgstr "No se pudo guardar: el almacenamiento de contactos no está disponible." -#: ui/app.slint:253 +#: ui/app.slint:261 msgid "Could not save contact: {}" msgstr "No se pudo guardar el contacto: {}" -#: ui/app.slint:254 +#: ui/app.slint:262 msgid "Contact saved." msgstr "Contacto guardado." -#: ui/app.slint:255 +#: ui/app.slint:263 msgid "Could not delete: contact storage is unavailable." msgstr "" "No se pudo eliminar: el almacenamiento de contactos no está disponible." -#: ui/app.slint:256 +#: ui/app.slint:264 msgid "Could not delete contact: {}" msgstr "No se pudo eliminar el contacto: {}" -#: ui/app.slint:257 +#: ui/app.slint:265 msgid "Contact deleted." msgstr "Contacto eliminado." -#: ui/app.slint:258 +#: ui/app.slint:266 msgid "Could not load contacts: {}" msgstr "No se pudieron cargar los contactos: {}" -#: ui/app.slint:259 +#: ui/app.slint:267 msgid "Local contact storage is still starting…" msgstr "El almacenamiento local de contactos aún se está iniciando…" -#: ui/app.slint:260 +#: ui/app.slint:268 msgid "Loading contacts…" msgstr "Cargando contactos…" -#: ui/app.slint:265 +#: ui/app.slint:273 msgid "Could not load calendar: {}" msgstr "No se pudo cargar el calendario: {}" -#: ui/app.slint:266 +#: ui/app.slint:274 msgid "Calendar shown and syncing." msgstr "Calendario visible y sincronizándose." -#: ui/app.slint:267 +#: ui/app.slint:275 msgid "Calendar hidden and sync paused." msgstr "Calendario oculto y sincronización en pausa." -#: ui/app.slint:268 +#: ui/app.slint:276 msgid "Calendar update failed: {}" msgstr "Falló la actualización del calendario: {}" -#: ui/app.slint:269 +#: ui/app.slint:277 msgid "Only events created in Flectar Mail can be edited." msgstr "Solo se pueden editar los eventos creados en Flectar Mail." -#: ui/app.slint:270 +#: ui/app.slint:278 msgid "Only events created in Flectar Mail can be deleted." msgstr "Solo se pueden eliminar los eventos creados en Flectar Mail." -#: ui/app.slint:271 +#: ui/app.slint:279 msgid "Calendar storage is unavailable." msgstr "El almacenamiento del calendario no está disponible." -#: ui/app.slint:272 +#: ui/app.slint:280 msgid "Could not delete event: {}" msgstr "No se pudo eliminar el evento: {}" -#: ui/app.slint:273 +#: ui/app.slint:281 msgid "Event deleted, but refresh failed: {}" msgstr "El evento se eliminó, pero falló la actualización: {}" -#: ui/app.slint:274 +#: ui/app.slint:282 msgid "Event deleted." msgstr "Evento eliminado." -#: ui/app.slint:275 +#: ui/app.slint:283 msgid "Use YYYY-MM-DD and a 24-hour time such as 09:30." msgstr "Usa AAAA-MM-DD y una hora en formato de 24 horas, como 09:30." -#: ui/app.slint:276 +#: ui/app.slint:284 msgid "Event duration must be a number of minutes." msgstr "La duración del evento debe ser un número de minutos." -#: ui/app.slint:277 +#: ui/app.slint:285 msgid "Add a title and choose a duration from 15 to 1440 minutes." msgstr "Añade un título y elige una duración de entre 15 y 1440 minutos." -#: ui/app.slint:278 +#: ui/app.slint:286 msgid "That local time does not exist in the current time zone." msgstr "Esa hora local no existe en la zona horaria actual." -#: ui/app.slint:279 +#: ui/app.slint:287 msgid "Could not save event: {}" msgstr "No se pudo guardar el evento: {}" -#: ui/app.slint:280 +#: ui/app.slint:288 msgid "Event saved, but refresh failed: {}" msgstr "El evento se guardó, pero falló la actualización: {}" -#: ui/app.slint:281 +#: ui/app.slint:289 msgid "Event saved. Provider sync will run when enabled." msgstr "" "Evento guardado. La sincronización del proveedor se ejecutará cuando esté " "activada." -#: ui/app.slint:286 +#: ui/app.slint:294 msgid "Email resource render failed: {}" msgstr "Falló la renderización de un recurso del correo: {}" -#: ui/app.slint:287 +#: ui/app.slint:295 msgid "Message refresh failed: {}" msgstr "Falló la actualización del mensaje: {}" -#: ui/app.slint:288 +#: ui/app.slint:296 msgid "Message load failed: {}" msgstr "Falló la carga del mensaje: {}" -#: ui/app.slint:289 +#: ui/app.slint:297 msgid "" "Your local mailbox could not be opened. No account operation was started. " "Retry, or copy the diagnostics for support." @@ -736,230 +736,230 @@ msgstr "" "No se pudo abrir tu buzón local. No se inició ninguna operación de cuenta. " "Vuelve a intentarlo o copia el diagnóstico para solicitar asistencia." -#: ui/app.slint:290 +#: ui/app.slint:298 msgid "Local storage startup failed." msgstr "Falló el inicio del almacenamiento local." -#: ui/app.slint:297 +#: ui/app.slint:305 msgid "Could not identify the dragged account." msgstr "No se pudo identificar la cuenta arrastrada." -#: ui/app.slint:298 +#: ui/app.slint:306 msgid "Account ordering requires local mail data." msgstr "Para ordenar las cuentas se necesitan los datos de correo locales." -#: ui/app.slint:299 +#: ui/app.slint:307 msgid "Could not find the account to reorder." msgstr "No se encontró la cuenta que se iba a reordenar." -#: ui/app.slint:300 +#: ui/app.slint:308 msgid "Saving account order…" msgstr "Guardando el orden de las cuentas…" -#: ui/app.slint:301 +#: ui/app.slint:309 msgid "Mail history requires local mail data." msgstr "El historial de correo necesita los datos de correo locales." -#: ui/app.slint:302 +#: ui/app.slint:310 msgid "Updating mail history…" msgstr "Actualizando el historial de correo…" -#: ui/app.slint:303 +#: ui/app.slint:311 msgid "Remote images are unavailable in this minimal build." msgstr "Las imágenes remotas no están disponibles en esta compilación mínima." -#: ui/app.slint:304 +#: ui/app.slint:312 msgid "Could not save remote-image setting: {}" msgstr "No se pudo guardar el ajuste de imágenes remotas: {}" -#: ui/app.slint:305 +#: ui/app.slint:313 msgid "Remote sender icons unavailable: {}" msgstr "Los iconos remotos de los remitentes no están disponibles: {}" -#: ui/app.slint:306 +#: ui/app.slint:314 msgid "Remote images unavailable: {}" msgstr "Las imágenes remotas no están disponibles: {}" -#: ui/app.slint:307 +#: ui/app.slint:315 msgid "Remote body images and sender favicons enabled." msgstr "Imágenes remotas del cuerpo e iconos de remitentes activados." -#: ui/app.slint:308 +#: ui/app.slint:316 msgid "Remote images blocked." msgstr "Imágenes remotas bloqueadas." -#: ui/app.slint:309 +#: ui/app.slint:317 msgid "Could not refresh message images: {}" msgstr "No se pudieron actualizar las imágenes del mensaje: {}" -#: ui/app.slint:310 +#: ui/app.slint:318 msgid "Remote-image loading is disabled." msgstr "La carga de imágenes remotas está desactivada." -#: ui/app.slint:311 +#: ui/app.slint:319 msgid "Could not load message images: {}" msgstr "No se pudieron cargar las imágenes del mensaje: {}" -#: ui/app.slint:312 +#: ui/app.slint:320 msgid "Sync finished, refresh failed: {}" msgstr "La sincronización terminó, pero falló la actualización: {}" -#: ui/app.slint:313 +#: ui/app.slint:321 msgid "Sync complete." msgstr "Sincronización completada." -#: ui/app.slint:314 +#: ui/app.slint:322 msgid "Sync failed: {}" msgstr "Falló la sincronización: {}" -#: ui/app.slint:315 +#: ui/app.slint:323 msgid "Local mail data is unavailable; preview data is read-only." msgstr "" "Los datos de correo locales no están disponibles; los datos de vista previa " "son de solo lectura." -#: ui/app.slint:316 +#: ui/app.slint:324 msgid "Synchronizing accounts…" msgstr "Sincronizando cuentas…" -#: ui/app.slint:321 +#: ui/app.slint:329 msgid "Message action completed." msgstr "Acción del mensaje completada." -#: ui/app.slint:322 +#: ui/app.slint:330 msgid "Message action failed: {}" msgstr "Falló la acción del mensaje: {}" -#: ui/app.slint:323 +#: ui/app.slint:331 msgid "Message action failed: message is no longer available" msgstr "Falló la acción del mensaje: el mensaje ya no está disponible" -#: ui/app.slint:324 +#: ui/app.slint:332 msgid "Label added." msgstr "Etiqueta agregada." -#: ui/app.slint:325 +#: ui/app.slint:333 msgid "Label removed." msgstr "Etiqueta eliminada." -#: ui/app.slint:326 +#: ui/app.slint:334 msgid "Label created and added." msgstr "Etiqueta creada y agregada." -#: ui/app.slint:327 +#: ui/app.slint:335 msgid "Label updated." msgstr "Etiqueta actualizada." -#: ui/app.slint:328 +#: ui/app.slint:336 msgid "Label deleted." msgstr "Etiqueta eliminada." -#: ui/app.slint:329 +#: ui/app.slint:337 msgid "Could not update label: {}" msgstr "No se pudo actualizar la etiqueta: {}" -#: ui/app.slint:330 +#: ui/app.slint:338 msgid "Could not save label: {}" msgstr "No se pudo guardar la etiqueta: {}" -#: ui/app.slint:331 +#: ui/app.slint:339 msgid "Could not delete label: {}" msgstr "No se pudo eliminar la etiqueta: {}" -#: ui/app.slint:332 +#: ui/app.slint:340 msgid "Folder created." msgstr "Carpeta creada." -#: ui/app.slint:333 +#: ui/app.slint:341 msgid "Folder renamed." msgstr "Carpeta renombrada." -#: ui/app.slint:334 +#: ui/app.slint:342 msgid "Folder deleted." msgstr "Carpeta eliminada." -#: ui/app.slint:335 +#: ui/app.slint:343 msgid "Could not create folder: {}" msgstr "No se pudo crear la carpeta: {}" -#: ui/app.slint:336 +#: ui/app.slint:344 msgid "Could not rename folder: {}" msgstr "No se pudo renombrar la carpeta: {}" -#: ui/app.slint:337 +#: ui/app.slint:345 msgid "Could not delete folder: {}" msgstr "No se pudo eliminar la carpeta: {}" -#: ui/app.slint:338 +#: ui/app.slint:346 msgid "Could not close preview: {}" msgstr "No se pudo cerrar la vista previa: {}" -#: ui/app.slint:339 +#: ui/app.slint:347 msgid "Opened the system print view." msgstr "Se abrió la vista de impresión del sistema." -#: ui/app.slint:340 +#: ui/app.slint:348 msgid "Could not print message: {}" msgstr "No se pudo imprimir el mensaje: {}" -#: ui/app.slint:341 +#: ui/app.slint:349 msgid "Opened the message in your browser." msgstr "Se abrió el mensaje en el navegador." -#: ui/app.slint:342 +#: ui/app.slint:350 msgid "Could not open message in browser: {}" msgstr "No se pudo abrir el mensaje en el navegador: {}" -#: ui/app.slint:343 +#: ui/app.slint:351 msgid "Connect an account before replying or forwarding." msgstr "Conecta una cuenta antes de responder o reenviar." -#: ui/app.slint:344 +#: ui/app.slint:352 msgid "Choose a message first." msgstr "Elige primero un mensaje." -#: ui/app.slint:345 +#: ui/app.slint:353 msgid "Only drafts can be reopened for editing." msgstr "Solo se pueden volver a abrir borradores para editarlos." -#: ui/app.slint:346 +#: ui/app.slint:354 msgid "Could not open draft: {}" msgstr "No se pudo abrir el borrador: {}" -#: ui/app.slint:347 +#: ui/app.slint:355 msgid "Could not open draft: invalid account identifier" msgstr "" "No se pudo abrir el borrador: el identificador de la cuenta no es válido" -#: ui/app.slint:348 +#: ui/app.slint:356 msgid "Connected account" msgstr "Cuenta conectada" -#: ui/app.slint:349 +#: ui/app.slint:357 msgid "Could not open composer: {}" msgstr "No se pudo abrir el editor: {}" -#: ui/app.slint:350 +#: ui/app.slint:358 msgid "Could not open composer: invalid account identifier" msgstr "No se pudo abrir el editor: el identificador de la cuenta no es válido" -#: ui/app.slint:355 +#: ui/app.slint:363 msgid "Attach files to this email" msgstr "Adjuntar archivos a este correo" -#: ui/app.slint:356 +#: ui/app.slint:364 msgid "The platform host does not provide a document picker." msgstr "La plataforma no proporciona un selector de documentos." -#: ui/app.slint:357 +#: ui/app.slint:365 msgid "Added 1 attachment." msgstr "Se añadió 1 archivo adjunto." -#: ui/app.slint:358 +#: ui/app.slint:366 msgid "Added {} attachments." msgstr "Se añadieron {} archivos adjuntos." -#: ui/app.slint:359 +#: ui/app.slint:367 msgid "" "Attachments added: {}. Skipped: {} (duplicate, unreadable, or over the 25 MB " "total limit)." @@ -967,53 +967,77 @@ msgstr "" "Archivos adjuntos añadidos: {}. Omitidos: {} (duplicados, ilegibles o por " "superar el límite total de 25 MB)." -#: ui/app.slint:360 +#: ui/app.slint:368 msgid "Attachment removed." msgstr "Archivo adjunto eliminado." -#: ui/app.slint:361 +#: ui/app.slint:369 msgid "Choose the account this message should be sent from." msgstr "Elige la cuenta desde la que se debe enviar este mensaje." -#: ui/app.slint:362 +#: ui/app.slint:370 msgid "Connect an account before saving or sending mail." msgstr "Conecta una cuenta antes de guardar o enviar correo." -#: ui/app.slint:363 +#: ui/app.slint:371 msgid "Message queued for delivery (action {})." msgstr "Mensaje en cola para su entrega (acción {})." -#: ui/app.slint:364 +#: ui/app.slint:372 msgid "Draft saved locally (#{})." msgstr "Borrador guardado localmente (#{})." -#: ui/app.slint:365 +#: ui/app.slint:373 msgid "Compose failed: {}" msgstr "Falló la redacción: {}" -#: ui/app.slint:366 -msgid "IMAP port must be a number." -msgstr "El puerto IMAP debe ser un número." +#: ui/app.slint:374 +msgid "IMAP port must be between 1 and 65535." +msgstr "El puerto IMAP debe estar entre 1 y 65535." -#: ui/app.slint:367 -msgid "SMTP port must be a number." -msgstr "El puerto SMTP debe ser un número." +#: ui/app.slint:375 +msgid "SMTP port must be between 1 and 65535." +msgstr "El puerto SMTP debe estar entre 1 y 65535." -#: ui/app.slint:368 +#: ui/app.slint:376 msgid "Local mail data is unavailable. Retry startup." msgstr "" "Los datos de correo locales no están disponibles. Vuelve a intentar el " "inicio." -#: ui/app.slint:369 +#: ui/app.slint:377 msgid "Discovering JMAP and checking capabilities…" msgstr "Detectando JMAP y comprobando sus capacidades…" -#: ui/app.slint:370 -msgid "Testing IMAP connection…" -msgstr "Probando la conexión IMAP…" +#: ui/app.slint:378 +msgid "Certificate imported. Connect to verify the server." +msgstr "Certificado importado. Conecta para verificar el servidor." -#: ui/app.slint:375 +#: ui/app.slint:379 +msgid "Could not load account preferences: {}" +msgstr "No se pudieron cargar las preferencias de la cuenta: {}" + +#: ui/app.slint:380 +msgid "" +"Your edited signature was preserved. Choose No signature to keep it as " +"message text before inserting another." +msgstr "" +"Se conservó la firma que editaste. Elige Sin firma para mantenerla como " +"texto del mensaje antes de insertar otra." + +#: ui/app.slint:381 +msgid "Certificate import failed: {}" +msgstr "Error al importar el certificado: {}" + +#: ui/app.slint:382 +msgid "Paste the PEM certificate into the certificate field." +msgstr "Pega el certificado PEM en el campo del certificado." + +#: ui/app.slint:383 +msgid "Testing IMAP and SMTP connections…" +msgstr "Comprobando las conexiones IMAP y SMTP…" + +#: ui/app.slint:388 msgid "" "Another browser authorization is already in progress; finish it or choose " "Cancel." @@ -1021,101 +1045,337 @@ msgstr "" "Ya hay otra autorización del navegador en curso; termínala o selecciona " "Cancelar." -#: ui/app.slint:376 +#: ui/app.slint:389 msgid "Add app keys in Sign-in settings to enable this provider." msgstr "" "Añade claves de aplicación en los ajustes de inicio de sesión para habilitar " "este proveedor." -#: ui/app.slint:377 +#: ui/app.slint:390 msgid "Choose Gmail or Outlook OAuth." msgstr "Elige OAuth de Gmail o Outlook." -#: ui/app.slint:378 +#: ui/app.slint:391 msgid "Complete sign-in in your browser, or choose Cancel here." msgstr "" "Completa el inicio de sesión en el navegador o selecciona Cancelar aquí." -#: ui/app.slint:379 +#: ui/app.slint:392 msgid "Cancelling browser sign-in…" msgstr "Cancelando el inicio de sesión en el navegador…" -#: ui/app.slint:380 +#: ui/app.slint:393 +msgid "Finishing browser sign-in…" +msgstr "Finalizando el inicio de sesión en el navegador…" + +#: ui/app.slint:394 +msgid "Could not use that browser address: {}" +msgstr "No se pudo usar esa dirección del navegador: {}" + +#: ui/app.slint:395 msgid "Calendar core is unavailable." msgstr "El núcleo del calendario no está disponible." -#: ui/app.slint:381 +#: ui/app.slint:396 msgid "Account no longer exists." msgstr "La cuenta ya no existe." -#: ui/app.slint:382 +#: ui/app.slint:397 msgid "Authorize calendar access in your browser." msgstr "Autoriza el acceso al calendario en el navegador." -#: ui/app.slint:383 +#: ui/app.slint:398 msgid "OAuth app keys saved." msgstr "Claves de la aplicación OAuth guardadas." -#: ui/app.slint:384 +#: ui/app.slint:399 msgid "{} OAuth settings saved." msgstr "Ajustes de OAuth de {} guardados." -#: ui/app.slint:385 +#: ui/app.slint:400 msgid "OAuth settings failed: {}" msgstr "Fallaron los ajustes de OAuth: {}" -#: ui/app.slint:386 +#: ui/app.slint:401 msgid "Mail refresh failed: {}" msgstr "Falló la actualización del correo: {}" -#: ui/app.slint:387 +#: ui/app.slint:402 msgid "Mail filter failed: {}" msgstr "Falló el filtro de correo: {}" -#: ui/app.slint:388 +#: ui/app.slint:403 msgid "Mail pagination failed: {}" msgstr "Falló la paginación del correo: {}" -#: ui/app.slint:389 +#: ui/app.slint:404 msgid "Could not mark message as read: {}" msgstr "No se pudo marcar el mensaje como leído: {}" -#: ui/app.slint:390 ui/views/mail-reading-pane.slint:729 +#: ui/app.slint:405 ui/views/mail-reading-pane.slint:755 msgid "Loading message…" msgstr "Cargando mensaje…" -#: ui/app.slint:396 ui/dialogs/settings-dialog.slint:946 ui/tray.slint:9 -#: ui/views/onboarding-view.slint:80 +#: ui/app.slint:411 ui/dialogs/settings-dialog.slint:971 ui/tray.slint:9 +#: ui/views/onboarding-view.slint:97 msgid "Flectar Mail" msgstr "Flectar Mail" -#: ui/app.slint:1089 +#: ui/app.slint:1138 msgid "Showing {} of {} contacts" msgstr "Mostrando {} de {} contactos" -#: ui/app.slint:1548 +#: ui/app.slint:1629 msgid "Mailbox startup failed" msgstr "Error al iniciar el buzón" -#: ui/app.slint:1554 -msgid "Flectar Mail could not open local storage" -msgstr "Flectar Mail no pudo abrir el almacenamiento local" +#: ui/app.slint:1635 +msgid "Flectar Mail could not open local storage" +msgstr "Flectar Mail no pudo abrir el almacenamiento local" + +#: ui/app.slint:1648 +msgid "Diagnostic ID: {}" +msgstr "ID de diagnóstico: {}" + +#: ui/app.slint:1651 +msgid "Diagnostic ID {}" +msgstr "ID de diagnóstico {}" + +#: ui/app.slint:1656 +msgid "Retry" +msgstr "Reintentar" + +#: ui/app.slint:1664 +msgid "Copy diagnostics" +msgstr "Copiar datos de diagnóstico" + +#: ui/components/account-mail-preferences.slint:64 +msgid "Signatures & OpenPGP" +msgstr "Firmas y OpenPGP" + +#: ui/components/account-mail-preferences.slint:65 +msgid "Choose an account to manage your signature and message protection." +msgstr "" +"Elige una cuenta para gestionar tu firma y la protección de los mensajes." + +#: ui/components/account-mail-preferences.slint:76 +msgid "Signatures" +msgstr "Firmas" + +#: ui/components/account-mail-preferences.slint:77 +msgid "OpenPGP" +msgstr "OpenPGP" + +#: ui/components/account-mail-preferences.slint:82 +msgid "Edit signature" +msgstr "Editar firma" + +#: ui/components/account-mail-preferences.slint:82 +msgid "Saved separately for each account." +msgstr "Se guarda por separado para cada cuenta." + +#: ui/components/account-mail-preferences.slint:87 +msgid "New signature" +msgstr "Nueva firma" + +#: ui/components/account-mail-preferences.slint:88 +msgid "Signature name" +msgstr "Nombre de la firma" + +#: ui/components/account-mail-preferences.slint:88 +msgid "Signature name, for example Work" +msgstr "Nombre de la firma, por ejemplo Trabajo" + +#: ui/components/account-mail-preferences.slint:89 +msgid "" +"Your name\n" +"Role · Company\n" +"Phone · Website" +msgstr "" +"Tu nombre\n" +"Cargo · Empresa\n" +"Teléfono · Sitio web" + +#: ui/components/account-mail-preferences.slint:90 +msgid "" +"Plain text works in every email client. You can format the inserted " +"signature in the composer." +msgstr "" +"El texto sin formato funciona en todos los clientes de correo. Puedes dar " +"formato a la firma insertada en el editor." + +#: ui/components/account-mail-preferences.slint:92 +msgid "Save signature" +msgstr "Guardar firma" + +#: ui/components/account-mail-preferences.slint:93 +#: ui/views/contacts-view.slint:542 +msgid "Delete" +msgstr "Eliminar" + +#: ui/components/account-mail-preferences.slint:96 +msgid "New messages" +msgstr "Mensajes nuevos" + +#: ui/components/account-mail-preferences.slint:96 +msgid "Signature inserted when you start a message." +msgstr "Firma que se inserta al comenzar un mensaje." + +#: ui/components/account-mail-preferences.slint:102 +msgid "Replies & forwards" +msgstr "Respuestas y reenvíos" + +#: ui/components/account-mail-preferences.slint:102 +msgid "Placed above the quoted message." +msgstr "Se coloca encima del mensaje citado." + +#: ui/components/account-mail-preferences.slint:107 +msgid "Save signature defaults" +msgstr "Guardar firmas predeterminadas" + +#: ui/components/account-mail-preferences.slint:111 +msgid "" +"OpenPGP protects message bodies and attachments. Digital signatures let " +"recipients check who signed a message." +msgstr "" +"OpenPGP protege el contenido de los mensajes y los archivos adjuntos. Las " +"firmas digitales permiten comprobar quién firmó un mensaje." + +#: ui/components/account-mail-preferences.slint:112 +msgid "" +"Uses installed GnuPG 2.x and its secure key storage. Passphrases are entered " +"in GnuPG's unlock dialog." +msgstr "" +"Utiliza GnuPG 2.x instalado y su almacenamiento seguro de claves. Las frases " +"de contraseña se introducen en el diálogo de desbloqueo de GnuPG." + +#: ui/components/account-mail-preferences.slint:112 +msgid "" +"OpenPGP is unavailable on this mobile build. Protected accounts cannot send " +"without a supported encryption engine." +msgstr "" +"OpenPGP no está disponible en esta versión móvil. Las cuentas protegidas no " +"pueden enviar mensajes sin un motor de cifrado compatible." + +#: ui/components/account-mail-preferences.slint:113 +msgid "Create a new key" +msgstr "Crear una clave nueva" + +#: ui/components/account-mail-preferences.slint:114 +msgid "" +"New keys expire in two years. GnuPG asks you to protect the private key with " +"a passphrase." +msgstr "" +"Las claves nuevas caducan en dos años. GnuPG te pide que protejas la clave " +"privada con una frase de contraseña." + +#: ui/components/account-mail-preferences.slint:115 +msgid "Your full OpenPGP fingerprint" +msgstr "Tu huella digital OpenPGP completa" + +#: ui/components/account-mail-preferences.slint:115 +msgid "Your full OpenPGP key fingerprint" +msgstr "La huella digital completa de tu clave OpenPGP" + +#: ui/components/account-mail-preferences.slint:117 +msgid "Digitally sign messages" +msgstr "Firmar mensajes digitalmente" + +#: ui/components/account-mail-preferences.slint:117 +msgid "Proves possession of your private key; does not encrypt the message." +msgstr "Demuestra la posesión de tu clave privada; no cifra el mensaje." + +#: ui/components/account-mail-preferences.slint:122 +msgid "Require encryption" +msgstr "Exigir cifrado" + +#: ui/components/account-mail-preferences.slint:122 +msgid "Blocks sending unless every recipient has a verified, usable key." +msgstr "" +"Impide el envío si algún destinatario no tiene una clave verificada y " +"utilizable." + +#: ui/components/account-mail-preferences.slint:126 +msgid "Verified recipient keys" +msgstr "Claves verificadas de los destinatarios" + +#: ui/components/account-mail-preferences.slint:127 +msgid "" +"Compare fingerprints with recipients through a separate trusted channel, " +"then enter one email=fingerprint per line. Importing a key alone does not " +"verify it." +msgstr "" +"Compara las huellas digitales con los destinatarios por otro canal de " +"confianza y después introduce un correo=huella por línea. Importar una clave " +"no basta para verificarla." + +#: ui/components/account-mail-preferences.slint:128 +msgid "person@example.org=FULL_FINGERPRINT" +msgstr "persona@example.org=HUELLA_COMPLETA" + +#: ui/components/account-mail-preferences.slint:129 +msgid "Save OpenPGP settings" +msgstr "Guardar ajustes de OpenPGP" + +#: ui/components/account-mail-preferences.slint:131 +msgid "Import key" +msgstr "Importar clave" + +#: ui/components/account-mail-preferences.slint:132 +msgid "Export public key" +msgstr "Exportar clave pública" + +#: ui/components/account-mail-preferences.slint:133 +msgid "Show keys" +msgstr "Mostrar claves" + +#: ui/components/account-mail-preferences.slint:141 +msgid "Open message file (.eml)" +msgstr "Abrir archivo de mensaje (.eml)" + +#: ui/components/account-mail-preferences.slint:143 +msgid "" +"Manage expiry, revocation and private-key backups in GnuPG. Only public keys " +"are exported here." +msgstr "" +"Gestiona la caducidad, la revocación y las copias de seguridad de claves " +"privadas en GnuPG. Aquí solo se exportan claves públicas." + +#: ui/components/account-mail-preferences.slint:144 +msgid "" +"Subjects and addresses remain visible. Protected drafts stay on this device. " +"Existing server drafts are not retroactively protected. S/MIME certificates " +"are not supported." +msgstr "" +"Los asuntos y las direcciones siguen siendo visibles. Los borradores " +"protegidos permanecen en este dispositivo. Los borradores que ya existen en " +"el servidor no se protegen retroactivamente. No se admiten certificados S/" +"MIME." + +#: ui/components/account-mail-preferences.slint:147 +msgid "Working…" +msgstr "Procesando…" -#: ui/app.slint:1567 -msgid "Diagnostic ID: {}" -msgstr "ID de diagnóstico: {}" +#: ui/components/account-mail-preferences.slint:159 +msgid "OpenPGP message" +msgstr "Mensaje OpenPGP" -#: ui/app.slint:1570 -msgid "Diagnostic ID {}" -msgstr "ID de diagnóstico {}" +#: ui/components/account-mail-preferences.slint:162 +msgid "" +"Decrypted content is shown as text, without remote content. It is not added " +"to search or saved to the mail database." +msgstr "" +"El contenido descifrado se muestra como texto, sin contenido remoto. No se " +"añade a la búsqueda ni se guarda en la base de datos de correo." -#: ui/app.slint:1575 -msgid "Retry" -msgstr "Reintentar" +#: ui/components/account-mail-preferences.slint:163 +msgid "Export decrypted message" +msgstr "Exportar mensaje descifrado" -#: ui/app.slint:1583 -msgid "Copy diagnostics" -msgstr "Copiar datos de diagnóstico" +#: ui/components/account-mail-preferences.slint:164 +#: ui/views/files-view.slint:390 ui/views/files-view.slint:570 +msgid "Close" +msgstr "Cerrar" #: ui/components/calendar.slint:60 ui/components/calendar.slint:268 #: ui/components/calendar.slint:772 @@ -1180,8 +1440,8 @@ msgid "MY CALENDARS" msgstr "MIS CALENDARIOS" #: ui/components/calendar.slint:268 ui/components/calendar.slint:278 -#: ui/components/calendar.slint:317 ui/components/product-rail.slint:109 -#: ui/dialogs/settings-dialog.slint:709 +#: ui/components/calendar.slint:317 ui/components/product-rail.slint:111 +#: ui/dialogs/settings-dialog.slint:733 msgid "Calendar" msgstr "Calendario" @@ -1380,7 +1640,7 @@ msgstr "Añadir enlace" msgid "The selected text will open this address." msgstr "El texto seleccionado abrirá esta dirección." -#: ui/components/composer-actions.slint:348 ui/views/contacts-view.slint:700 +#: ui/components/composer-actions.slint:348 ui/views/contacts-view.slint:642 msgid "https://example.com" msgstr "https://ejemplo.com" @@ -1389,7 +1649,8 @@ msgid "Remove link" msgstr "Eliminar enlace" #: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 -#: ui/dialogs/settings-dialog.slint:774 ui/views/contacts-view.slint:596 +#: ui/dialogs/settings-dialog.slint:799 ui/views/contacts-view.slint:538 +#: ui/views/files-view.slint:534 msgid "Cancel" msgstr "Cancelar" @@ -1409,18 +1670,18 @@ msgstr "El mensaje y sus archivos adjuntos se eliminarán de este editor." msgid "Keep" msgstr "Conservar" -#: ui/components/composer-fields.slint:35 ui/views/mail-reading-pane.slint:541 -#: ui/views/mail-reading-pane.slint:1169 +#: ui/components/composer-fields.slint:35 ui/views/mail-reading-pane.slint:552 +#: ui/views/mail-reading-pane.slint:1227 msgid "Reply" msgstr "Responder" -#: ui/components/composer-fields.slint:36 ui/views/mail-reading-pane.slint:542 -#: ui/views/mail-reading-pane.slint:1186 +#: ui/components/composer-fields.slint:36 ui/views/mail-reading-pane.slint:553 +#: ui/views/mail-reading-pane.slint:1244 msgid "Reply all" msgstr "Responder a todos" -#: ui/components/composer-fields.slint:37 ui/views/mail-reading-pane.slint:543 -#: ui/views/mail-reading-pane.slint:1203 +#: ui/components/composer-fields.slint:37 ui/views/mail-reading-pane.slint:554 +#: ui/views/mail-reading-pane.slint:1261 msgid "Forward" msgstr "Reenviar" @@ -1432,10 +1693,6 @@ msgstr "Editar borrador" msgid "New message" msgstr "Nuevo mensaje" -#: ui/components/composer-fields.slint:55 -msgid "Close" -msgstr "Cerrar" - #: ui/components/composer-fields.slint:94 msgid "From" msgstr "De" @@ -1484,11 +1741,11 @@ msgstr "Confirmar" msgid "On" msgstr "Activado" -#: ui/components/controls.slint:160 ui/dialogs/settings-dialog.slint:114 +#: ui/components/controls.slint:160 ui/dialogs/settings-dialog.slint:127 msgid "Off" msgstr "Desactivado" -#: ui/components/controls.slint:185 ui/views/mail-list-pane.slint:251 +#: ui/components/controls.slint:185 ui/views/mail-list-pane.slint:197 msgid "Clear selection" msgstr "Limpiar selección" @@ -1496,7 +1753,23 @@ msgstr "Limpiar selección" msgid "Select" msgstr "Seleccionar" -#: ui/components/email-composer.slint:306 +#: ui/components/document-preview.slint:43 +msgid "Previous page" +msgstr "Página anterior" + +#: ui/components/document-preview.slint:44 +msgid "Page {} of {}" +msgstr "Página {} de {}" + +#: ui/components/document-preview.slint:45 +msgid "Next page" +msgstr "Página siguiente" + +#: ui/components/email-composer.slint:225 +msgid "Signature" +msgstr "Firma" + +#: ui/components/email-composer.slint:318 msgid "Write your message…" msgstr "Escribe tu mensaje…" @@ -1508,19 +1781,134 @@ msgstr "Abrir enlace" msgid "Copy link" msgstr "Copiar enlace" -#: ui/components/product-rail.slint:98 ui/components/product-rail.slint:245 +#: ui/components/mail-security-fields.slint:17 +msgid "Connection security" +msgstr "Seguridad de la conexión" + +#: ui/components/mail-security-fields.slint:27 +msgid "IMAP encryption" +msgstr "Cifrado IMAP" + +#: ui/components/mail-security-fields.slint:32 +msgid "Automatic" +msgstr "Automático" + +#: ui/components/mail-security-fields.slint:41 +msgid "SMTP encryption" +msgstr "Cifrado SMTP" + +#: ui/components/mail-security-fields.slint:53 +msgid "" +"Automatic: IMAP uses STARTTLS on 143, SSL/TLS otherwise. SMTP uses SSL/TLS " +"on 465, STARTTLS otherwise." +msgstr "" +"Automático: IMAP usa STARTTLS en el puerto 143 y SSL/TLS en los demás. SMTP " +"usa SSL/TLS en el 465 y STARTTLS en los demás." + +#: ui/components/mail-security-fields.slint:58 +msgid "Manage trusted certificate" +msgstr "Gestionar certificado de confianza" + +#: ui/components/mail-security-fields.slint:58 +msgid "Custom server certificate…" +msgstr "Certificado de servidor personalizado…" + +#: ui/components/mail-security-fields.slint:66 +msgid "" +"For Proton Bridge, export its certificate from Bridge settings and import it " +"here. Only trust certificates from your server administrator. Server names " +"and expiry dates are still checked." +msgstr "" +"Para Proton Bridge, exporta su certificado desde los ajustes de Bridge e " +"impórtalo aquí. Confía solo en certificados del administrador de tu " +"servidor. Se siguen comprobando los nombres del servidor y las fechas de " +"caducidad." + +#: ui/components/mail-security-fields.slint:69 +msgid "Import certificate file" +msgstr "Importar archivo de certificado" + +#: ui/components/mail-security-fields.slint:71 +msgid "PEM certificate only. Do not paste a private key." +msgstr "Solo certificados PEM. No pegues una clave privada." + +#: ui/components/mail-security-fields.slint:72 +msgid "Remove certificate" +msgstr "Eliminar certificado" + +#: ui/components/mail-security-fields.slint:75 +msgid "Custom certificate added for this account." +msgstr "Se ha añadido un certificado personalizado para esta cuenta." + +#: ui/components/oauth-browser-help.slint:42 +msgid "Browser did not open — show sign-in help" +msgstr "El navegador no se abrió — mostrar ayuda de inicio de sesión" + +#: ui/components/oauth-browser-help.slint:42 +msgid "Having trouble with browser sign-in?" +msgstr "¿Problemas con el inicio de sesión en el navegador?" + +#: ui/components/oauth-browser-help.slint:72 +msgid "" +"Flectar could not reach the system browser from this app environment. Open " +"the link again or copy it into a browser on this device." +msgstr "" +"Flectar no pudo acceder al navegador del sistema desde este entorno. Abre el " +"enlace de nuevo o cópialo en un navegador de este dispositivo." + +#: ui/components/oauth-browser-help.slint:72 +msgid "" +"Open the sign-in page again or copy its link into a browser on this device." +msgstr "" +"Abre de nuevo la página de inicio de sesión o copia su enlace en un " +"navegador de este dispositivo." + +#: ui/components/oauth-browser-help.slint:79 +msgid "Open browser" +msgstr "Abrir navegador" + +#: ui/components/oauth-browser-help.slint:80 +msgid "Copy sign-in link" +msgstr "Copiar enlace de inicio de sesión" + +#: ui/components/oauth-browser-help.slint:83 +msgid "" +"If the final browser page says localhost can’t be reached, copy its full " +"address and paste it below." +msgstr "" +"Si la página final del navegador indica que no se puede acceder a localhost, " +"copia la dirección completa y pégala abajo." + +#: ui/components/oauth-browser-help.slint:88 +msgid "Final browser address" +msgstr "Dirección final del navegador" + +#: ui/components/oauth-browser-help.slint:89 +msgid "Final localhost address" +msgstr "Dirección final de localhost" + +#: ui/components/oauth-browser-help.slint:100 +msgid "Finish sign-in" +msgstr "Finalizar inicio de sesión" + +#: ui/components/product-rail.slint:100 ui/components/product-rail.slint:259 msgid "Mail" msgstr "Correo" -#: ui/components/product-rail.slint:120 ui/components/product-rail.slint:267 +#: ui/components/product-rail.slint:122 ui/components/product-rail.slint:281 +#: ui/views/files-view.slint:456 +msgid "Files" +msgstr "Archivos" + +#: ui/components/product-rail.slint:133 ui/components/product-rail.slint:292 msgid "Contacts" msgstr "Contactos" -#: ui/components/product-rail.slint:133 ui/components/product-rail.slint:278 +#: ui/components/product-rail.slint:147 ui/components/product-rail.slint:303 msgid "Settings" msgstr "Ajustes" -#: ui/components/product-rail.slint:224 +#: ui/components/product-rail.slint:238 msgid "Primary navigation" msgstr "Navegación principal" @@ -1552,11 +1940,11 @@ msgstr "Renombrar etiqueta" msgid "Delete label" msgstr "Eliminar etiqueta" -#: ui/components/sidebar.slint:52 ui/views/mail-list-pane.slint:577 +#: ui/components/sidebar.slint:52 ui/views/mail-list-pane.slint:578 msgid "New email" msgstr "Nuevo correo" -#: ui/components/sidebar.slint:93 ui/views/contacts-view.slint:238 +#: ui/components/sidebar.slint:93 ui/views/contacts-view.slint:180 msgid "UNIFIED" msgstr "UNIFICADO" @@ -1580,15 +1968,16 @@ msgstr "Importante" msgid "Other" msgstr "Otros" -#: ui/components/sidebar.slint:254 ui/views/mail-reading-pane.slint:386 +#: ui/components/sidebar.slint:254 ui/views/mail-reading-pane.slint:397 msgid "Labels" msgstr "Etiquetas" -#: ui/components/sidebar.slint:302 ui/views/contacts-view.slint:238 +#: ui/components/sidebar.slint:302 ui/views/contacts-view.slint:180 msgid "ACCOUNTS" msgstr "CUENTAS" #: ui/components/sidebar.slint:386 ui/dialogs/folder-dialog.slint:25 +#: ui/views/files-view.slint:279 msgid "New folder" msgstr "Nueva carpeta" @@ -1637,7 +2026,7 @@ msgid "Palette" msgstr "Paleta" #: ui/components/theme-editor.slint:567 ui/components/theme-editor.slint:579 -#: ui/dialogs/settings-dialog.slint:343 +#: ui/dialogs/settings-dialog.slint:359 msgid "Default" msgstr "Predeterminado" @@ -1712,6 +2101,7 @@ msgid "Revert" msgstr "Revertir" #: ui/components/theme-editor.slint:692 ui/dialogs/folder-dialog.slint:122 +#: ui/views/mail-reading-pane.slint:1421 msgid "Save" msgstr "Guardar" @@ -1719,6 +2109,34 @@ msgstr "Guardar" msgid "Save palette" msgstr "Guardar paleta" +#: ui/components/zoom-controls.slint:19 +msgid "Zoom out" +msgstr "Alejar" + +#: ui/components/zoom-controls.slint:20 +msgid "Reset zoom ({}%)" +msgstr "Restablecer zoom ({}%)" + +#: ui/components/zoom-controls.slint:27 +msgid "Zoom percentage" +msgstr "Porcentaje de zoom" + +#: ui/components/zoom-controls.slint:31 +msgid "Zoom in" +msgstr "Acercar" + +#: ui/components/zoom-controls.slint:32 +msgid "Fit to width" +msgstr "Ajustar al ancho" + +#: ui/dialogs/attachment-preview-dialog.slint:30 ui/views/files-view.slint:397 +msgid "Download" +msgstr "Descargar" + +#: ui/dialogs/attachment-preview-dialog.slint:43 ui/views/files-view.slint:410 +msgid "Loading preview…" +msgstr "Cargando vista previa…" + #: ui/dialogs/calendar-details-dialog.slint:82 msgid "Close calendar details" msgstr "Cerrar detalles del calendario" @@ -1799,11 +2217,6 @@ msgstr "DESCRIPCIÓN" msgid "Open meeting link" msgstr "Abrir enlace de la reunión" -#: ui/dialogs/calendar-details-dialog.slint:209 -#: ui/views/contacts-view.slint:600 -msgid "Delete" -msgstr "Eliminar" - #: ui/dialogs/calendar-details-dialog.slint:219 msgid "Edit" msgstr "Editar" @@ -1836,7 +2249,7 @@ msgstr "FECHA" msgid "START TIME" msgstr "HORA DE INICIO" -#: ui/dialogs/calendar-event-dialog.slint:53 ui/views/contacts-view.slint:681 +#: ui/dialogs/calendar-event-dialog.slint:53 ui/views/contacts-view.slint:623 msgid "YYYY-MM-DD" msgstr "AAAA-MM-DD" @@ -1904,7 +2317,7 @@ msgstr "Crear en el nivel superior" msgid "Inside “{}”" msgstr "Dentro de “{}”" -#: ui/dialogs/oauth-setup-dialog.slint:27 ui/views/onboarding-view.slint:157 +#: ui/dialogs/oauth-setup-dialog.slint:27 ui/views/onboarding-view.slint:175 msgid "Sign-in settings" msgstr "Ajustes de inicio de sesión" @@ -1968,7 +2381,7 @@ msgstr "ID de cliente de escritorio de Google" msgid "Microsoft client ID" msgstr "ID de cliente de Microsoft" -#: ui/dialogs/oauth-setup-dialog.slint:95 ui/dialogs/settings-dialog.slint:762 +#: ui/dialogs/oauth-setup-dialog.slint:95 ui/dialogs/settings-dialog.slint:787 msgid "Microsoft Entra desktop client ID" msgstr "ID de cliente de escritorio de Microsoft Entra" @@ -2015,7 +2428,7 @@ msgid "Save settings" msgstr "Guardar ajustes" #: ui/dialogs/rendering-info-dialog.slint:25 -#: ui/views/mail-reading-pane.slint:1251 +#: ui/views/mail-reading-pane.slint:1309 msgid "About email rendering" msgstr "Acerca de la visualización de correos" @@ -2045,220 +2458,220 @@ msgstr "Copiar código fuente" msgid "Report issue" msgstr "Informar de un problema" -#: ui/dialogs/settings-dialog.slint:109 +#: ui/dialogs/settings-dialog.slint:122 msgid "Every minute" msgstr "Cada minuto" -#: ui/dialogs/settings-dialog.slint:110 +#: ui/dialogs/settings-dialog.slint:123 msgid "Every 5 minutes" msgstr "Cada 5 minutos" -#: ui/dialogs/settings-dialog.slint:111 +#: ui/dialogs/settings-dialog.slint:124 msgid "Every 15 minutes" msgstr "Cada 15 minutos" -#: ui/dialogs/settings-dialog.slint:115 +#: ui/dialogs/settings-dialog.slint:128 msgid "Important mail" msgstr "Correo importante" -#: ui/dialogs/settings-dialog.slint:116 +#: ui/dialogs/settings-dialog.slint:129 msgid "All new mail" msgstr "Todo el correo nuevo" -#: ui/dialogs/settings-dialog.slint:119 +#: ui/dialogs/settings-dialog.slint:132 msgid "1 month" msgstr "1 mes" -#: ui/dialogs/settings-dialog.slint:120 +#: ui/dialogs/settings-dialog.slint:133 msgid "3 months" msgstr "3 meses" -#: ui/dialogs/settings-dialog.slint:121 +#: ui/dialogs/settings-dialog.slint:134 msgid "6 months · Recommended" msgstr "6 meses · Recomendado" -#: ui/dialogs/settings-dialog.slint:122 +#: ui/dialogs/settings-dialog.slint:135 msgid "1 year" msgstr "1 año" -#: ui/dialogs/settings-dialog.slint:123 ui/views/mail-list-pane.slint:260 +#: ui/dialogs/settings-dialog.slint:136 ui/views/mail-list-pane.slint:208 msgid "All mail" msgstr "Todo el correo" -#: ui/dialogs/settings-dialog.slint:217 ui/dialogs/settings-dialog.slint:1087 +#: ui/dialogs/settings-dialog.slint:233 ui/dialogs/settings-dialog.slint:1112 msgid "SETTINGS" msgstr "AJUSTES" -#: ui/dialogs/settings-dialog.slint:219 ui/dialogs/settings-dialog.slint:246 +#: ui/dialogs/settings-dialog.slint:235 ui/dialogs/settings-dialog.slint:262 msgid "General" msgstr "General" -#: ui/dialogs/settings-dialog.slint:220 ui/dialogs/settings-dialog.slint:542 +#: ui/dialogs/settings-dialog.slint:236 ui/dialogs/settings-dialog.slint:558 msgid "Accounts" msgstr "Cuentas" -#: ui/dialogs/settings-dialog.slint:221 ui/dialogs/settings-dialog.slint:848 +#: ui/dialogs/settings-dialog.slint:237 ui/dialogs/settings-dialog.slint:873 msgid "Notifications" msgstr "Notificaciones" -#: ui/dialogs/settings-dialog.slint:222 ui/dialogs/settings-dialog.slint:1091 +#: ui/dialogs/settings-dialog.slint:238 ui/dialogs/settings-dialog.slint:1116 msgid "Shortcuts" msgstr "Atajos" -#: ui/dialogs/settings-dialog.slint:223 ui/dialogs/settings-dialog.slint:792 -#: ui/dialogs/settings-dialog.slint:1092 +#: ui/dialogs/settings-dialog.slint:239 ui/dialogs/settings-dialog.slint:817 +#: ui/dialogs/settings-dialog.slint:1117 msgid "Data management" msgstr "Gestión de datos" -#: ui/dialogs/settings-dialog.slint:224 ui/dialogs/settings-dialog.slint:922 +#: ui/dialogs/settings-dialog.slint:240 ui/dialogs/settings-dialog.slint:947 msgid "About" msgstr "Acerca de" -#: ui/dialogs/settings-dialog.slint:247 +#: ui/dialogs/settings-dialog.slint:263 msgid "Manage appearance, privacy, and synchronization." msgstr "Gestiona la apariencia, la privacidad y la sincronización." -#: ui/dialogs/settings-dialog.slint:293 +#: ui/dialogs/settings-dialog.slint:309 msgid "Interface" msgstr "Interfaz" -#: ui/dialogs/settings-dialog.slint:296 +#: ui/dialogs/settings-dialog.slint:312 msgid "Monochrome sidebar icons" msgstr "Iconos monocromos en la barra lateral" -#: ui/dialogs/settings-dialog.slint:297 +#: ui/dialogs/settings-dialog.slint:313 msgid "Use one neutral color for mailbox and folder icons." msgstr "Usa un único color neutro para los iconos de buzones y carpetas." -#: ui/dialogs/settings-dialog.slint:309 +#: ui/dialogs/settings-dialog.slint:325 msgid "No avatars" msgstr "Sin avatares" -#: ui/dialogs/settings-dialog.slint:310 +#: ui/dialogs/settings-dialog.slint:326 msgid "Hide sender images in the message list and reader." msgstr "" "Oculta las imágenes de los remitentes en la lista de mensajes y el lector." -#: ui/dialogs/settings-dialog.slint:323 ui/dialogs/settings-dialog.slint:339 +#: ui/dialogs/settings-dialog.slint:339 ui/dialogs/settings-dialog.slint:355 msgid "Workspace layout" msgstr "Diseño del espacio de trabajo" -#: ui/dialogs/settings-dialog.slint:330 ui/dialogs/settings-dialog.slint:344 +#: ui/dialogs/settings-dialog.slint:346 ui/dialogs/settings-dialog.slint:360 msgid "Minimal" msgstr "Mínimo" -#: ui/dialogs/settings-dialog.slint:362 +#: ui/dialogs/settings-dialog.slint:378 msgid "Language" msgstr "Idioma" -#: ui/dialogs/settings-dialog.slint:366 +#: ui/dialogs/settings-dialog.slint:382 msgid "System language" msgstr "Idioma del sistema" -#: ui/dialogs/settings-dialog.slint:367 +#: ui/dialogs/settings-dialog.slint:383 msgid "Spanish" msgstr "Español" -#: ui/dialogs/settings-dialog.slint:368 +#: ui/dialogs/settings-dialog.slint:384 msgid "English" msgstr "Inglés" -#: ui/dialogs/settings-dialog.slint:385 +#: ui/dialogs/settings-dialog.slint:401 msgid "Inbox behavior" msgstr "Comportamiento de la bandeja de entrada" -#: ui/dialogs/settings-dialog.slint:388 +#: ui/dialogs/settings-dialog.slint:404 msgid "Unified Inbox" msgstr "Bandeja unificada" -#: ui/dialogs/settings-dialog.slint:390 +#: ui/dialogs/settings-dialog.slint:406 msgid "DEFAULT" msgstr "PREDETERMINADO" -#: ui/dialogs/settings-dialog.slint:392 +#: ui/dialogs/settings-dialog.slint:408 msgid "Combines incoming messages from every connected account." msgstr "Combina los mensajes entrantes de todas las cuentas conectadas." -#: ui/dialogs/settings-dialog.slint:401 +#: ui/dialogs/settings-dialog.slint:417 msgid "Mark messages as read when opened" msgstr "Marcar los mensajes como leídos al abrirlos" -#: ui/dialogs/settings-dialog.slint:402 +#: ui/dialogs/settings-dialog.slint:418 msgid "Turn this off to keep unread status until you change it manually." msgstr "" "Desactiva esta opción para mantenerlos sin leer hasta que los cambies " "manualmente." -#: ui/dialogs/settings-dialog.slint:429 +#: ui/dialogs/settings-dialog.slint:445 msgid "Remote images" msgstr "Imágenes remotas" -#: ui/dialogs/settings-dialog.slint:430 +#: ui/dialogs/settings-dialog.slint:446 msgid "Load sender favicons and images embedded in message bodies." msgstr "" "Carga los iconos de los remitentes y las imágenes incluidas en los mensajes." -#: ui/dialogs/settings-dialog.slint:430 +#: ui/dialogs/settings-dialog.slint:446 msgid "Unavailable in this minimal build." msgstr "No disponible en esta compilación mínima." -#: ui/dialogs/settings-dialog.slint:431 +#: ui/dialogs/settings-dialog.slint:447 msgid "Off by default to block tracking pixels and minimize memory use." msgstr "" "Desactivadas de forma predeterminada para bloquear píxeles de seguimiento y " "reducir el uso de memoria." -#: ui/dialogs/settings-dialog.slint:457 +#: ui/dialogs/settings-dialog.slint:473 msgid "Keep running in system tray" msgstr "Seguir ejecutándose en la bandeja del sistema" -#: ui/dialogs/settings-dialog.slint:458 +#: ui/dialogs/settings-dialog.slint:474 msgid "Closing the window hides Flectar Mail while mail continues syncing." msgstr "" "Al cerrar la ventana, Flectar Mail se oculta mientras el correo sigue " "sincronizándose." -#: ui/dialogs/settings-dialog.slint:459 +#: ui/dialogs/settings-dialog.slint:475 msgid "Disabled by default; Quit from the tray menu stops the app." msgstr "" "Desactivado de forma predeterminada; Salir en el menú de la bandeja detiene " "la aplicación." -#: ui/dialogs/settings-dialog.slint:484 +#: ui/dialogs/settings-dialog.slint:500 msgid "Sync and notifications" msgstr "Sincronización y notificaciones" -#: ui/dialogs/settings-dialog.slint:487 +#: ui/dialogs/settings-dialog.slint:503 msgid "Check for new mail" msgstr "Buscar correo nuevo" -#: ui/dialogs/settings-dialog.slint:488 +#: ui/dialogs/settings-dialog.slint:504 msgid "How often to refresh when an instant provider update is unavailable." msgstr "" "Con qué frecuencia actualizar cuando no haya una actualización instantánea " "del proveedor." -#: ui/dialogs/settings-dialog.slint:498 ui/dialogs/settings-dialog.slint:865 +#: ui/dialogs/settings-dialog.slint:514 ui/dialogs/settings-dialog.slint:890 msgid "Desktop notifications" msgstr "Notificaciones de escritorio" -#: ui/dialogs/settings-dialog.slint:499 ui/dialogs/settings-dialog.slint:866 +#: ui/dialogs/settings-dialog.slint:515 ui/dialogs/settings-dialog.slint:891 msgid "Choose which incoming messages can alert you." msgstr "Elige qué mensajes entrantes pueden enviarte alertas." -#: ui/dialogs/settings-dialog.slint:512 +#: ui/dialogs/settings-dialog.slint:528 msgid "Provider synchronization is ready." msgstr "La sincronización del proveedor está lista." -#: ui/dialogs/settings-dialog.slint:520 ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:536 ui/dialogs/settings-dialog.slint:694 msgid "Syncing…" msgstr "Sincronizando…" -#: ui/dialogs/settings-dialog.slint:520 +#: ui/dialogs/settings-dialog.slint:536 msgid "Sync now" msgstr "Sincronizar ahora" -#: ui/dialogs/settings-dialog.slint:543 +#: ui/dialogs/settings-dialog.slint:559 msgid "" "Manage connected mailboxes and add providers. Drag an account to change its " "order everywhere." @@ -2266,67 +2679,67 @@ msgstr "" "Gestiona los buzones conectados y añade proveedores. Arrastra una cuenta " "para cambiar su orden en todas partes." -#: ui/dialogs/settings-dialog.slint:563 ui/dialogs/settings-dialog.slint:607 +#: ui/dialogs/settings-dialog.slint:578 ui/dialogs/settings-dialog.slint:622 msgid "Add Gmail" msgstr "Añadir Gmail" -#: ui/dialogs/settings-dialog.slint:575 ui/dialogs/settings-dialog.slint:608 +#: ui/dialogs/settings-dialog.slint:590 ui/dialogs/settings-dialog.slint:623 msgid "Add Outlook" msgstr "Añadir Outlook" -#: ui/dialogs/settings-dialog.slint:587 ui/dialogs/settings-dialog.slint:609 +#: ui/dialogs/settings-dialog.slint:602 ui/dialogs/settings-dialog.slint:624 msgid "Add mail account" msgstr "Añadir cuenta de correo" -#: ui/dialogs/settings-dialog.slint:600 ui/dialogs/settings-dialog.slint:610 +#: ui/dialogs/settings-dialog.slint:615 ui/dialogs/settings-dialog.slint:625 msgid "Cancel sign-in" msgstr "Cancelar inicio de sesión" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Offline · retrying automatically" msgstr "Sin conexión · reintentando automáticamente" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Sign-in required" msgstr "Se requiere iniciar sesión" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Connected · JMAP" msgstr "Conectado · JMAP" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Connected · IMAP + SMTP" msgstr "Conectado · IMAP + SMTP" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Account status: {}" msgstr "Estado de la cuenta: {}" -#: ui/dialogs/settings-dialog.slint:677 +#: ui/dialogs/settings-dialog.slint:698 msgid "Mail synchronization error: {}" msgstr "Error de sincronización del correo: {}" -#: ui/dialogs/settings-dialog.slint:686 +#: ui/dialogs/settings-dialog.slint:707 msgid "Reconnect" msgstr "Volver a conectar" -#: ui/dialogs/settings-dialog.slint:716 +#: ui/dialogs/settings-dialog.slint:740 msgid "Add calendar" msgstr "Añadir calendario" -#: ui/dialogs/settings-dialog.slint:716 +#: ui/dialogs/settings-dialog.slint:740 msgid "Retry calendar" msgstr "Reintentar calendario" -#: ui/dialogs/settings-dialog.slint:723 +#: ui/dialogs/settings-dialog.slint:747 msgid "Set up CalDAV" msgstr "Configurar CalDAV" -#: ui/dialogs/settings-dialog.slint:742 +#: ui/dialogs/settings-dialog.slint:766 msgid "Mail history" msgstr "Historial de correo" -#: ui/dialogs/settings-dialog.slint:744 +#: ui/dialogs/settings-dialog.slint:768 msgid "" "Download the full mailbox. Large accounts can take considerable time and " "storage." @@ -2334,66 +2747,66 @@ msgstr "" "Descarga todo el buzón. Las cuentas grandes pueden requerir bastante tiempo " "y almacenamiento." -#: ui/dialogs/settings-dialog.slint:745 +#: ui/dialogs/settings-dialog.slint:769 msgid "How far back to download. Mail already stored on this device is kept." msgstr "" "Hasta qué fecha descargar. Se conserva el correo ya almacenado en este " "dispositivo." -#: ui/dialogs/settings-dialog.slint:756 +#: ui/dialogs/settings-dialog.slint:781 msgid "OAuth app credentials" msgstr "Credenciales de la aplicación OAuth" -#: ui/dialogs/settings-dialog.slint:756 +#: ui/dialogs/settings-dialog.slint:781 msgid "Custom desktop app credentials are configured." msgstr "Las credenciales personalizadas de escritorio están configuradas." -#: ui/dialogs/settings-dialog.slint:756 +#: ui/dialogs/settings-dialog.slint:781 msgid "Advanced: use your own Gmail or Microsoft desktop app registration." msgstr "" "Avanzado: usa tu propio registro de aplicación de escritorio de Gmail o " "Microsoft." -#: ui/dialogs/settings-dialog.slint:757 +#: ui/dialogs/settings-dialog.slint:782 msgid "Hide" msgstr "Ocultar" -#: ui/dialogs/settings-dialog.slint:757 +#: ui/dialogs/settings-dialog.slint:782 msgid "Configure" msgstr "Configurar" -#: ui/dialogs/settings-dialog.slint:760 ui/dialogs/settings-dialog.slint:761 +#: ui/dialogs/settings-dialog.slint:785 ui/dialogs/settings-dialog.slint:786 msgid "Gmail desktop client ID" msgstr "ID de cliente de escritorio de Gmail" -#: ui/dialogs/settings-dialog.slint:760 ui/dialogs/settings-dialog.slint:761 +#: ui/dialogs/settings-dialog.slint:785 ui/dialogs/settings-dialog.slint:786 msgid "Gmail client secret (optional)" msgstr "Secreto de cliente de Gmail (opcional)" -#: ui/dialogs/settings-dialog.slint:760 +#: ui/dialogs/settings-dialog.slint:785 msgid "Save Gmail" msgstr "Guardar Gmail" -#: ui/dialogs/settings-dialog.slint:761 +#: ui/dialogs/settings-dialog.slint:786 msgid "Save Gmail settings" msgstr "Guardar ajustes de Gmail" -#: ui/dialogs/settings-dialog.slint:762 +#: ui/dialogs/settings-dialog.slint:787 msgid "Save Outlook" msgstr "Guardar Outlook" -#: ui/dialogs/settings-dialog.slint:763 +#: ui/dialogs/settings-dialog.slint:788 msgid "" "Outlook public desktop clients use PKCE and do not need a client secret." msgstr "" "Los clientes públicos de escritorio de Outlook usan PKCE y no necesitan un " "secreto de cliente." -#: ui/dialogs/settings-dialog.slint:769 +#: ui/dialogs/settings-dialog.slint:794 msgid "Connect CalDAV" msgstr "Conectar CalDAV" -#: ui/dialogs/settings-dialog.slint:769 +#: ui/dialogs/settings-dialog.slint:794 msgid "" "Works with iCloud, Fastmail, Nextcloud, and standards-compliant CalDAV " "servers." @@ -2401,23 +2814,23 @@ msgstr "" "Funciona con iCloud, Fastmail, Nextcloud y servidores CalDAV compatibles con " "los estándares." -#: ui/dialogs/settings-dialog.slint:772 +#: ui/dialogs/settings-dialog.slint:797 msgid "https://calendar.example.com/" msgstr "https://calendario.ejemplo.com/" -#: ui/dialogs/settings-dialog.slint:773 ui/dialogs/settings-dialog.slint:1170 +#: ui/dialogs/settings-dialog.slint:798 ui/dialogs/settings-dialog.slint:1201 msgid "Username" msgstr "Nombre de usuario" -#: ui/dialogs/settings-dialog.slint:773 +#: ui/dialogs/settings-dialog.slint:798 msgid "App password" msgstr "Contraseña de aplicación" -#: ui/dialogs/settings-dialog.slint:774 +#: ui/dialogs/settings-dialog.slint:799 ui/views/files-view.slint:586 msgid "Connect" msgstr "Conectar" -#: ui/dialogs/settings-dialog.slint:795 +#: ui/dialogs/settings-dialog.slint:820 msgid "" "Transfer your setup, export a full snapshot, or remove local data from this " "device." @@ -2425,15 +2838,15 @@ msgstr "" "Transfiere tu configuración, exporta una instantánea completa o elimina los " "datos locales de este dispositivo." -#: ui/dialogs/settings-dialog.slint:795 +#: ui/dialogs/settings-dialog.slint:820 msgid "Remove local data from this device." msgstr "Elimina los datos locales de este dispositivo." -#: ui/dialogs/settings-dialog.slint:806 +#: ui/dialogs/settings-dialog.slint:831 msgid "Database snapshot" msgstr "Instantánea de la base de datos" -#: ui/dialogs/settings-dialog.slint:806 +#: ui/dialogs/settings-dialog.slint:831 msgid "" "Export verified copies of the mail and calendar databases with their schema " "versions." @@ -2441,7 +2854,7 @@ msgstr "" "Exporta copias verificadas de las bases de datos de correo y calendario con " "sus versiones de esquema." -#: ui/dialogs/settings-dialog.slint:808 +#: ui/dialogs/settings-dialog.slint:833 msgid "" "The snapshot contains private local mail and settings, but not passwords or " "OAuth tokens. Store it securely." @@ -2449,15 +2862,15 @@ msgstr "" "La instantánea contiene correo local y ajustes privados, pero no contraseñas " "ni tokens OAuth. Guárdala de forma segura." -#: ui/dialogs/settings-dialog.slint:809 +#: ui/dialogs/settings-dialog.slint:834 msgid "Export snapshot" msgstr "Exportar instantánea" -#: ui/dialogs/settings-dialog.slint:816 +#: ui/dialogs/settings-dialog.slint:841 msgid "Transfer account setup" msgstr "Transferir configuración de cuentas" -#: ui/dialogs/settings-dialog.slint:816 +#: ui/dialogs/settings-dialog.slint:841 msgid "Import a backup or export one connected account and your preferences." msgid_plural "" "Import a backup or export {n} connected accounts and your preferences." @@ -2468,7 +2881,7 @@ msgstr[1] "" "Importa una copia de seguridad o exporta {n} cuentas conectadas y tus " "preferencias." -#: ui/dialogs/settings-dialog.slint:818 +#: ui/dialogs/settings-dialog.slint:843 msgid "" "Passwords and OAuth tokens stay in the system keyring. A restored device " "asks you to sign in securely." @@ -2476,19 +2889,19 @@ msgstr "" "Las contraseñas y los tokens OAuth permanecen en el llavero del sistema. Un " "dispositivo restaurado te pedirá que inicies sesión de forma segura." -#: ui/dialogs/settings-dialog.slint:825 +#: ui/dialogs/settings-dialog.slint:850 msgid "Import" msgstr "Importar" -#: ui/dialogs/settings-dialog.slint:826 +#: ui/dialogs/settings-dialog.slint:851 msgid "Export" msgstr "Exportar" -#: ui/dialogs/settings-dialog.slint:833 +#: ui/dialogs/settings-dialog.slint:858 msgid "Delete all local data" msgstr "Eliminar todos los datos locales" -#: ui/dialogs/settings-dialog.slint:833 +#: ui/dialogs/settings-dialog.slint:858 msgid "" "Remove accounts, downloaded mail, calendars, contacts, preferences, and " "credentials from this device." @@ -2496,125 +2909,125 @@ msgstr "" "Elimina de este dispositivo las cuentas, el correo descargado, los " "calendarios, los contactos, las preferencias y las credenciales." -#: ui/dialogs/settings-dialog.slint:835 +#: ui/dialogs/settings-dialog.slint:860 msgid "This does not delete anything from Gmail, Outlook, or your IMAP server." msgstr "Esto no elimina nada de Gmail, Outlook ni de tu servidor IMAP." -#: ui/dialogs/settings-dialog.slint:836 ui/dialogs/settings-dialog.slint:1236 +#: ui/dialogs/settings-dialog.slint:861 ui/dialogs/settings-dialog.slint:1285 msgid "Delete everything" msgstr "Eliminar todo" -#: ui/dialogs/settings-dialog.slint:849 +#: ui/dialogs/settings-dialog.slint:874 msgid "Choose how Flectar Mail gets your attention." msgstr "Elige cómo Flectar Mail llama tu atención." -#: ui/dialogs/settings-dialog.slint:876 +#: ui/dialogs/settings-dialog.slint:901 msgid "Notification sounds" msgstr "Sonidos de notificación" -#: ui/dialogs/settings-dialog.slint:877 +#: ui/dialogs/settings-dialog.slint:902 msgid "Play a sound for new mail and sent messages." msgstr "Reproduce un sonido al recibir correo y al enviar mensajes." -#: ui/dialogs/settings-dialog.slint:896 +#: ui/dialogs/settings-dialog.slint:921 msgid "Keyboard shortcuts" msgstr "Atajos de teclado" -#: ui/dialogs/settings-dialog.slint:897 +#: ui/dialogs/settings-dialog.slint:922 msgid "Move through mail without leaving the keyboard." msgstr "Muévete por el correo sin apartar las manos del teclado." -#: ui/dialogs/settings-dialog.slint:900 +#: ui/dialogs/settings-dialog.slint:925 msgid "Compose a new message" msgstr "Redactar un mensaje nuevo" -#: ui/dialogs/settings-dialog.slint:900 +#: ui/dialogs/settings-dialog.slint:925 msgid "C" msgstr "C" -#: ui/dialogs/settings-dialog.slint:901 ui/views/mail-list-pane.slint:258 +#: ui/dialogs/settings-dialog.slint:926 ui/views/mail-list-pane.slint:206 msgid "Search mail" msgstr "Buscar correo" -#: ui/dialogs/settings-dialog.slint:901 +#: ui/dialogs/settings-dialog.slint:926 msgid "/" msgstr "/" -#: ui/dialogs/settings-dialog.slint:902 +#: ui/dialogs/settings-dialog.slint:927 msgid "Archive selected message" msgstr "Archivar el mensaje seleccionado" -#: ui/dialogs/settings-dialog.slint:902 +#: ui/dialogs/settings-dialog.slint:927 msgid "E" msgstr "E" -#: ui/dialogs/settings-dialog.slint:903 +#: ui/dialogs/settings-dialog.slint:928 msgid "Mark as spam" msgstr "Marcar como spam" -#: ui/dialogs/settings-dialog.slint:903 +#: ui/dialogs/settings-dialog.slint:928 msgid "!" msgstr "!" -#: ui/dialogs/settings-dialog.slint:904 ui/views/mail-list-pane.slint:537 -#: ui/views/mail-reading-pane.slint:564 +#: ui/dialogs/settings-dialog.slint:929 ui/views/mail-list-pane.slint:538 +#: ui/views/mail-reading-pane.slint:576 msgid "Move to trash" msgstr "Mover a la papelera" -#: ui/dialogs/settings-dialog.slint:904 +#: ui/dialogs/settings-dialog.slint:929 msgid "#" msgstr "#" -#: ui/dialogs/settings-dialog.slint:923 +#: ui/dialogs/settings-dialog.slint:948 msgid "Flectar Mail, its makers, and its community." msgstr "Flectar Mail, sus creadores y su comunidad." -#: ui/dialogs/settings-dialog.slint:939 ui/views/onboarding-view.slint:74 +#: ui/dialogs/settings-dialog.slint:964 ui/views/onboarding-view.slint:91 msgid "Flectar Mail logo" msgstr "Logotipo de Flectar Mail" -#: ui/dialogs/settings-dialog.slint:959 +#: ui/dialogs/settings-dialog.slint:984 msgid "A native mail client for a calm, private inbox." msgstr "" "Un cliente de correo nativo para una bandeja de entrada tranquila y privada." -#: ui/dialogs/settings-dialog.slint:971 +#: ui/dialogs/settings-dialog.slint:996 msgid "Version 0.1.0" msgstr "Versión 0.1.0" -#: ui/dialogs/settings-dialog.slint:982 +#: ui/dialogs/settings-dialog.slint:1007 msgid "Credits" msgstr "Créditos" -#: ui/dialogs/settings-dialog.slint:982 +#: ui/dialogs/settings-dialog.slint:1007 msgid "Built by Flectar with help from the open-source community." msgstr "Creado por Flectar con la ayuda de la comunidad de código abierto." -#: ui/dialogs/settings-dialog.slint:990 +#: ui/dialogs/settings-dialog.slint:1015 msgid "Flectar" msgstr "Flectar" -#: ui/dialogs/settings-dialog.slint:991 +#: ui/dialogs/settings-dialog.slint:1016 msgid "Company website" msgstr "Sitio web de la empresa" -#: ui/dialogs/settings-dialog.slint:1001 +#: ui/dialogs/settings-dialog.slint:1026 msgid "Open-source contributors" msgstr "Colaboradores de código abierto" -#: ui/dialogs/settings-dialog.slint:1002 +#: ui/dialogs/settings-dialog.slint:1027 msgid "Meet the people who have contributed on GitHub" msgstr "Conoce a las personas que han contribuido en GitHub" -#: ui/dialogs/settings-dialog.slint:1015 +#: ui/dialogs/settings-dialog.slint:1040 msgid "License" msgstr "Licencia" -#: ui/dialogs/settings-dialog.slint:1015 +#: ui/dialogs/settings-dialog.slint:1040 msgid "AGPL-3.0-only." msgstr "Solo AGPL-3.0." -#: ui/dialogs/settings-dialog.slint:1015 +#: ui/dialogs/settings-dialog.slint:1040 msgid "" "Flectar Mail is released under the GNU Affero General Public License v3.0 " "only." @@ -2622,119 +3035,133 @@ msgstr "" "Flectar Mail se publica únicamente bajo la GNU Affero General Public License " "v3.0." -#: ui/dialogs/settings-dialog.slint:1021 +#: ui/dialogs/settings-dialog.slint:1046 msgid "Third-party components retain their own licenses and notices." msgstr "Los componentes de terceros conservan sus propias licencias y avisos." -#: ui/dialogs/settings-dialog.slint:1042 +#: ui/dialogs/settings-dialog.slint:1067 msgid "Settings sections" msgstr "Secciones de ajustes" -#: ui/dialogs/settings-dialog.slint:1054 +#: ui/dialogs/settings-dialog.slint:1079 msgid "Close settings" msgstr "Cerrar ajustes" -#: ui/dialogs/settings-dialog.slint:1071 +#: ui/dialogs/settings-dialog.slint:1096 msgid "Close settings sections" msgstr "Cerrar secciones de ajustes" -#: ui/dialogs/settings-dialog.slint:1106 +#: ui/dialogs/settings-dialog.slint:1131 msgid "Edit mail account" msgstr "Editar cuenta de correo" -#: ui/dialogs/settings-dialog.slint:1106 ui/dialogs/settings-dialog.slint:1151 +#: ui/dialogs/settings-dialog.slint:1131 ui/dialogs/settings-dialog.slint:1179 msgid "Connect a mail account" msgstr "Conectar una cuenta de correo" -#: ui/dialogs/settings-dialog.slint:1151 +#: ui/dialogs/settings-dialog.slint:1179 msgid "Edit {}" msgstr "Editar {}" -#: ui/dialogs/settings-dialog.slint:1152 +#: ui/dialogs/settings-dialog.slint:1180 msgid "Uses JMAP for mail sync and sending, with automatic discovery." msgstr "Usa JMAP para sincronizar y enviar correo, con detección automática." -#: ui/dialogs/settings-dialog.slint:1152 +#: ui/dialogs/settings-dialog.slint:1180 msgid "Uses separate IMAP and SMTP servers for mail sync and sending." msgstr "Usa servidores IMAP y SMTP separados para sincronizar y enviar correo." -#: ui/dialogs/settings-dialog.slint:1169 ui/dialogs/settings-dialog.slint:1175 +#: ui/dialogs/settings-dialog.slint:1200 ui/dialogs/settings-dialog.slint:1207 +#: ui/views/onboarding-view.slint:234 msgid "Email address" msgstr "Dirección de correo" -#: ui/dialogs/settings-dialog.slint:1169 ui/dialogs/settings-dialog.slint:1175 +#: ui/dialogs/settings-dialog.slint:1200 ui/dialogs/settings-dialog.slint:1207 msgid "name@example.com" msgstr "nombre@ejemplo.com" -#: ui/dialogs/settings-dialog.slint:1170 ui/dialogs/settings-dialog.slint:1176 +#: ui/dialogs/settings-dialog.slint:1201 ui/dialogs/settings-dialog.slint:1208 msgid "Usually your email address" msgstr "Normalmente, tu dirección de correo" -#: ui/dialogs/settings-dialog.slint:1178 ui/views/onboarding-view.slint:212 +#: ui/dialogs/settings-dialog.slint:1210 ui/views/onboarding-view.slint:249 msgid "Password or app password" msgstr "Contraseña o contraseña de aplicación" -#: ui/dialogs/settings-dialog.slint:1178 +#: ui/dialogs/settings-dialog.slint:1210 msgid "Stored securely in your system keyring" msgstr "Guardada de forma segura en el llavero del sistema" -#: ui/dialogs/settings-dialog.slint:1182 ui/views/onboarding-view.slint:221 +#: ui/dialogs/settings-dialog.slint:1215 ui/views/onboarding-view.slint:263 msgid "IMAP + SMTP" msgstr "IMAP + SMTP" -#: ui/dialogs/settings-dialog.slint:1183 ui/views/onboarding-view.slint:228 +#: ui/dialogs/settings-dialog.slint:1216 ui/views/onboarding-view.slint:271 msgid "JMAP" msgstr "JMAP" -#: ui/dialogs/settings-dialog.slint:1187 +#: ui/dialogs/settings-dialog.slint:1220 msgid "Server address · Optional" msgstr "Dirección del servidor · Opcional" -#: ui/dialogs/settings-dialog.slint:1189 +#: ui/dialogs/settings-dialog.slint:1222 msgid "Discovered from your email domain" msgstr "Se detecta a partir de tu dominio de correo" -#: ui/dialogs/settings-dialog.slint:1196 ui/views/onboarding-view.slint:251 +#: ui/dialogs/settings-dialog.slint:1230 ui/views/onboarding-view.slint:296 msgid "IMAP server" msgstr "Servidor IMAP" -#: ui/dialogs/settings-dialog.slint:1196 +#: ui/dialogs/settings-dialog.slint:1230 msgid "imap.example.com" msgstr "imap.ejemplo.com" -#: ui/dialogs/settings-dialog.slint:1197 ui/dialogs/settings-dialog.slint:1203 +#: ui/dialogs/settings-dialog.slint:1231 ui/dialogs/settings-dialog.slint:1238 +#: ui/views/onboarding-view.slint:329 msgid "Port" msgstr "Puerto" -#: ui/dialogs/settings-dialog.slint:1197 ui/views/onboarding-view.slint:258 +#: ui/dialogs/settings-dialog.slint:1231 ui/views/onboarding-view.slint:309 msgid "993" msgstr "993" -#: ui/dialogs/settings-dialog.slint:1202 ui/views/onboarding-view.slint:268 +#: ui/dialogs/settings-dialog.slint:1237 ui/views/onboarding-view.slint:320 msgid "SMTP server" msgstr "Servidor SMTP" -#: ui/dialogs/settings-dialog.slint:1202 +#: ui/dialogs/settings-dialog.slint:1237 msgid "smtp.example.com" msgstr "smtp.ejemplo.com" -#: ui/dialogs/settings-dialog.slint:1203 ui/views/onboarding-view.slint:275 +#: ui/dialogs/settings-dialog.slint:1238 ui/views/onboarding-view.slint:333 msgid "465" msgstr "465" -#: ui/dialogs/settings-dialog.slint:1215 +#: ui/dialogs/settings-dialog.slint:1241 ui/views/onboarding-view.slint:338 +msgid "" +"Proton Bridge: use the local servers, ports and password shown in Bridge, " +"and match its encryption settings." +msgstr "" +"Proton Bridge: usa los servidores locales, puertos y contraseña que aparecen " +"en Bridge, con los mismos ajustes de cifrado." + +#: ui/dialogs/settings-dialog.slint:1265 ui/views/onboarding-view.slint:351 +msgid "Connecting…" +msgstr "Conectando…" + +#: ui/dialogs/settings-dialog.slint:1265 msgid "Save account" msgstr "Guardar cuenta" -#: ui/dialogs/settings-dialog.slint:1215 ui/views/onboarding-view.slint:282 +#: ui/dialogs/settings-dialog.slint:1265 ui/views/onboarding-view.slint:351 msgid "Connect account" msgstr "Conectar cuenta" -#: ui/dialogs/settings-dialog.slint:1233 +#: ui/dialogs/settings-dialog.slint:1282 msgid "Delete all local data?" msgstr "¿Eliminar todos los datos locales?" -#: ui/dialogs/settings-dialog.slint:1234 +#: ui/dialogs/settings-dialog.slint:1283 msgid "" "This removes every account, downloaded message, calendar, contact, " "preference, and credential stored by Flectar Mail on this device." @@ -2743,14 +3170,14 @@ msgstr "" "contactos, preferencias y credenciales que Flectar Mail almacena en este " "dispositivo." -#: ui/dialogs/settings-dialog.slint:1235 +#: ui/dialogs/settings-dialog.slint:1284 msgid "" "Mail and events on Gmail, Outlook, and IMAP servers will not be deleted." msgstr "" "No se eliminarán el correo ni los eventos de Gmail, Outlook o los servidores " "IMAP." -#: ui/dialogs/settings-dialog.slint:1237 +#: ui/dialogs/settings-dialog.slint:1286 msgid "Keep my data" msgstr "Conservar mis datos" @@ -2762,217 +3189,645 @@ msgstr "Mostrar Flectar Mail" msgid "Quit" msgstr "Salir" -#: ui/views/contacts-view.slint:205 ui/views/contacts-view.slint:519 -#: ui/views/contacts-view.slint:648 +#: ui/views/contacts-view.slint:147 ui/views/contacts-view.slint:461 +#: ui/views/contacts-view.slint:590 msgid "New contact" msgstr "Nuevo contacto" -#: ui/views/contacts-view.slint:245 +#: ui/views/contacts-view.slint:187 msgid "Unified contacts" msgstr "Contactos unificados" -#: ui/views/contacts-view.slint:264 ui/views/contacts-view.slint:336 +#: ui/views/contacts-view.slint:206 ui/views/contacts-view.slint:278 msgid "All contacts" msgstr "Todos los contactos" -#: ui/views/contacts-view.slint:265 ui/views/contacts-view.slint:337 +#: ui/views/contacts-view.slint:207 ui/views/contacts-view.slint:279 msgid "Favorites" msgstr "Favoritos" -#: ui/views/contacts-view.slint:286 +#: ui/views/contacts-view.slint:228 msgid "People discovered from your conversations appear here automatically." msgstr "" "Las personas detectadas en tus conversaciones aparecen aquí automáticamente." -#: ui/views/contacts-view.slint:345 +#: ui/views/contacts-view.slint:287 msgid "Show contact groups" msgstr "Mostrar grupos de contactos" -#: ui/views/contacts-view.slint:345 +#: ui/views/contacts-view.slint:287 msgid "Hide contact groups" msgstr "Ocultar grupos de contactos" -#: ui/views/contacts-view.slint:359 +#: ui/views/contacts-view.slint:301 msgid "Search contacts" msgstr "Buscar contactos" -#: ui/views/contacts-view.slint:365 +#: ui/views/contacts-view.slint:307 msgid "Filter contacts" msgstr "Filtrar contactos" -#: ui/views/contacts-view.slint:367 +#: ui/views/contacts-view.slint:309 msgid "Reload contacts" msgstr "Recargar contactos" -#: ui/views/contacts-view.slint:478 +#: ui/views/contacts-view.slint:420 msgid "Open {}" msgstr "Abrir {}" -#: ui/views/contacts-view.slint:493 +#: ui/views/contacts-view.slint:435 msgid "No contacts yet. Create one or sync some mail." msgstr "Aún no hay contactos. Crea uno o sincroniza el correo." -#: ui/views/contacts-view.slint:493 +#: ui/views/contacts-view.slint:435 msgid "No contacts match this search." msgstr "Ningún contacto coincide con esta búsqueda." -#: ui/views/contacts-view.slint:563 +#: ui/views/contacts-view.slint:505 msgid "Back to contacts" msgstr "Volver a contactos" -#: ui/views/contacts-view.slint:571 +#: ui/views/contacts-view.slint:513 msgid "Contact details" msgstr "Detalles del contacto" -#: ui/views/contacts-view.slint:585 +#: ui/views/contacts-view.slint:527 msgid "Remove from favorites" msgstr "Quitar de favoritos" -#: ui/views/contacts-view.slint:585 +#: ui/views/contacts-view.slint:527 msgid "Add to favorites" msgstr "Añadir a favoritos" -#: ui/views/contacts-view.slint:589 +#: ui/views/contacts-view.slint:531 msgid "Delete this contact?" msgstr "¿Eliminar este contacto?" -#: ui/views/contacts-view.slint:612 +#: ui/views/contacts-view.slint:554 msgid "Delete contact" msgstr "Eliminar contacto" -#: ui/views/contacts-view.slint:666 +#: ui/views/contacts-view.slint:608 msgid "NAME" msgstr "NOMBRE" -#: ui/views/contacts-view.slint:667 +#: ui/views/contacts-view.slint:609 msgid "Full name" msgstr "Nombre completo" -#: ui/views/contacts-view.slint:668 +#: ui/views/contacts-view.slint:610 msgid "EMAIL" msgstr "CORREO" -#: ui/views/contacts-view.slint:675 +#: ui/views/contacts-view.slint:617 msgid "PHONE" msgstr "TELÉFONO" -#: ui/views/contacts-view.slint:676 +#: ui/views/contacts-view.slint:618 msgid "+1 555 0100" msgstr "+34 600 000 000" -#: ui/views/contacts-view.slint:680 +#: ui/views/contacts-view.slint:622 msgid "BIRTHDAY" msgstr "CUMPLEAÑOS" -#: ui/views/contacts-view.slint:689 +#: ui/views/contacts-view.slint:631 msgid "COMPANY" msgstr "EMPRESA" -#: ui/views/contacts-view.slint:690 +#: ui/views/contacts-view.slint:632 msgid "Company" msgstr "Empresa" -#: ui/views/contacts-view.slint:694 +#: ui/views/contacts-view.slint:636 msgid "JOB TITLE" msgstr "CARGO" -#: ui/views/contacts-view.slint:695 +#: ui/views/contacts-view.slint:637 msgid "Role" msgstr "Cargo" -#: ui/views/contacts-view.slint:699 +#: ui/views/contacts-view.slint:641 msgid "WEBSITE" msgstr "SITIO WEB" -#: ui/views/contacts-view.slint:701 +#: ui/views/contacts-view.slint:643 msgid "ADDRESS" msgstr "DIRECCIÓN" -#: ui/views/contacts-view.slint:702 +#: ui/views/contacts-view.slint:644 msgid "Postal address" msgstr "Dirección postal" -#: ui/views/contacts-view.slint:703 +#: ui/views/contacts-view.slint:645 msgid "TAGS" msgstr "ETIQUETAS" -#: ui/views/contacts-view.slint:704 +#: ui/views/contacts-view.slint:646 msgid "customer, design, london" msgstr "cliente, diseño, madrid" -#: ui/views/contacts-view.slint:705 +#: ui/views/contacts-view.slint:647 msgid "NOTES" msgstr "NOTAS" -#: ui/views/contacts-view.slint:706 +#: ui/views/contacts-view.slint:648 msgid "Notes about this contact" msgstr "Notas sobre este contacto" -#: ui/views/contacts-view.slint:714 +#: ui/views/contacts-view.slint:656 msgid "Send email" msgstr "Enviar correo" -#: ui/views/contacts-view.slint:721 +#: ui/views/contacts-view.slint:663 msgid "Save contact" msgstr "Guardar contacto" -#: ui/views/contacts-view.slint:751 +#: ui/views/contacts-view.slint:693 msgid "Select a contact" msgstr "Selecciona un contacto" -#: ui/views/contacts-view.slint:752 +#: ui/views/contacts-view.slint:694 msgid "View and edit contact details here." msgstr "Consulta y edita aquí los detalles del contacto." -#: ui/views/mail-list-pane.slint:227 +#: ui/views/files-view.slint:32 +msgid "All accounts" +msgstr "Todas las cuentas" + +#: ui/views/files-view.slint:51 ui/views/files-view.slint:103 +#: ui/views/files-view.slint:267 +msgid "Email attachments" +msgstr "Adjuntos de correo" + +#: ui/views/files-view.slint:92 ui/views/files-view.slint:569 +msgid "Connect storage" +msgstr "Conectar almacenamiento" + +#: ui/views/files-view.slint:129 ui/views/files-view.slint:267 +msgid "Server storage" +msgstr "Almacenamiento del servidor" + +#: ui/views/files-view.slint:135 +msgid "STORAGE SPACE" +msgstr "ESPACIO DE ALMACENAMIENTO" + +#: ui/views/files-view.slint:146 ui/views/files-view.slint:385 +msgid "Sharing activity" +msgstr "Actividad de uso compartido" + +#: ui/views/files-view.slint:147 +msgid "Transfers" +msgstr "Transferencias" + +#: ui/views/files-view.slint:271 ui/views/files-view.slint:433 +msgid "File sources" +msgstr "Orígenes de archivos" + +#: ui/views/files-view.slint:274 +msgid "Upload" +msgstr "Subir" + +#: ui/views/files-view.slint:285 +msgid "Search files" +msgstr "Buscar archivos" + +#: ui/views/files-view.slint:287 +msgid "Refresh" +msgstr "Actualizar" + +#: ui/views/files-view.slint:288 ui/views/files-view.slint:295 +msgid "Filter and sort files" +msgstr "Filtrar y ordenar archivos" + +#: ui/views/files-view.slint:291 ui/views/files-view.slint:315 +msgid "Synchronized mail" +msgstr "Correo sincronizado" + +#: ui/views/files-view.slint:292 ui/views/files-view.slint:315 +msgid "All mail on server (JMAP)" +msgstr "Todo el correo del servidor (JMAP)" + +#: ui/views/files-view.slint:293 +msgid "Index cached text" +msgstr "Indexar texto en caché" + +#: ui/views/files-view.slint:314 ui/views/onboarding-view.slint:370 +msgid "Back" +msgstr "Atrás" + +#: ui/views/files-view.slint:323 +msgid "Loading files…" +msgstr "Cargando archivos…" + +#: ui/views/files-view.slint:323 +msgid "No files to show" +msgstr "No hay archivos que mostrar" + +#: ui/views/files-view.slint:324 +msgid "Attachments from synchronized messages will appear here." +msgstr "Aquí aparecerán los adjuntos de los mensajes sincronizados." + +#: ui/views/files-view.slint:324 +msgid "Upload a file or create a folder to get started." +msgstr "Sube un archivo o crea una carpeta para empezar." + +#: ui/views/files-view.slint:324 +msgid "Select an account and connect its storage." +msgstr "Selecciona una cuenta y conecta su almacenamiento." + +#: ui/views/files-view.slint:374 +msgid "Load more" +msgstr "Cargar más" + +#: ui/views/files-view.slint:388 +msgid "Back to files" +msgstr "Volver a los archivos" + +#: ui/views/files-view.slint:397 +msgid "Open folder" +msgstr "Abrir carpeta" + +#: ui/views/files-view.slint:398 +msgid "Manage" +msgstr "Gestionar" + +#: ui/views/files-view.slint:399 +msgid "Preview" +msgstr "Vista previa" + +#: ui/views/files-view.slint:400 +msgid "Details" +msgstr "Detalles" + +#: ui/views/files-view.slint:407 +msgid "Select a file to preview" +msgstr "Selecciona un archivo para ver una vista previa" + +#: ui/views/files-view.slint:408 +msgid "PDFs, images and text appear here. Other formats can be downloaded." +msgstr "" +"Aquí se muestran PDF, imágenes y texto. Puedes descargar otros formatos." + +#: ui/views/files-view.slint:408 +msgid "Images and text appear here. Other formats can be downloaded." +msgstr "Aquí se muestran imágenes y texto. Puedes descargar otros formatos." + +#: ui/views/files-view.slint:411 +msgid "Clear displayed activity" +msgstr "Borrar actividad mostrada" + +#: ui/views/files-view.slint:453 +msgid "Delete permanently?" +msgstr "¿Eliminar permanentemente?" + +#: ui/views/files-view.slint:453 +msgid "Share file or folder" +msgstr "Compartir archivo o carpeta" + +#: ui/views/files-view.slint:453 ui/views/files-view.slint:467 +msgid "Rename" +msgstr "Cambiar nombre" + +#: ui/views/files-view.slint:453 +msgid "Move to folder" +msgstr "Mover a carpeta" + +#: ui/views/files-view.slint:453 +msgid "Copy to folder" +msgstr "Copiar a carpeta" + +#: ui/views/files-view.slint:453 +msgid "File properties" +msgstr "Propiedades del archivo" + +#: ui/views/files-view.slint:453 +msgid "Lock file or folder" +msgstr "Bloquear archivo o carpeta" + +#: ui/views/files-view.slint:453 +msgid "Manage file" +msgstr "Gestionar archivo" + +#: ui/views/files-view.slint:456 +msgid "Files and folders" +msgstr "Archivos y carpetas" + +#: ui/views/files-view.slint:456 +msgid "Folders" +msgstr "Carpetas" + +#: ui/views/files-view.slint:457 ui/views/files-view.slint:478 +msgid "Name" +msgstr "Nombre" + +#: ui/views/files-view.slint:457 +msgid "Size" +msgstr "Tamaño" + +#: ui/views/files-view.slint:457 +msgid "Kind" +msgstr "Tipo" + +#: ui/views/files-view.slint:458 +msgid "Descending order" +msgstr "Orden descendente" + +#: ui/views/files-view.slint:459 +msgid "Minimum bytes" +msgstr "Mínimo de bytes" + +#: ui/views/files-view.slint:459 +msgid "Minimum bytes (optional)" +msgstr "Mínimo de bytes (opcional)" + +#: ui/views/files-view.slint:460 +msgid "Maximum bytes" +msgstr "Máximo de bytes" + +#: ui/views/files-view.slint:460 +msgid "Maximum bytes (optional)" +msgstr "Máximo de bytes (opcional)" + +#: ui/views/files-view.slint:461 +msgid "Reset filters" +msgstr "Restablecer filtros" + +#: ui/views/files-view.slint:465 +msgid "Keep offline" +msgstr "Mantener sin conexión" + +#: ui/views/files-view.slint:466 +msgid "Release offline copy" +msgstr "Liberar copia sin conexión" + +#: ui/views/files-view.slint:468 +msgid "Move…" +msgstr "Mover…" + +#: ui/views/files-view.slint:469 +msgid "Copy…" +msgstr "Copiar…" + +#: ui/views/files-view.slint:470 +msgid "Replace content…" +msgstr "Reemplazar contenido…" + +#: ui/views/files-view.slint:471 +msgid "Properties…" +msgstr "Propiedades…" + +#: ui/views/files-view.slint:472 +msgid "Sharing…" +msgstr "Compartir…" + +#: ui/views/files-view.slint:473 +msgid "Release lock" +msgstr "Desbloquear" + +#: ui/views/files-view.slint:473 +msgid "Lock…" +msgstr "Bloquear…" + +#: ui/views/files-view.slint:474 +msgid "Renew lock for 5 minutes" +msgstr "Renovar bloqueo durante 5 minutos" + +#: ui/views/files-view.slint:475 +msgid "Delete…" +msgstr "Eliminar…" + +#: ui/views/files-view.slint:477 +msgid "" +"This permanently deletes “{}” and all of its contents from storage. This " +"cannot be undone." +msgstr "" +"Esto elimina permanentemente «{}» y todo su contenido del almacenamiento. " +"Esta acción no se puede deshacer." + +#: ui/views/files-view.slint:482 +msgid "" +"Destination folder path (for example /Documents). Leave empty for the " +"current folder, or the root of another storage space." +msgstr "" +"Ruta de la carpeta de destino (por ejemplo, /Documents). Déjala vacía para " +"usar la carpeta actual o la raíz de otro espacio de almacenamiento." + +#: ui/views/files-view.slint:483 +msgid "Destination folder" +msgstr "Carpeta de destino" + +#: ui/views/files-view.slint:487 +msgid "Enter the recipient’s principal URL on this server." +msgstr "Introduce la URL del principal del destinatario en este servidor." + +#: ui/views/files-view.slint:487 +msgid "Enter the recipient’s email or principal ID on this server." +msgstr "" +"Introduce el correo o el identificador del principal del destinatario en " +"este servidor." + +#: ui/views/files-view.slint:488 +msgid "Recipient" +msgstr "Destinatario" + +#: ui/views/files-view.slint:489 +msgid "Allow editing" +msgstr "Permitir edición" + +#: ui/views/files-view.slint:489 +msgid "Modify content" +msgstr "Modificar contenido" + +#: ui/views/files-view.slint:492 +msgid "Modify properties" +msgstr "Modificar propiedades" + +#: ui/views/files-view.slint:493 +msgid "Add children" +msgstr "Añadir archivos y subcarpetas" + +#: ui/views/files-view.slint:494 +msgid "Rename and move" +msgstr "Cambiar nombre y mover" + +#: ui/views/files-view.slint:496 +msgid "Manage sharing" +msgstr "Gestionar acceso compartido" + +#: ui/views/files-view.slint:498 +msgid "Remove recipient’s access" +msgstr "Revocar acceso del destinatario" + +#: ui/views/files-view.slint:502 +msgid "Shared lock" +msgstr "Bloqueo compartido" + +#: ui/views/files-view.slint:503 +msgid "Include folder contents" +msgstr "Incluir contenido de la carpeta" + +#: ui/views/files-view.slint:504 +msgid "Locks expire after 5 minutes unless renewed." +msgstr "Los bloqueos caducan a los 5 minutos si no se renuevan." + +#: ui/views/files-view.slint:508 +msgid "If a name already exists" +msgstr "Si el nombre ya existe" + +#: ui/views/files-view.slint:509 +msgid "Stop and report conflict" +msgstr "Detener e informar del conflicto" + +#: ui/views/files-view.slint:509 +msgid "Keep both with a new name" +msgstr "Conservar ambos con un nombre nuevo" + +#: ui/views/files-view.slint:509 +msgid "Replace existing" +msgstr "Reemplazar existente" + +#: ui/views/files-view.slint:509 +msgid "Keep newest" +msgstr "Conservar el más reciente" + +#: ui/views/files-view.slint:510 +msgid "This can replace existing content at the destination." +msgstr "Esto puede reemplazar el contenido existente en el destino." + +#: ui/views/files-view.slint:511 +msgid "Compare names without case" +msgstr "Comparar nombres sin distinguir mayúsculas" + +#: ui/views/files-view.slint:517 +msgid "Property namespace" +msgstr "Espacio de nombres de la propiedad" + +#: ui/views/files-view.slint:518 +msgid "Property name" +msgstr "Nombre de la propiedad" + +#: ui/views/files-view.slint:519 +msgid "Value" +msgstr "Valor" + +#: ui/views/files-view.slint:520 +msgid "Remove property" +msgstr "Eliminar propiedad" + +#: ui/views/files-view.slint:524 +msgid "Dates use UTC, for example 2026-09-09T12:00:00Z." +msgstr "Las fechas usan UTC, por ejemplo, 2026-09-09T12:00:00Z." + +#: ui/views/files-view.slint:525 +msgid "Created" +msgstr "Creación" + +#: ui/views/files-view.slint:526 +msgid "Modified" +msgstr "Modificación" + +#: ui/views/files-view.slint:527 +msgid "Accessed" +msgstr "Último acceso" + +#: ui/views/files-view.slint:528 +msgid "Executable" +msgstr "Ejecutable" + +#: ui/views/files-view.slint:529 +msgid "Subscription changes are unavailable on this server version." +msgstr "Esta versión del servidor no permite cambiar suscripciones." + +#: ui/views/files-view.slint:535 +msgid "Delete permanently" +msgstr "Eliminar permanentemente" + +#: ui/views/files-view.slint:576 +msgid "Account" +msgstr "Cuenta" + +#: ui/views/files-view.slint:581 +msgid "Uses the selected account’s saved credentials." +msgstr "Usa las credenciales guardadas de la cuenta seleccionada." + +#: ui/views/files-view.slint:585 +msgid "Storage URL" +msgstr "URL de almacenamiento" + +#: ui/views/files-view.slint:599 +msgid "Transfers and operations" +msgstr "Transferencias y operaciones" + +#: ui/views/files-view.slint:600 +msgid "" +"Interrupted changes need review before retrying. Refresh storage and inspect " +"the destination first." +msgstr "" +"Los cambios interrumpidos deben revisarse antes de reintentarlos. Actualiza " +"el almacenamiento y comprueba primero el destino." + +#: ui/views/files-view.slint:607 +msgid "Resume" +msgstr "Reanudar" + +#: ui/views/files-view.slint:608 +msgid "Dismiss" +msgstr "Descartar" + +#: ui/views/files-view.slint:609 +msgid "Reviewed as completed" +msgstr "Revisada como completada" + +#: ui/views/mail-list-pane.slint:159 msgid "Show folders" msgstr "Mostrar carpetas" -#: ui/views/mail-list-pane.slint:227 +#: ui/views/mail-list-pane.slint:159 msgid "Hide folders" msgstr "Ocultar carpetas" -#: ui/views/mail-list-pane.slint:251 +#: ui/views/mail-list-pane.slint:197 msgid "Select all messages on this page" msgstr "Seleccionar todos los mensajes de esta página" -#: ui/views/mail-list-pane.slint:261 +#: ui/views/mail-list-pane.slint:209 msgid "Unread" msgstr "No leídos" -#: ui/views/mail-list-pane.slint:262 +#: ui/views/mail-list-pane.slint:210 msgid "Starred" msgstr "Destacados" -#: ui/views/mail-list-pane.slint:263 ui/views/mail-list-pane.slint:329 +#: ui/views/mail-list-pane.slint:211 ui/views/mail-list-pane.slint:281 msgid "Has attachments" msgstr "Con adjuntos" -#: ui/views/mail-list-pane.slint:266 +#: ui/views/mail-list-pane.slint:214 msgid "Filter mail" msgstr "Filtrar correo" -#: ui/views/mail-list-pane.slint:269 +#: ui/views/mail-list-pane.slint:217 msgid "Synchronizing mail" msgstr "Sincronizando correo" -#: ui/views/mail-list-pane.slint:269 +#: ui/views/mail-list-pane.slint:217 msgid "Reload mail" msgstr "Recargar correo" -#: ui/views/mail-list-pane.slint:294 +#: ui/views/mail-list-pane.slint:242 msgid "{} selected" msgstr "{} seleccionados" -#: ui/views/mail-list-pane.slint:327 +#: ui/views/mail-list-pane.slint:279 msgid "{}, {}" msgstr "{}, {}" -#: ui/views/mail-list-pane.slint:327 ui/views/mail-list-pane.slint:429 +#: ui/views/mail-list-pane.slint:279 ui/views/mail-list-pane.slint:397 msgid "(No subject)" msgstr "(Sin asunto)" -#: ui/views/mail-list-pane.slint:330 +#: ui/views/mail-list-pane.slint:282 msgid "Replied" msgstr "Respondido" @@ -2980,295 +3835,283 @@ msgstr "Respondido" msgid "Deselect {}" msgstr "Deseleccionar {}" -#: ui/views/mail-list-pane.slint:533 ui/views/mail-reading-pane.slint:303 +#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:314 msgid "Remove star" msgstr "Quitar destacado" -#: ui/views/mail-list-pane.slint:533 ui/views/mail-reading-pane.slint:303 +#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:314 msgid "Star message" msgstr "Destacar mensaje" -#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:316 +#: ui/views/mail-list-pane.slint:535 ui/views/mail-reading-pane.slint:327 msgid "Mark as read" msgstr "Marcar como leído" -#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:316 +#: ui/views/mail-list-pane.slint:535 ui/views/mail-reading-pane.slint:327 msgid "Mark as unread" msgstr "Marcar como no leído" -#: ui/views/mail-list-pane.slint:535 ui/views/mail-reading-pane.slint:562 +#: ui/views/mail-list-pane.slint:536 ui/views/mail-reading-pane.slint:574 msgid "Archive" msgstr "Archivar" -#: ui/views/mail-list-pane.slint:536 ui/views/mail-reading-pane.slint:325 +#: ui/views/mail-list-pane.slint:537 ui/views/mail-reading-pane.slint:336 msgid "Move to spam" msgstr "Mover a spam" -#: ui/views/mail-list-pane.slint:554 ui/views/onboarding-view.slint:92 +#: ui/views/mail-list-pane.slint:555 ui/views/onboarding-view.slint:109 msgid "Opening your mailbox…" msgstr "Abriendo tu buzón…" -#: ui/views/mail-list-pane.slint:554 +#: ui/views/mail-list-pane.slint:555 msgid "Nothing here" msgstr "No hay nada aquí" -#: ui/views/mail-list-pane.slint:555 +#: ui/views/mail-list-pane.slint:556 msgid "Try another folder or search term." msgstr "Prueba otra carpeta o término de búsqueda." -#: ui/views/mail-reading-pane.slint:257 +#: ui/views/mail-reading-pane.slint:268 msgid "Close message preview" msgstr "Cerrar vista previa del mensaje" -#: ui/views/mail-reading-pane.slint:267 +#: ui/views/mail-reading-pane.slint:278 msgid "Previous message" msgstr "Mensaje anterior" -#: ui/views/mail-reading-pane.slint:277 +#: ui/views/mail-reading-pane.slint:288 msgid "Next message" msgstr "Mensaje siguiente" -#: ui/views/mail-reading-pane.slint:290 +#: ui/views/mail-reading-pane.slint:301 msgid "Archive message" msgstr "Archivar mensaje" -#: ui/views/mail-reading-pane.slint:353 +#: ui/views/mail-reading-pane.slint:364 msgid "Manage labels" msgstr "Administrar etiquetas" -#: ui/views/mail-reading-pane.slint:396 +#: ui/views/mail-reading-pane.slint:407 msgid "Search labels" msgstr "Buscar etiquetas" -#: ui/views/mail-reading-pane.slint:413 +#: ui/views/mail-reading-pane.slint:424 msgid "Available labels" msgstr "Etiquetas disponibles" -#: ui/views/mail-reading-pane.slint:480 ui/views/mail-reading-pane.slint:1317 +#: ui/views/mail-reading-pane.slint:491 ui/views/mail-reading-pane.slint:1375 msgid "Edit label" msgstr "Editar etiqueta" -#: ui/views/mail-reading-pane.slint:496 +#: ui/views/mail-reading-pane.slint:507 msgid "No matching labels" msgstr "No hay etiquetas que coincidan" -#: ui/views/mail-reading-pane.slint:508 +#: ui/views/mail-reading-pane.slint:519 msgid "Create label…" msgstr "Crear etiqueta…" -#: ui/views/mail-reading-pane.slint:536 +#: ui/views/mail-reading-pane.slint:547 msgid "More message actions" msgstr "Más acciones del mensaje" -#: ui/views/mail-reading-pane.slint:540 ui/views/mail-reading-pane.slint:1210 +#: ui/views/mail-reading-pane.slint:551 ui/views/mail-reading-pane.slint:1268 msgid "Continue editing" msgstr "Seguir editando" -#: ui/views/mail-reading-pane.slint:544 +#: ui/views/mail-reading-pane.slint:555 +msgid "Decrypt / verify OpenPGP" +msgstr "Descifrar / verificar OpenPGP" + +#: ui/views/mail-reading-pane.slint:556 msgid "Print message" msgstr "Imprimir mensaje" -#: ui/views/mail-reading-pane.slint:545 ui/views/mail-reading-pane.slint:1220 +#: ui/views/mail-reading-pane.slint:557 ui/views/mail-reading-pane.slint:1278 msgid "Open in browser" msgstr "Abrir en el navegador" -#: ui/views/mail-reading-pane.slint:548 ui/views/mail-reading-pane.slint:555 +#: ui/views/mail-reading-pane.slint:560 ui/views/mail-reading-pane.slint:567 msgid "Show rendered message" msgstr "Mostrar mensaje renderizado" -#: ui/views/mail-reading-pane.slint:548 +#: ui/views/mail-reading-pane.slint:560 msgid "Show source" msgstr "Mostrar código fuente" -#: ui/views/mail-reading-pane.slint:555 +#: ui/views/mail-reading-pane.slint:567 msgid "View plain text" msgstr "Ver texto sin formato" -#: ui/views/mail-reading-pane.slint:663 +#: ui/views/mail-reading-pane.slint:679 msgid "Verified brand with BIMI authority evidence" msgstr "Marca verificada con evidencia de autoridad BIMI" -#: ui/views/mail-reading-pane.slint:665 +#: ui/views/mail-reading-pane.slint:681 msgid "BIMI-authenticated sender" msgstr "Remitente autenticado mediante BIMI" -#: ui/views/mail-reading-pane.slint:667 +#: ui/views/mail-reading-pane.slint:683 msgid "Sender authenticated by Microsoft" msgstr "Remitente autenticado por Microsoft" -#: ui/views/mail-reading-pane.slint:668 +#: ui/views/mail-reading-pane.slint:684 msgid "Sender domain authenticated with DMARC and DKIM" msgstr "Dominio del remitente autenticado mediante DMARC y DKIM" -#: ui/views/mail-reading-pane.slint:675 -msgid "to {} · {}" -msgstr "para {} · {}" - -#: ui/views/mail-reading-pane.slint:690 -msgid "Zoom out" -msgstr "Alejar" - -#: ui/views/mail-reading-pane.slint:691 -msgid "Reset zoom ({}%)" -msgstr "Restablecer zoom ({}%)" - -#: ui/views/mail-reading-pane.slint:700 -msgid "Zoom percentage" -msgstr "Porcentaje de zoom" - -#: ui/views/mail-reading-pane.slint:707 -msgid "Zoom in" -msgstr "Acercar" +#: ui/views/mail-reading-pane.slint:696 +msgid "to {}" +msgstr "para {}" -#: ui/views/mail-reading-pane.slint:708 -msgid "Fit to width" -msgstr "Ajustar al ancho" +#: ui/views/mail-reading-pane.slint:726 ui/views/mail-reading-pane.slint:1128 +msgid "Attachments" +msgstr "Adjuntos" -#: ui/views/mail-reading-pane.slint:710 ui/views/mail-reading-pane.slint:716 +#: ui/views/mail-reading-pane.slint:736 ui/views/mail-reading-pane.slint:742 msgid "Find in message" msgstr "Buscar en el mensaje" -#: ui/views/mail-reading-pane.slint:711 +#: ui/views/mail-reading-pane.slint:737 msgid "Show original layout" msgstr "Mostrar diseño original" -#: ui/views/mail-reading-pane.slint:711 +#: ui/views/mail-reading-pane.slint:737 msgid "Reader mode" msgstr "Modo de lectura" -#: ui/views/mail-reading-pane.slint:718 +#: ui/views/mail-reading-pane.slint:744 msgid "Previous match" msgstr "Coincidencia anterior" -#: ui/views/mail-reading-pane.slint:719 +#: ui/views/mail-reading-pane.slint:745 msgid "Next match" msgstr "Siguiente coincidencia" -#: ui/views/mail-reading-pane.slint:720 +#: ui/views/mail-reading-pane.slint:746 msgid "Close find" msgstr "Cerrar búsqueda" -#: ui/views/mail-reading-pane.slint:724 +#: ui/views/mail-reading-pane.slint:750 msgid "Some images could not be loaded." msgstr "No se pudieron cargar algunas imágenes." -#: ui/views/mail-reading-pane.slint:725 ui/views/mail-reading-pane.slint:1011 +#: ui/views/mail-reading-pane.slint:751 ui/views/mail-reading-pane.slint:1037 msgid "Retry images" msgstr "Reintentar imágenes" -#: ui/views/mail-reading-pane.slint:730 +#: ui/views/mail-reading-pane.slint:756 msgid "Reload message" msgstr "Volver a cargar el mensaje" -#: ui/views/mail-reading-pane.slint:785 +#: ui/views/mail-reading-pane.slint:811 msgid "Remote images were blocked to protect your privacy." msgstr "Las imágenes remotas se bloquearon para proteger tu privacidad." -#: ui/views/mail-reading-pane.slint:794 +#: ui/views/mail-reading-pane.slint:820 msgid "Load images" msgstr "Cargar imágenes" -#: ui/views/mail-reading-pane.slint:824 +#: ui/views/mail-reading-pane.slint:850 msgid "Loading image…" msgstr "Cargando imagen…" -#: ui/views/mail-reading-pane.slint:824 +#: ui/views/mail-reading-pane.slint:850 msgid "Blocked image" msgstr "Imagen bloqueada" -#: ui/views/mail-reading-pane.slint:824 +#: ui/views/mail-reading-pane.slint:850 msgid "Image exceeds limits" msgstr "La imagen supera los límites" -#: ui/views/mail-reading-pane.slint:824 +#: ui/views/mail-reading-pane.slint:850 msgid "Image unavailable" msgstr "Imagen no disponible" -#: ui/views/mail-reading-pane.slint:960 +#: ui/views/mail-reading-pane.slint:986 msgid "Show rendered email" msgstr "Mostrar correo renderizado" -#: ui/views/mail-reading-pane.slint:983 +#: ui/views/mail-reading-pane.slint:1009 msgid "Copy this HTML into your GitHub rendering issue." msgstr "Copia este HTML en tu incidencia de GitHub sobre la visualización." -#: ui/views/mail-reading-pane.slint:1000 +#: ui/views/mail-reading-pane.slint:1026 msgid "Show email" msgstr "Mostrar correo" -#: ui/views/mail-reading-pane.slint:1010 +#: ui/views/mail-reading-pane.slint:1036 msgid "View original plain text" msgstr "Ver texto original sin formato" -#: ui/views/mail-reading-pane.slint:1012 +#: ui/views/mail-reading-pane.slint:1038 msgid "Copy formatted selection" msgstr "Copiar selección con formato" -#: ui/views/mail-reading-pane.slint:1013 +#: ui/views/mail-reading-pane.slint:1039 msgid "Caret browsing" msgstr "Navegación con cursor" -#: ui/views/mail-reading-pane.slint:1014 +#: ui/views/mail-reading-pane.slint:1040 msgid "Use dark reader" msgstr "Usar lectura oscura" -#: ui/views/mail-reading-pane.slint:1016 +#: ui/views/mail-reading-pane.slint:1042 msgid "Copy selected text" msgstr "Copiar texto seleccionado" -#: ui/views/mail-reading-pane.slint:1026 +#: ui/views/mail-reading-pane.slint:1052 msgid "Copy all email text" msgstr "Copiar todo el texto del correo" -#: ui/views/mail-reading-pane.slint:1036 +#: ui/views/mail-reading-pane.slint:1062 msgid "Select all email text" msgstr "Seleccionar todo el texto del correo" -#: ui/views/mail-reading-pane.slint:1052 +#: ui/views/mail-reading-pane.slint:1078 msgid "Link: {}" msgstr "Enlace: {}" -#: ui/views/mail-reading-pane.slint:1091 +#: ui/views/mail-reading-pane.slint:1117 msgid "Heading: {}" msgstr "Encabezado: {}" -#: ui/views/mail-reading-pane.slint:1285 +#: ui/views/mail-reading-pane.slint:1343 msgid "No message selected" msgstr "No hay ningún mensaje seleccionado" -#: ui/views/mail-reading-pane.slint:1286 +#: ui/views/mail-reading-pane.slint:1344 msgid "Choose an email to read it here." msgstr "Elige un correo para leerlo aquí." -#: ui/views/mail-reading-pane.slint:1317 +#: ui/views/mail-reading-pane.slint:1375 msgid "Create label" msgstr "Crear etiqueta" -#: ui/views/mail-reading-pane.slint:1324 +#: ui/views/mail-reading-pane.slint:1382 msgid "Label name" msgstr "Nombre de la etiqueta" -#: ui/views/mail-reading-pane.slint:1332 +#: ui/views/mail-reading-pane.slint:1390 msgid "Color" msgstr "Color" -#: ui/views/mail-reading-pane.slint:1342 +#: ui/views/mail-reading-pane.slint:1400 msgid "Blue" msgstr "Azul" -#: ui/views/mail-reading-pane.slint:1344 +#: ui/views/mail-reading-pane.slint:1402 msgid "Pink" msgstr "Rosa" -#: ui/views/mail-reading-pane.slint:1345 +#: ui/views/mail-reading-pane.slint:1403 msgid "Red" msgstr "Rojo" -#: ui/views/mail-reading-pane.slint:1346 +#: ui/views/mail-reading-pane.slint:1404 msgid "Amber" msgstr "Ámbar" -#: ui/views/mail-reading-pane.slint:1349 +#: ui/views/mail-reading-pane.slint:1407 msgid "Slate" msgstr "Gris pizarra" @@ -3276,60 +4119,60 @@ msgstr "Gris pizarra" msgid "Close folders" msgstr "Cerrar carpetas" -#: ui/views/onboarding-view.slint:103 +#: ui/views/onboarding-view.slint:121 msgid "Connect an account to get started." msgstr "Conecta una cuenta para comenzar." -#: ui/views/onboarding-view.slint:110 +#: ui/views/onboarding-view.slint:128 msgid "Continue with Gmail" msgstr "Continuar con Gmail" -#: ui/views/onboarding-view.slint:121 +#: ui/views/onboarding-view.slint:139 msgid "Continue with Outlook" msgstr "Continuar con Outlook" -#: ui/views/onboarding-view.slint:132 +#: ui/views/onboarding-view.slint:150 msgid "Connect another provider" msgstr "Conectar otro proveedor" -#: ui/views/onboarding-view.slint:147 +#: ui/views/onboarding-view.slint:165 msgid "Add your own app keys to enable Gmail and Outlook." msgstr "Añade tus propias claves de aplicación para habilitar Gmail y Outlook." -#: ui/views/onboarding-view.slint:148 +#: ui/views/onboarding-view.slint:166 msgid "Add your own app keys to enable Gmail." msgstr "Añade tus propias claves de aplicación para habilitar Gmail." -#: ui/views/onboarding-view.slint:149 +#: ui/views/onboarding-view.slint:167 msgid "Add your own app key to enable Outlook." msgstr "Añade tu propia clave de aplicación para habilitar Outlook." -#: ui/views/onboarding-view.slint:165 +#: ui/views/onboarding-view.slint:183 msgid "Already used Flectar Mail?" msgstr "¿Ya usabas Flectar Mail?" -#: ui/views/onboarding-view.slint:172 +#: ui/views/onboarding-view.slint:190 msgid "Import a backup" msgstr "Importar una copia de seguridad" -#: ui/views/onboarding-view.slint:196 +#: ui/views/onboarding-view.slint:221 msgid "Connect using JMAP for mail sync and sending." msgstr "Conecta mediante JMAP para sincronizar y enviar correo." -#: ui/views/onboarding-view.slint:196 -msgid "Connect using IMAP for mail sync and SMTP for sending." -msgstr "Conecta mediante IMAP para sincronizar y SMTP para enviar correo." +#: ui/views/onboarding-view.slint:221 +msgid "Connect using IMAP and SMTP. POP3 is not supported." +msgstr "Conecta mediante IMAP y SMTP. POP3 no es compatible." -#: ui/views/onboarding-view.slint:236 +#: ui/views/onboarding-view.slint:240 ui/views/onboarding-view.slint:243 +msgid "Username (defaults to email)" +msgstr "Usuario (por defecto, tu correo)" + +#: ui/views/onboarding-view.slint:280 msgid "Server address (optional)" msgstr "Dirección del servidor (opcional)" -#: ui/views/onboarding-view.slint:240 +#: ui/views/onboarding-view.slint:284 msgid "Leave blank to discover /.well-known/jmap from your email domain." msgstr "" "Déjalo vacío para detectar /.well-known/jmap a partir de tu dominio de " "correo." - -#: ui/views/onboarding-view.slint:301 -msgid "Back" -msgstr "Atrás" diff --git a/lang/flectar-mail.pot b/lang/flectar-mail.pot index 8c52589..adda0cf 100644 --- a/lang/flectar-mail.pot +++ b/lang/flectar-mail.pot @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: flectar-mail 0.1.0\n" -"POT-Creation-Date: 2026-09-08 14:26+0000\n" +"POT-Creation-Date: 2026-09-11 13:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -12,1073 +12,1313 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: ui/app.slint:57 +#: ui/app.slint:65 msgid "Jan" msgstr "" -#: ui/app.slint:57 +#: ui/app.slint:65 msgid "January" msgstr "" -#: ui/app.slint:58 +#: ui/app.slint:66 msgid "Feb" msgstr "" -#: ui/app.slint:58 +#: ui/app.slint:66 msgid "February" msgstr "" -#: ui/app.slint:59 +#: ui/app.slint:67 msgid "Mar" msgstr "" -#: ui/app.slint:59 +#: ui/app.slint:67 msgid "March" msgstr "" -#: ui/app.slint:60 +#: ui/app.slint:68 msgid "Apr" msgstr "" -#: ui/app.slint:60 +#: ui/app.slint:68 msgid "April" msgstr "" -#: ui/app.slint:61 +#: ui/app.slint:69 msgid "May" msgstr "" -#: ui/app.slint:62 +#: ui/app.slint:70 msgid "Jun" msgstr "" -#: ui/app.slint:62 +#: ui/app.slint:70 msgid "June" msgstr "" -#: ui/app.slint:63 +#: ui/app.slint:71 msgid "Jul" msgstr "" -#: ui/app.slint:63 +#: ui/app.slint:71 msgid "July" msgstr "" -#: ui/app.slint:64 +#: ui/app.slint:72 msgid "Aug" msgstr "" -#: ui/app.slint:64 +#: ui/app.slint:72 msgid "August" msgstr "" -#: ui/app.slint:65 +#: ui/app.slint:73 msgid "Sep" msgstr "" -#: ui/app.slint:65 +#: ui/app.slint:73 msgid "September" msgstr "" -#: ui/app.slint:66 +#: ui/app.slint:74 msgid "Oct" msgstr "" -#: ui/app.slint:66 +#: ui/app.slint:74 msgid "October" msgstr "" -#: ui/app.slint:67 +#: ui/app.slint:75 msgid "Nov" msgstr "" -#: ui/app.slint:67 +#: ui/app.slint:75 msgid "November" msgstr "" -#: ui/app.slint:68 +#: ui/app.slint:76 msgid "Dec" msgstr "" -#: ui/app.slint:68 +#: ui/app.slint:76 msgid "December" msgstr "" -#: ui/app.slint:73 ui/components/calendar.slint:635 +#: ui/app.slint:81 ui/components/calendar.slint:635 msgid "Mon" msgstr "" -#: ui/app.slint:74 ui/components/calendar.slint:635 +#: ui/app.slint:82 ui/components/calendar.slint:635 msgid "Tue" msgstr "" -#: ui/app.slint:75 ui/components/calendar.slint:635 +#: ui/app.slint:83 ui/components/calendar.slint:635 msgid "Wed" msgstr "" -#: ui/app.slint:76 ui/components/calendar.slint:635 +#: ui/app.slint:84 ui/components/calendar.slint:635 msgid "Thu" msgstr "" -#: ui/app.slint:77 ui/components/calendar.slint:635 +#: ui/app.slint:85 ui/components/calendar.slint:635 msgid "Fri" msgstr "" -#: ui/app.slint:78 ui/components/calendar.slint:635 +#: ui/app.slint:86 ui/components/calendar.slint:635 msgid "Sat" msgstr "" -#: ui/app.slint:79 ui/components/calendar.slint:635 +#: ui/app.slint:87 ui/components/calendar.slint:635 msgid "Sun" msgstr "" -#: ui/app.slint:82 +#: ui/app.slint:90 msgid "All day" msgstr "" -#: ui/app.slint:83 +#: ui/app.slint:91 msgid "(untitled event)" msgstr "" -#: ui/app.slint:84 +#: ui/app.slint:92 msgid "No conversation history" msgstr "" -#: ui/app.slint:85 +#: ui/app.slint:93 msgid "Conversation history available" msgstr "" -#: ui/app.slint:86 +#: ui/app.slint:94 msgid "New address-book entry" msgstr "" -#: ui/app.slint:87 ui/app.slint:668 ui/components/composer-fields.slint:63 -#: ui/app.slint:87 ui/app.slint:668 ui/components/composer-fields.slint:63 ui/dialogs/compose-dialog.slint:34 +#: ui/app.slint:95 ui/app.slint:686 ui/components/composer-fields.slint:63 +#: ui/app.slint:95 ui/app.slint:686 ui/components/composer-fields.slint:63 ui/dialogs/compose-dialog.slint:34 msgid "No connected account" msgstr "" -#: ui/app.slint:89 +#: ui/app.slint:97 msgid "{n} interaction" msgid_plural "{n} interactions" msgstr[0] "" msgstr[1] "" -#: ui/app.slint:92 +#: ui/app.slint:100 msgid "Last contacted {} {}, {}" msgstr "" -#: ui/app.slint:94 ui/app.slint:291 +#: ui/app.slint:102 ui/app.slint:299 msgid "Loading local data…" msgstr "" -#: ui/app.slint:95 ui/app.slint:292 +#: ui/app.slint:103 ui/app.slint:300 msgid "Local data ready." msgstr "" -#: ui/app.slint:96 +#: ui/app.slint:104 msgid "Local data is unavailable: {}" msgstr "" -#: ui/app.slint:97 +#: ui/app.slint:105 msgid "Could not display local mail: {}" msgstr "" -#: ui/app.slint:128 +#: ui/app.slint:136 msgid "An unexpected interface message occurred." msgstr "" -#: ui/app.slint:132 +#: ui/app.slint:140 msgid "Notification preferences are active for this session." msgstr "" -#: ui/app.slint:133 +#: ui/app.slint:141 msgid "Notification preferences saved." msgstr "" -#: ui/app.slint:134 +#: ui/app.slint:142 msgid "Could not save notifications: {}" msgstr "" -#: ui/app.slint:135 +#: ui/app.slint:143 msgid "Sync interval is active for this session." msgstr "" -#: ui/app.slint:136 +#: ui/app.slint:144 msgid "Sync interval set to {} minutes." msgstr "" -#: ui/app.slint:137 +#: ui/app.slint:145 msgid "Could not save sync interval: {}" msgstr "" -#: ui/app.slint:138 +#: ui/app.slint:146 msgid "Message-opening preference updated for this session." msgstr "" -#: ui/app.slint:139 +#: ui/app.slint:147 msgid "Message-opening preference saved." msgstr "" -#: ui/app.slint:140 +#: ui/app.slint:148 msgid "Could not save message-opening preference: {}" msgstr "" -#: ui/app.slint:141 +#: ui/app.slint:149 msgid "Theme updated for this session." msgstr "" -#: ui/app.slint:142 +#: ui/app.slint:150 msgid "Theme preference saved." msgstr "" -#: ui/app.slint:143 +#: ui/app.slint:151 msgid "Could not save theme: {}" msgstr "" -#: ui/app.slint:144 +#: ui/app.slint:152 msgid "Theme palette updated for this session." msgstr "" -#: ui/app.slint:145 +#: ui/app.slint:153 msgid "Theme palette saved." msgstr "" -#: ui/app.slint:146 +#: ui/app.slint:154 msgid "Could not save theme palette: {}" msgstr "" -#: ui/app.slint:147 +#: ui/app.slint:155 msgid "Custom theme updated for this session." msgstr "" -#: ui/app.slint:148 +#: ui/app.slint:156 msgid "Custom theme saved." msgstr "" -#: ui/app.slint:149 +#: ui/app.slint:157 msgid "Could not save custom theme: {}" msgstr "" -#: ui/app.slint:150 +#: ui/app.slint:158 msgid "Language updated for this session." msgstr "" -#: ui/app.slint:151 +#: ui/app.slint:159 msgid "Language preference saved." msgstr "" -#: ui/app.slint:152 +#: ui/app.slint:160 msgid "Could not save language: {}" msgstr "" -#: ui/app.slint:153 +#: ui/app.slint:161 msgid "Sidebar icon style updated for this session." msgstr "" -#: ui/app.slint:154 +#: ui/app.slint:162 msgid "Sidebar icon preference saved." msgstr "" -#: ui/app.slint:155 +#: ui/app.slint:163 msgid "Could not save sidebar icon preference: {}" msgstr "" -#: ui/app.slint:156 +#: ui/app.slint:164 msgid "Avatar preference updated for this session." msgstr "" -#: ui/app.slint:157 +#: ui/app.slint:165 msgid "Avatar preference saved." msgstr "" -#: ui/app.slint:158 +#: ui/app.slint:166 msgid "Could not save avatar preference: {}" msgstr "" -#: ui/app.slint:159 +#: ui/app.slint:167 msgid "Workspace layout updated for this session." msgstr "" -#: ui/app.slint:160 +#: ui/app.slint:168 msgid "Workspace layout preference saved." msgstr "" -#: ui/app.slint:161 +#: ui/app.slint:169 msgid "Could not save workspace layout: {}" msgstr "" -#: ui/app.slint:166 +#: ui/app.slint:174 msgid "Opened {}" msgstr "" -#: ui/app.slint:167 +#: ui/app.slint:175 msgid "Could not open link: {}" msgstr "" -#: ui/app.slint:168 +#: ui/app.slint:176 msgid "Copied HTML source" msgstr "" -#: ui/app.slint:169 +#: ui/app.slint:177 msgid "Copied selected text" msgstr "" -#: ui/app.slint:170 +#: ui/app.slint:178 msgid "Copied email text" msgstr "" -#: ui/app.slint:171 +#: ui/app.slint:179 msgid "Copied link" msgstr "" -#: ui/app.slint:172 +#: ui/app.slint:180 msgid "Blitz software selection render failed: {}" msgstr "" -#: ui/app.slint:173 +#: ui/app.slint:181 msgid "Blitz software tile render failed: {}" msgstr "" -#: ui/app.slint:174 +#: ui/app.slint:182 msgid "Message ready." msgstr "" -#: ui/app.slint:175 -msgid "System tray behavior is unavailable on mobile." +#: ui/app.slint:183 +msgid "System tray behavior is unavailable in this package." msgstr "" -#: ui/app.slint:176 +#: ui/app.slint:184 msgid "Close-to-tray is active for this session." msgstr "" -#: ui/app.slint:177 +#: ui/app.slint:185 msgid "Saving close behavior…" msgstr "" -#: ui/app.slint:178 +#: ui/app.slint:186 msgid "Closing the window will now keep Flectar Mail in the tray." msgstr "" -#: ui/app.slint:179 +#: ui/app.slint:187 msgid "Closing the window will now quit Flectar Mail." msgstr "" -#: ui/app.slint:180 +#: ui/app.slint:188 msgid "Could not save close behavior: {}" msgstr "" -#: ui/app.slint:185 +#: ui/app.slint:193 msgid "Database snapshot exported to {}." msgstr "" -#: ui/app.slint:186 +#: ui/app.slint:194 msgid "Could not export database snapshot: {}" msgstr "" -#: ui/app.slint:187 +#: ui/app.slint:195 msgid "Account order saved." msgstr "" -#: ui/app.slint:188 +#: ui/app.slint:196 msgid "Could not save account order: {}" msgstr "" -#: ui/app.slint:189 +#: ui/app.slint:197 msgid "Mail history updated. Background sync is adjusting now." msgstr "" -#: ui/app.slint:190 +#: ui/app.slint:198 msgid "Could not update mail history: {}" msgstr "" -#: ui/app.slint:191 +#: ui/app.slint:199 msgid "Connected {}. Initial sync is running in the background." msgstr "" -#: ui/app.slint:192 +#: ui/app.slint:200 msgid "Account setup failed: {}" msgstr "" -#: ui/app.slint:193 +#: ui/app.slint:201 msgid "OAuth failed: {}" msgstr "" -#: ui/app.slint:194 +#: ui/app.slint:202 msgid "Reconnected {}. Synchronization is resuming." msgstr "" -#: ui/app.slint:195 +#: ui/app.slint:203 msgid "Reconnection failed: {}" msgstr "" -#: ui/app.slint:196 +#: ui/app.slint:204 msgid "Calendar connected and initial sync started." msgstr "" -#: ui/app.slint:197 +#: ui/app.slint:205 msgid "Could not connect calendar: {}" msgstr "" -#: ui/app.slint:198 +#: ui/app.slint:206 msgid "Calendar sync enabled." msgstr "" -#: ui/app.slint:199 +#: ui/app.slint:207 msgid "Calendar sync paused." msgstr "" -#: ui/app.slint:200 +#: ui/app.slint:208 msgid "Could not update calendar sync: {}" msgstr "" -#: ui/app.slint:201 +#: ui/app.slint:209 msgid "CalDAV connected and initial sync started." msgstr "" -#: ui/app.slint:202 +#: ui/app.slint:210 msgid "Could not connect CalDAV: {}" msgstr "" -#: ui/app.slint:207 +#: ui/app.slint:215 msgid "Connect an account before creating a backup." msgstr "" -#: ui/app.slint:208 +#: ui/app.slint:216 msgid "Export Flectar Mail account backup" msgstr "" -#: ui/app.slint:209 +#: ui/app.slint:217 msgid "Import Flectar Mail account backup" msgstr "" -#: ui/app.slint:210 +#: ui/app.slint:218 msgid "Flectar Mail backup" msgstr "" -#: ui/app.slint:211 +#: ui/app.slint:219 msgid "Choose where to export the database snapshot" msgstr "" -#: ui/app.slint:212 +#: ui/app.slint:220 msgid "The platform host does not provide document export." msgstr "" -#: ui/app.slint:213 +#: ui/app.slint:221 msgid "The platform host does not provide document import." msgstr "" -#: ui/app.slint:214 +#: ui/app.slint:222 msgid "Backup exported. Passwords and OAuth tokens were not included." msgstr "" -#: ui/app.slint:215 +#: ui/app.slint:223 msgid "Could not export backup: {}" msgstr "" -#: ui/app.slint:216 +#: ui/app.slint:224 msgid "Local data storage is unavailable. Retry startup." msgstr "" -#: ui/app.slint:217 +#: ui/app.slint:225 msgid "Creating verified database snapshot…" msgstr "" -#: ui/app.slint:218 +#: ui/app.slint:226 msgid "Backup imported. All {} account configurations already existed." msgstr "" -#: ui/app.slint:219 +#: ui/app.slint:227 msgid "" "Imported {} account configurations. Open Accounts and sign in to each one." msgstr "" -#: ui/app.slint:220 +#: ui/app.slint:228 msgid "Could not import backup: {}" msgstr "" -#: ui/app.slint:221 +#: ui/app.slint:229 msgid "All local Flectar Mail data was deleted." msgstr "" -#: ui/app.slint:222 +#: ui/app.slint:230 msgid "Could not delete all local data: {}" msgstr "" -#: ui/app.slint:223 +#: ui/app.slint:231 msgid "Deleting local accounts and cached data…" msgstr "" -#: ui/app.slint:228 +#: ui/app.slint:236 msgid "Background mail refresh failed: {}" msgstr "" -#: ui/app.slint:229 +#: ui/app.slint:237 msgid "No messages in this folder" msgstr "" -#: ui/app.slint:230 +#: ui/app.slint:238 msgid "No messages match this search" msgstr "" -#: ui/app.slint:231 +#: ui/app.slint:239 msgid "Mail core is ready for account synchronization and message actions." msgstr "" -#: ui/app.slint:232 +#: ui/app.slint:240 msgid "Showing {} of {} message" msgstr "" -#: ui/app.slint:233 +#: ui/app.slint:241 msgid "Showing {} of {} message · more available" msgstr "" -#: ui/app.slint:234 +#: ui/app.slint:242 msgid "Showing {} of {} message · current results complete" msgstr "" -#: ui/app.slint:235 +#: ui/app.slint:243 msgid "Showing {} of {} messages" msgstr "" -#: ui/app.slint:236 +#: ui/app.slint:244 msgid "Showing {} of {} messages · more available" msgstr "" -#: ui/app.slint:237 +#: ui/app.slint:245 msgid "Showing {} of {} messages · current results complete" msgstr "" -#: ui/app.slint:238 +#: ui/app.slint:246 msgid "Showing {} of {} message matching \"{}\"" msgstr "" -#: ui/app.slint:239 +#: ui/app.slint:247 msgid "Showing {} of {} message matching \"{}\" · more available" msgstr "" -#: ui/app.slint:240 +#: ui/app.slint:248 msgid "Showing {} of {} message matching \"{}\" · current results complete" msgstr "" -#: ui/app.slint:241 +#: ui/app.slint:249 msgid "Showing {} of {} messages matching \"{}\"" msgstr "" -#: ui/app.slint:242 +#: ui/app.slint:250 msgid "Showing {} of {} messages matching \"{}\" · more available" msgstr "" -#: ui/app.slint:243 +#: ui/app.slint:251 msgid "Showing {} of {} messages matching \"{}\" · current results complete" msgstr "" -#: ui/app.slint:244 +#: ui/app.slint:252 msgid "Rendered with software fallback after GPU error: {}" msgstr "" -#: ui/app.slint:245 +#: ui/app.slint:253 msgid "Blitz render failed (GPU: {}; software: {})" msgstr "" -#: ui/app.slint:250 +#: ui/app.slint:258 msgid "Add an email address, then save the contact." msgstr "" -#: ui/app.slint:251 +#: ui/app.slint:259 msgid "Enter a valid email address before saving." msgstr "" -#: ui/app.slint:252 +#: ui/app.slint:260 msgid "Could not save: contact storage is unavailable." msgstr "" -#: ui/app.slint:253 +#: ui/app.slint:261 msgid "Could not save contact: {}" msgstr "" -#: ui/app.slint:254 +#: ui/app.slint:262 msgid "Contact saved." msgstr "" -#: ui/app.slint:255 +#: ui/app.slint:263 msgid "Could not delete: contact storage is unavailable." msgstr "" -#: ui/app.slint:256 +#: ui/app.slint:264 msgid "Could not delete contact: {}" msgstr "" -#: ui/app.slint:257 +#: ui/app.slint:265 msgid "Contact deleted." msgstr "" -#: ui/app.slint:258 +#: ui/app.slint:266 msgid "Could not load contacts: {}" msgstr "" -#: ui/app.slint:259 +#: ui/app.slint:267 msgid "Local contact storage is still starting…" msgstr "" -#: ui/app.slint:260 +#: ui/app.slint:268 msgid "Loading contacts…" msgstr "" -#: ui/app.slint:265 +#: ui/app.slint:273 msgid "Could not load calendar: {}" msgstr "" -#: ui/app.slint:266 +#: ui/app.slint:274 msgid "Calendar shown and syncing." msgstr "" -#: ui/app.slint:267 +#: ui/app.slint:275 msgid "Calendar hidden and sync paused." msgstr "" -#: ui/app.slint:268 +#: ui/app.slint:276 msgid "Calendar update failed: {}" msgstr "" -#: ui/app.slint:269 +#: ui/app.slint:277 msgid "Only events created in Flectar Mail can be edited." msgstr "" -#: ui/app.slint:270 +#: ui/app.slint:278 msgid "Only events created in Flectar Mail can be deleted." msgstr "" -#: ui/app.slint:271 +#: ui/app.slint:279 msgid "Calendar storage is unavailable." msgstr "" -#: ui/app.slint:272 +#: ui/app.slint:280 msgid "Could not delete event: {}" msgstr "" -#: ui/app.slint:273 +#: ui/app.slint:281 msgid "Event deleted, but refresh failed: {}" msgstr "" -#: ui/app.slint:274 +#: ui/app.slint:282 msgid "Event deleted." msgstr "" -#: ui/app.slint:275 +#: ui/app.slint:283 msgid "Use YYYY-MM-DD and a 24-hour time such as 09:30." msgstr "" -#: ui/app.slint:276 +#: ui/app.slint:284 msgid "Event duration must be a number of minutes." msgstr "" -#: ui/app.slint:277 +#: ui/app.slint:285 msgid "Add a title and choose a duration from 15 to 1440 minutes." msgstr "" -#: ui/app.slint:278 +#: ui/app.slint:286 msgid "That local time does not exist in the current time zone." msgstr "" -#: ui/app.slint:279 +#: ui/app.slint:287 msgid "Could not save event: {}" msgstr "" -#: ui/app.slint:280 +#: ui/app.slint:288 msgid "Event saved, but refresh failed: {}" msgstr "" -#: ui/app.slint:281 +#: ui/app.slint:289 msgid "Event saved. Provider sync will run when enabled." msgstr "" -#: ui/app.slint:286 +#: ui/app.slint:294 msgid "Email resource render failed: {}" msgstr "" -#: ui/app.slint:287 +#: ui/app.slint:295 msgid "Message refresh failed: {}" msgstr "" -#: ui/app.slint:288 +#: ui/app.slint:296 msgid "Message load failed: {}" msgstr "" -#: ui/app.slint:289 +#: ui/app.slint:297 msgid "" "Your local mailbox could not be opened. No account operation was started. " "Retry, or copy the diagnostics for support." msgstr "" -#: ui/app.slint:290 +#: ui/app.slint:298 msgid "Local storage startup failed." msgstr "" -#: ui/app.slint:297 +#: ui/app.slint:305 msgid "Could not identify the dragged account." msgstr "" -#: ui/app.slint:298 +#: ui/app.slint:306 msgid "Account ordering requires local mail data." msgstr "" -#: ui/app.slint:299 +#: ui/app.slint:307 msgid "Could not find the account to reorder." msgstr "" -#: ui/app.slint:300 +#: ui/app.slint:308 msgid "Saving account order…" msgstr "" -#: ui/app.slint:301 +#: ui/app.slint:309 msgid "Mail history requires local mail data." msgstr "" -#: ui/app.slint:302 +#: ui/app.slint:310 msgid "Updating mail history…" msgstr "" -#: ui/app.slint:303 +#: ui/app.slint:311 msgid "Remote images are unavailable in this minimal build." msgstr "" -#: ui/app.slint:304 +#: ui/app.slint:312 msgid "Could not save remote-image setting: {}" msgstr "" -#: ui/app.slint:305 +#: ui/app.slint:313 msgid "Remote sender icons unavailable: {}" msgstr "" -#: ui/app.slint:306 +#: ui/app.slint:314 msgid "Remote images unavailable: {}" msgstr "" -#: ui/app.slint:307 +#: ui/app.slint:315 msgid "Remote body images and sender favicons enabled." msgstr "" -#: ui/app.slint:308 +#: ui/app.slint:316 msgid "Remote images blocked." msgstr "" -#: ui/app.slint:309 +#: ui/app.slint:317 msgid "Could not refresh message images: {}" msgstr "" -#: ui/app.slint:310 +#: ui/app.slint:318 msgid "Remote-image loading is disabled." msgstr "" -#: ui/app.slint:311 +#: ui/app.slint:319 msgid "Could not load message images: {}" msgstr "" -#: ui/app.slint:312 +#: ui/app.slint:320 msgid "Sync finished, refresh failed: {}" msgstr "" -#: ui/app.slint:313 +#: ui/app.slint:321 msgid "Sync complete." msgstr "" -#: ui/app.slint:314 +#: ui/app.slint:322 msgid "Sync failed: {}" msgstr "" -#: ui/app.slint:315 +#: ui/app.slint:323 msgid "Local mail data is unavailable; preview data is read-only." msgstr "" -#: ui/app.slint:316 +#: ui/app.slint:324 msgid "Synchronizing accounts…" msgstr "" -#: ui/app.slint:321 +#: ui/app.slint:329 msgid "Message action completed." msgstr "" -#: ui/app.slint:322 +#: ui/app.slint:330 msgid "Message action failed: {}" msgstr "" -#: ui/app.slint:323 +#: ui/app.slint:331 msgid "Message action failed: message is no longer available" msgstr "" -#: ui/app.slint:324 +#: ui/app.slint:332 msgid "Label added." msgstr "" -#: ui/app.slint:325 +#: ui/app.slint:333 msgid "Label removed." msgstr "" -#: ui/app.slint:326 +#: ui/app.slint:334 msgid "Label created and added." msgstr "" -#: ui/app.slint:327 +#: ui/app.slint:335 msgid "Label updated." msgstr "" -#: ui/app.slint:328 +#: ui/app.slint:336 msgid "Label deleted." msgstr "" -#: ui/app.slint:329 +#: ui/app.slint:337 msgid "Could not update label: {}" msgstr "" -#: ui/app.slint:330 +#: ui/app.slint:338 msgid "Could not save label: {}" msgstr "" -#: ui/app.slint:331 +#: ui/app.slint:339 msgid "Could not delete label: {}" msgstr "" -#: ui/app.slint:332 +#: ui/app.slint:340 msgid "Folder created." msgstr "" -#: ui/app.slint:333 +#: ui/app.slint:341 msgid "Folder renamed." msgstr "" -#: ui/app.slint:334 +#: ui/app.slint:342 msgid "Folder deleted." msgstr "" -#: ui/app.slint:335 +#: ui/app.slint:343 msgid "Could not create folder: {}" msgstr "" -#: ui/app.slint:336 +#: ui/app.slint:344 msgid "Could not rename folder: {}" msgstr "" -#: ui/app.slint:337 +#: ui/app.slint:345 msgid "Could not delete folder: {}" msgstr "" -#: ui/app.slint:338 +#: ui/app.slint:346 msgid "Could not close preview: {}" msgstr "" -#: ui/app.slint:339 +#: ui/app.slint:347 msgid "Opened the system print view." msgstr "" -#: ui/app.slint:340 +#: ui/app.slint:348 msgid "Could not print message: {}" msgstr "" -#: ui/app.slint:341 +#: ui/app.slint:349 msgid "Opened the message in your browser." msgstr "" -#: ui/app.slint:342 +#: ui/app.slint:350 msgid "Could not open message in browser: {}" msgstr "" -#: ui/app.slint:343 +#: ui/app.slint:351 msgid "Connect an account before replying or forwarding." msgstr "" -#: ui/app.slint:344 +#: ui/app.slint:352 msgid "Choose a message first." msgstr "" -#: ui/app.slint:345 +#: ui/app.slint:353 msgid "Only drafts can be reopened for editing." msgstr "" -#: ui/app.slint:346 +#: ui/app.slint:354 msgid "Could not open draft: {}" msgstr "" -#: ui/app.slint:347 +#: ui/app.slint:355 msgid "Could not open draft: invalid account identifier" msgstr "" -#: ui/app.slint:348 +#: ui/app.slint:356 msgid "Connected account" msgstr "" -#: ui/app.slint:349 +#: ui/app.slint:357 msgid "Could not open composer: {}" msgstr "" -#: ui/app.slint:350 +#: ui/app.slint:358 msgid "Could not open composer: invalid account identifier" msgstr "" -#: ui/app.slint:355 +#: ui/app.slint:363 msgid "Attach files to this email" msgstr "" -#: ui/app.slint:356 +#: ui/app.slint:364 msgid "The platform host does not provide a document picker." msgstr "" -#: ui/app.slint:357 +#: ui/app.slint:365 msgid "Added 1 attachment." msgstr "" -#: ui/app.slint:358 +#: ui/app.slint:366 msgid "Added {} attachments." msgstr "" -#: ui/app.slint:359 +#: ui/app.slint:367 msgid "" "Attachments added: {}. Skipped: {} (duplicate, unreadable, or over the 25 MB " "total limit)." msgstr "" -#: ui/app.slint:360 +#: ui/app.slint:368 msgid "Attachment removed." msgstr "" -#: ui/app.slint:361 +#: ui/app.slint:369 msgid "Choose the account this message should be sent from." msgstr "" -#: ui/app.slint:362 +#: ui/app.slint:370 msgid "Connect an account before saving or sending mail." msgstr "" -#: ui/app.slint:363 +#: ui/app.slint:371 msgid "Message queued for delivery (action {})." msgstr "" -#: ui/app.slint:364 +#: ui/app.slint:372 msgid "Draft saved locally (#{})." msgstr "" -#: ui/app.slint:365 +#: ui/app.slint:373 msgid "Compose failed: {}" msgstr "" -#: ui/app.slint:366 -msgid "IMAP port must be a number." +#: ui/app.slint:374 +msgid "IMAP port must be between 1 and 65535." msgstr "" -#: ui/app.slint:367 -msgid "SMTP port must be a number." +#: ui/app.slint:375 +msgid "SMTP port must be between 1 and 65535." msgstr "" -#: ui/app.slint:368 +#: ui/app.slint:376 msgid "Local mail data is unavailable. Retry startup." msgstr "" -#: ui/app.slint:369 +#: ui/app.slint:377 msgid "Discovering JMAP and checking capabilities…" msgstr "" -#: ui/app.slint:370 -msgid "Testing IMAP connection…" +#: ui/app.slint:378 +msgid "Certificate imported. Connect to verify the server." msgstr "" -#: ui/app.slint:375 +#: ui/app.slint:379 +msgid "Could not load account preferences: {}" +msgstr "" + +#: ui/app.slint:380 +msgid "" +"Your edited signature was preserved. Choose No signature to keep it as " +"message text before inserting another." +msgstr "" + +#: ui/app.slint:381 +msgid "Certificate import failed: {}" +msgstr "" + +#: ui/app.slint:382 +msgid "Paste the PEM certificate into the certificate field." +msgstr "" + +#: ui/app.slint:383 +msgid "Testing IMAP and SMTP connections…" +msgstr "" + +#: ui/app.slint:388 msgid "" "Another browser authorization is already in progress; finish it or choose " "Cancel." msgstr "" -#: ui/app.slint:376 +#: ui/app.slint:389 msgid "Add app keys in Sign-in settings to enable this provider." msgstr "" -#: ui/app.slint:377 +#: ui/app.slint:390 msgid "Choose Gmail or Outlook OAuth." msgstr "" -#: ui/app.slint:378 +#: ui/app.slint:391 msgid "Complete sign-in in your browser, or choose Cancel here." msgstr "" -#: ui/app.slint:379 +#: ui/app.slint:392 msgid "Cancelling browser sign-in…" msgstr "" -#: ui/app.slint:380 +#: ui/app.slint:393 +msgid "Finishing browser sign-in…" +msgstr "" + +#: ui/app.slint:394 +msgid "Could not use that browser address: {}" +msgstr "" + +#: ui/app.slint:395 msgid "Calendar core is unavailable." msgstr "" -#: ui/app.slint:381 +#: ui/app.slint:396 msgid "Account no longer exists." msgstr "" -#: ui/app.slint:382 +#: ui/app.slint:397 msgid "Authorize calendar access in your browser." msgstr "" -#: ui/app.slint:383 +#: ui/app.slint:398 msgid "OAuth app keys saved." msgstr "" -#: ui/app.slint:384 +#: ui/app.slint:399 msgid "{} OAuth settings saved." msgstr "" -#: ui/app.slint:385 +#: ui/app.slint:400 msgid "OAuth settings failed: {}" msgstr "" -#: ui/app.slint:386 +#: ui/app.slint:401 msgid "Mail refresh failed: {}" msgstr "" -#: ui/app.slint:387 +#: ui/app.slint:402 msgid "Mail filter failed: {}" msgstr "" -#: ui/app.slint:388 +#: ui/app.slint:403 msgid "Mail pagination failed: {}" msgstr "" -#: ui/app.slint:389 +#: ui/app.slint:404 msgid "Could not mark message as read: {}" msgstr "" -#: ui/app.slint:390 ui/views/mail-reading-pane.slint:729 +#: ui/app.slint:405 ui/views/mail-reading-pane.slint:755 msgid "Loading message…" msgstr "" -#: ui/app.slint:396 ui/dialogs/settings-dialog.slint:946 ui/tray.slint:9 -#: ui/app.slint:396 ui/dialogs/settings-dialog.slint:946 ui/tray.slint:9 ui/views/onboarding-view.slint:80 +#: ui/app.slint:411 ui/dialogs/settings-dialog.slint:971 ui/tray.slint:9 +#: ui/app.slint:411 ui/dialogs/settings-dialog.slint:971 ui/tray.slint:9 ui/views/onboarding-view.slint:97 msgid "Flectar Mail" msgstr "" -#: ui/app.slint:1089 +#: ui/app.slint:1138 msgid "Showing {} of {} contacts" msgstr "" -#: ui/app.slint:1548 +#: ui/app.slint:1629 msgid "Mailbox startup failed" msgstr "" -#: ui/app.slint:1554 +#: ui/app.slint:1635 msgid "Flectar Mail could not open local storage" msgstr "" -#: ui/app.slint:1567 +#: ui/app.slint:1648 msgid "Diagnostic ID: {}" msgstr "" -#: ui/app.slint:1570 +#: ui/app.slint:1651 msgid "Diagnostic ID {}" msgstr "" -#: ui/app.slint:1575 +#: ui/app.slint:1656 msgid "Retry" msgstr "" -#: ui/app.slint:1583 +#: ui/app.slint:1664 msgid "Copy diagnostics" msgstr "" +#: ui/components/account-mail-preferences.slint:64 +msgid "Signatures & OpenPGP" +msgstr "" + +#: ui/components/account-mail-preferences.slint:65 +msgid "Choose an account to manage your signature and message protection." +msgstr "" + +#: ui/components/account-mail-preferences.slint:76 +msgid "Signatures" +msgstr "" + +#: ui/components/account-mail-preferences.slint:77 +msgid "OpenPGP" +msgstr "" + +#: ui/components/account-mail-preferences.slint:82 +msgid "Edit signature" +msgstr "" + +#: ui/components/account-mail-preferences.slint:82 +msgid "Saved separately for each account." +msgstr "" + +#: ui/components/account-mail-preferences.slint:87 +msgid "New signature" +msgstr "" + +#: ui/components/account-mail-preferences.slint:88 +msgid "Signature name" +msgstr "" + +#: ui/components/account-mail-preferences.slint:88 +msgid "Signature name, for example Work" +msgstr "" + +#: ui/components/account-mail-preferences.slint:89 +msgid "Your name\nRole · Company\nPhone · Website" +msgstr "" + +#: ui/components/account-mail-preferences.slint:90 +msgid "" +"Plain text works in every email client. You can format the inserted " +"signature in the composer." +msgstr "" + +#: ui/components/account-mail-preferences.slint:92 +msgid "Save signature" +msgstr "" + +#: ui/components/account-mail-preferences.slint:93 +#: ui/components/account-mail-preferences.slint:93 +#: ui/components/account-mail-preferences.slint:93 ui/views/contacts-view.slint:542 +#: ui/components/account-mail-preferences.slint:93 ui/views/contacts-view.slint:542 +msgid "Delete" +msgstr "" + +#: ui/components/account-mail-preferences.slint:96 +msgid "New messages" +msgstr "" + +#: ui/components/account-mail-preferences.slint:96 +msgid "Signature inserted when you start a message." +msgstr "" + +#: ui/components/account-mail-preferences.slint:102 +msgid "Replies & forwards" +msgstr "" + +#: ui/components/account-mail-preferences.slint:102 +msgid "Placed above the quoted message." +msgstr "" + +#: ui/components/account-mail-preferences.slint:107 +msgid "Save signature defaults" +msgstr "" + +#: ui/components/account-mail-preferences.slint:111 +msgid "" +"OpenPGP protects message bodies and attachments. Digital signatures let " +"recipients check who signed a message." +msgstr "" + +#: ui/components/account-mail-preferences.slint:112 +msgid "" +"Uses installed GnuPG 2.x and its secure key storage. Passphrases are entered " +"in GnuPG's unlock dialog." +msgstr "" + +#: ui/components/account-mail-preferences.slint:112 +msgid "" +"OpenPGP is unavailable on this mobile build. Protected accounts cannot send " +"without a supported encryption engine." +msgstr "" + +#: ui/components/account-mail-preferences.slint:113 +msgid "Create a new key" +msgstr "" + +#: ui/components/account-mail-preferences.slint:114 +msgid "" +"New keys expire in two years. GnuPG asks you to protect the private key with " +"a passphrase." +msgstr "" + +#: ui/components/account-mail-preferences.slint:115 +msgid "Your full OpenPGP fingerprint" +msgstr "" + +#: ui/components/account-mail-preferences.slint:115 +msgid "Your full OpenPGP key fingerprint" +msgstr "" + +#: ui/components/account-mail-preferences.slint:117 +msgid "Digitally sign messages" +msgstr "" + +#: ui/components/account-mail-preferences.slint:117 +msgid "Proves possession of your private key; does not encrypt the message." +msgstr "" + +#: ui/components/account-mail-preferences.slint:122 +msgid "Require encryption" +msgstr "" + +#: ui/components/account-mail-preferences.slint:122 +msgid "Blocks sending unless every recipient has a verified, usable key." +msgstr "" + +#: ui/components/account-mail-preferences.slint:126 +msgid "Verified recipient keys" +msgstr "" + +#: ui/components/account-mail-preferences.slint:127 +msgid "" +"Compare fingerprints with recipients through a separate trusted channel, " +"then enter one email=fingerprint per line. Importing a key alone does not " +"verify it." +msgstr "" + +#: ui/components/account-mail-preferences.slint:128 +msgid "person@example.org=FULL_FINGERPRINT" +msgstr "" + +#: ui/components/account-mail-preferences.slint:129 +msgid "Save OpenPGP settings" +msgstr "" + +#: ui/components/account-mail-preferences.slint:131 +#: ui/components/account-mail-preferences.slint:131 +msgid "Import key" +msgstr "" + +#: ui/components/account-mail-preferences.slint:132 +#: ui/components/account-mail-preferences.slint:132 +msgid "Export public key" +msgstr "" + +#: ui/components/account-mail-preferences.slint:133 +#: ui/components/account-mail-preferences.slint:133 +msgid "Show keys" +msgstr "" + +#: ui/components/account-mail-preferences.slint:141 +msgid "Open message file (.eml)" +msgstr "" + +#: ui/components/account-mail-preferences.slint:143 +msgid "" +"Manage expiry, revocation and private-key backups in GnuPG. Only public keys " +"are exported here." +msgstr "" + +#: ui/components/account-mail-preferences.slint:144 +msgid "" +"Subjects and addresses remain visible. Protected drafts stay on this device. " +"Existing server drafts are not retroactively protected. S/MIME certificates " +"are not supported." +msgstr "" + +#: ui/components/account-mail-preferences.slint:147 +#: ui/components/account-mail-preferences.slint:147 +msgid "Working…" +msgstr "" + +#: ui/components/account-mail-preferences.slint:159 +msgid "OpenPGP message" +msgstr "" + +#: ui/components/account-mail-preferences.slint:162 +msgid "" +"Decrypted content is shown as text, without remote content. It is not added " +"to search or saved to the mail database." +msgstr "" + +#: ui/components/account-mail-preferences.slint:163 +msgid "Export decrypted message" +msgstr "" + +#: ui/components/account-mail-preferences.slint:164 +#: ui/components/account-mail-preferences.slint:164 +#: ui/components/account-mail-preferences.slint:164 +#: ui/components/account-mail-preferences.slint:164 +#: ui/components/account-mail-preferences.slint:164 +#: ui/components/account-mail-preferences.slint:164 ui/views/files-view.slint:390 +#: ui/components/account-mail-preferences.slint:164 ui/views/files-view.slint:390 ui/views/files-view.slint:570 +#: ui/components/account-mail-preferences.slint:164 ui/views/files-view.slint:390 ui/views/files-view.slint:570 +msgid "Close" +msgstr "" + #: ui/components/calendar.slint:60 ui/components/calendar.slint:268 #: ui/components/calendar.slint:60 ui/components/calendar.slint:268 ui/components/calendar.slint:772 msgid "View details for {}" @@ -1145,8 +1385,8 @@ msgstr "" #: ui/components/calendar.slint:268 ui/components/calendar.slint:278 #: ui/components/calendar.slint:268 ui/components/calendar.slint:278 ui/components/calendar.slint:317 -#: ui/components/calendar.slint:268 ui/components/calendar.slint:278 ui/components/calendar.slint:317 ui/components/product-rail.slint:109 -#: ui/components/calendar.slint:268 ui/components/calendar.slint:278 ui/components/calendar.slint:317 ui/components/product-rail.slint:109 ui/dialogs/settings-dialog.slint:709 +#: ui/components/calendar.slint:268 ui/components/calendar.slint:278 ui/components/calendar.slint:317 ui/components/product-rail.slint:111 +#: ui/components/calendar.slint:268 ui/components/calendar.slint:278 ui/components/calendar.slint:317 ui/components/product-rail.slint:111 ui/dialogs/settings-dialog.slint:733 msgid "Calendar" msgstr "" @@ -1345,7 +1585,7 @@ msgstr "" msgid "The selected text will open this address." msgstr "" -#: ui/components/composer-actions.slint:348 ui/views/contacts-view.slint:700 +#: ui/components/composer-actions.slint:348 ui/views/contacts-view.slint:642 msgid "https://example.com" msgstr "" @@ -1356,9 +1596,10 @@ msgstr "" #: ui/components/composer-actions.slint:393 #: ui/components/composer-actions.slint:393 #: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 -#: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 ui/dialogs/settings-dialog.slint:774 -#: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 ui/dialogs/settings-dialog.slint:774 ui/views/contacts-view.slint:596 -#: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 ui/dialogs/settings-dialog.slint:774 ui/views/contacts-view.slint:596 +#: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 ui/dialogs/settings-dialog.slint:799 +#: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 ui/dialogs/settings-dialog.slint:799 ui/views/contacts-view.slint:538 +#: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 ui/dialogs/settings-dialog.slint:799 ui/views/contacts-view.slint:538 ui/views/files-view.slint:534 +#: ui/components/composer-actions.slint:393 ui/dialogs/folder-dialog.slint:118 ui/dialogs/settings-dialog.slint:799 ui/views/contacts-view.slint:538 ui/views/files-view.slint:534 msgid "Cancel" msgstr "" @@ -1378,18 +1619,18 @@ msgstr "" msgid "Keep" msgstr "" -#: ui/components/composer-fields.slint:35 ui/views/mail-reading-pane.slint:541 -#: ui/components/composer-fields.slint:35 ui/views/mail-reading-pane.slint:541 ui/views/mail-reading-pane.slint:1169 +#: ui/components/composer-fields.slint:35 ui/views/mail-reading-pane.slint:552 +#: ui/components/composer-fields.slint:35 ui/views/mail-reading-pane.slint:552 ui/views/mail-reading-pane.slint:1227 msgid "Reply" msgstr "" -#: ui/components/composer-fields.slint:36 ui/views/mail-reading-pane.slint:542 -#: ui/components/composer-fields.slint:36 ui/views/mail-reading-pane.slint:542 ui/views/mail-reading-pane.slint:1186 +#: ui/components/composer-fields.slint:36 ui/views/mail-reading-pane.slint:553 +#: ui/components/composer-fields.slint:36 ui/views/mail-reading-pane.slint:553 ui/views/mail-reading-pane.slint:1244 msgid "Reply all" msgstr "" -#: ui/components/composer-fields.slint:37 ui/views/mail-reading-pane.slint:543 -#: ui/components/composer-fields.slint:37 ui/views/mail-reading-pane.slint:543 ui/views/mail-reading-pane.slint:1203 +#: ui/components/composer-fields.slint:37 ui/views/mail-reading-pane.slint:554 +#: ui/components/composer-fields.slint:37 ui/views/mail-reading-pane.slint:554 ui/views/mail-reading-pane.slint:1261 msgid "Forward" msgstr "" @@ -1401,13 +1642,6 @@ msgstr "" msgid "New message" msgstr "" -#: ui/components/composer-fields.slint:55 -#: ui/components/composer-fields.slint:55 -#: ui/components/composer-fields.slint:55 -#: ui/components/composer-fields.slint:55 -msgid "Close" -msgstr "" - #: ui/components/composer-fields.slint:94 msgid "From" msgstr "" @@ -1458,11 +1692,11 @@ msgstr "" msgid "On" msgstr "" -#: ui/components/controls.slint:160 ui/dialogs/settings-dialog.slint:114 +#: ui/components/controls.slint:160 ui/dialogs/settings-dialog.slint:127 msgid "Off" msgstr "" -#: ui/components/controls.slint:185 ui/views/mail-list-pane.slint:251 +#: ui/components/controls.slint:185 ui/views/mail-list-pane.slint:197 msgid "Clear selection" msgstr "" @@ -1470,7 +1704,23 @@ msgstr "" msgid "Select" msgstr "" -#: ui/components/email-composer.slint:306 +#: ui/components/document-preview.slint:43 +msgid "Previous page" +msgstr "" + +#: ui/components/document-preview.slint:44 +msgid "Page {} of {}" +msgstr "" + +#: ui/components/document-preview.slint:45 +msgid "Next page" +msgstr "" + +#: ui/components/email-composer.slint:225 +msgid "Signature" +msgstr "" + +#: ui/components/email-composer.slint:318 msgid "Write your message…" msgstr "" @@ -1484,96 +1734,205 @@ msgstr "" msgid "Copy link" msgstr "" -#: ui/components/product-rail.slint:98 ui/components/product-rail.slint:245 -#: ui/components/product-rail.slint:98 ui/components/product-rail.slint:245 -msgid "Mail" +#: ui/components/mail-security-fields.slint:17 +msgid "Connection security" msgstr "" -#: ui/components/product-rail.slint:120 ui/components/product-rail.slint:267 -#: ui/components/product-rail.slint:120 ui/components/product-rail.slint:267 -msgid "Contacts" +#: ui/components/mail-security-fields.slint:27 +msgid "IMAP encryption" msgstr "" -#: ui/components/product-rail.slint:133 ui/components/product-rail.slint:278 -msgid "Settings" +#: ui/components/mail-security-fields.slint:32 +#: ui/components/mail-security-fields.slint:32 +msgid "Automatic" msgstr "" -#: ui/components/product-rail.slint:224 -msgid "Primary navigation" +#: ui/components/mail-security-fields.slint:41 +msgid "SMTP encryption" msgstr "" -#: ui/components/sidebar-controls.slint:156 -msgid "Collapse folder" +#: ui/components/mail-security-fields.slint:53 +msgid "" +"Automatic: IMAP uses STARTTLS on 143, SSL/TLS otherwise. SMTP uses SSL/TLS " +"on 465, STARTTLS otherwise." msgstr "" -#: ui/components/sidebar-controls.slint:156 -msgid "Expand folder" +#: ui/components/mail-security-fields.slint:58 +msgid "Manage trusted certificate" msgstr "" -#: ui/components/sidebar-controls.slint:171 -msgid "New subfolder" +#: ui/components/mail-security-fields.slint:58 +msgid "Custom server certificate…" msgstr "" -#: ui/components/sidebar-controls.slint:176 ui/dialogs/folder-dialog.slint:27 -#: ui/components/sidebar-controls.slint:176 ui/dialogs/folder-dialog.slint:27 -msgid "Rename folder" +#: ui/components/mail-security-fields.slint:66 +msgid "" +"For Proton Bridge, export its certificate from Bridge settings and import it " +"here. Only trust certificates from your server administrator. Server names " +"and expiry dates are still checked." msgstr "" -#: ui/components/sidebar-controls.slint:181 -msgid "Delete folder" +#: ui/components/mail-security-fields.slint:69 +msgid "Import certificate file" msgstr "" -#: ui/components/sidebar-controls.slint:193 ui/dialogs/folder-dialog.slint:26 -#: ui/components/sidebar-controls.slint:193 ui/dialogs/folder-dialog.slint:26 -msgid "Rename label" +#: ui/components/mail-security-fields.slint:71 +msgid "PEM certificate only. Do not paste a private key." msgstr "" -#: ui/components/sidebar-controls.slint:198 -msgid "Delete label" +#: ui/components/mail-security-fields.slint:72 +msgid "Remove certificate" msgstr "" -#: ui/components/sidebar.slint:52 ui/views/mail-list-pane.slint:577 -msgid "New email" +#: ui/components/mail-security-fields.slint:75 +msgid "Custom certificate added for this account." msgstr "" -#: ui/components/sidebar.slint:93 ui/views/contacts-view.slint:238 -msgid "UNIFIED" +#: ui/components/oauth-browser-help.slint:42 +#: ui/components/oauth-browser-help.slint:42 +msgid "Browser did not open — show sign-in help" msgstr "" -#: ui/components/sidebar.slint:101 -msgid "All Inboxes" +#: ui/components/oauth-browser-help.slint:42 +#: ui/components/oauth-browser-help.slint:42 +msgid "Having trouble with browser sign-in?" msgstr "" -#: ui/components/sidebar.slint:116 -msgid "Inbox" +#: ui/components/oauth-browser-help.slint:72 +msgid "" +"Flectar could not reach the system browser from this app environment. Open " +"the link again or copy it into a browser on this device." msgstr "" -#: ui/components/sidebar.slint:175 -msgid "Categories" +#: ui/components/oauth-browser-help.slint:72 +msgid "" +"Open the sign-in page again or copy its link into a browser on this device." msgstr "" -#: ui/components/sidebar.slint:192 -msgid "Important" +#: ui/components/oauth-browser-help.slint:79 +msgid "Open browser" msgstr "" -#: ui/components/sidebar.slint:211 -msgid "Other" +#: ui/components/oauth-browser-help.slint:80 +msgid "Copy sign-in link" msgstr "" -#: ui/components/sidebar.slint:254 ui/views/mail-reading-pane.slint:386 -msgid "Labels" +#: ui/components/oauth-browser-help.slint:83 +msgid "" +"If the final browser page says localhost can’t be reached, copy its full " +"address and paste it below." msgstr "" -#: ui/components/sidebar.slint:302 ui/views/contacts-view.slint:238 -msgid "ACCOUNTS" +#: ui/components/oauth-browser-help.slint:88 +msgid "Final browser address" msgstr "" -#: ui/components/sidebar.slint:386 ui/dialogs/folder-dialog.slint:25 -#: ui/components/sidebar.slint:386 ui/dialogs/folder-dialog.slint:25 -msgid "New folder" +#: ui/components/oauth-browser-help.slint:89 +msgid "Final localhost address" msgstr "" -#: ui/components/sidebar.slint:409 ui/components/sidebar.slint:428 +#: ui/components/oauth-browser-help.slint:100 +msgid "Finish sign-in" +msgstr "" + +#: ui/components/product-rail.slint:100 ui/components/product-rail.slint:259 +#: ui/components/product-rail.slint:100 ui/components/product-rail.slint:259 +msgid "Mail" +msgstr "" + +#: ui/components/product-rail.slint:122 ui/components/product-rail.slint:281 +#: ui/components/product-rail.slint:122 ui/components/product-rail.slint:281 ui/views/files-view.slint:456 +msgid "Files" +msgstr "" + +#: ui/components/product-rail.slint:133 ui/components/product-rail.slint:292 +#: ui/components/product-rail.slint:133 ui/components/product-rail.slint:292 +msgid "Contacts" +msgstr "" + +#: ui/components/product-rail.slint:147 ui/components/product-rail.slint:303 +msgid "Settings" +msgstr "" + +#: ui/components/product-rail.slint:238 +msgid "Primary navigation" +msgstr "" + +#: ui/components/sidebar-controls.slint:156 +msgid "Collapse folder" +msgstr "" + +#: ui/components/sidebar-controls.slint:156 +msgid "Expand folder" +msgstr "" + +#: ui/components/sidebar-controls.slint:171 +msgid "New subfolder" +msgstr "" + +#: ui/components/sidebar-controls.slint:176 ui/dialogs/folder-dialog.slint:27 +#: ui/components/sidebar-controls.slint:176 ui/dialogs/folder-dialog.slint:27 +msgid "Rename folder" +msgstr "" + +#: ui/components/sidebar-controls.slint:181 +msgid "Delete folder" +msgstr "" + +#: ui/components/sidebar-controls.slint:193 ui/dialogs/folder-dialog.slint:26 +#: ui/components/sidebar-controls.slint:193 ui/dialogs/folder-dialog.slint:26 +msgid "Rename label" +msgstr "" + +#: ui/components/sidebar-controls.slint:198 +msgid "Delete label" +msgstr "" + +#: ui/components/sidebar.slint:52 ui/views/mail-list-pane.slint:578 +msgid "New email" +msgstr "" + +#: ui/components/sidebar.slint:93 ui/views/contacts-view.slint:180 +#: ui/components/sidebar.slint:93 ui/views/contacts-view.slint:180 +msgid "UNIFIED" +msgstr "" + +#: ui/components/sidebar.slint:101 +msgid "All Inboxes" +msgstr "" + +#: ui/components/sidebar.slint:116 +msgid "Inbox" +msgstr "" + +#: ui/components/sidebar.slint:175 +msgid "Categories" +msgstr "" + +#: ui/components/sidebar.slint:192 +msgid "Important" +msgstr "" + +#: ui/components/sidebar.slint:211 +msgid "Other" +msgstr "" + +#: ui/components/sidebar.slint:254 ui/views/mail-reading-pane.slint:397 +msgid "Labels" +msgstr "" + +#: ui/components/sidebar.slint:302 ui/views/contacts-view.slint:180 +#: ui/components/sidebar.slint:302 ui/views/contacts-view.slint:180 +msgid "ACCOUNTS" +msgstr "" + +#: ui/components/sidebar.slint:386 ui/dialogs/folder-dialog.slint:25 +#: ui/components/sidebar.slint:386 ui/dialogs/folder-dialog.slint:25 ui/views/files-view.slint:279 +#: ui/components/sidebar.slint:386 ui/dialogs/folder-dialog.slint:25 ui/views/files-view.slint:279 +msgid "New folder" +msgstr "" + +#: ui/components/sidebar.slint:409 ui/components/sidebar.slint:428 msgid "Add account" msgstr "" @@ -1620,7 +1979,7 @@ msgid "Palette" msgstr "" #: ui/components/theme-editor.slint:567 ui/components/theme-editor.slint:579 -#: ui/components/theme-editor.slint:567 ui/components/theme-editor.slint:579 ui/dialogs/settings-dialog.slint:343 +#: ui/components/theme-editor.slint:567 ui/components/theme-editor.slint:579 ui/dialogs/settings-dialog.slint:359 msgid "Default" msgstr "" @@ -1697,7 +2056,7 @@ msgstr "" #: ui/components/theme-editor.slint:692 #: ui/components/theme-editor.slint:692 ui/dialogs/folder-dialog.slint:122 -#: ui/components/theme-editor.slint:692 ui/dialogs/folder-dialog.slint:122 +#: ui/components/theme-editor.slint:692 ui/dialogs/folder-dialog.slint:122 ui/views/mail-reading-pane.slint:1421 msgid "Save" msgstr "" @@ -1705,6 +2064,35 @@ msgstr "" msgid "Save palette" msgstr "" +#: ui/components/zoom-controls.slint:19 +msgid "Zoom out" +msgstr "" + +#: ui/components/zoom-controls.slint:20 +msgid "Reset zoom ({}%)" +msgstr "" + +#: ui/components/zoom-controls.slint:27 +msgid "Zoom percentage" +msgstr "" + +#: ui/components/zoom-controls.slint:31 +msgid "Zoom in" +msgstr "" + +#: ui/components/zoom-controls.slint:32 +msgid "Fit to width" +msgstr "" + +#: ui/dialogs/attachment-preview-dialog.slint:30 ui/views/files-view.slint:397 +#: ui/dialogs/attachment-preview-dialog.slint:30 ui/views/files-view.slint:397 +msgid "Download" +msgstr "" + +#: ui/dialogs/attachment-preview-dialog.slint:43 ui/views/files-view.slint:410 +msgid "Loading preview…" +msgstr "" + #: ui/dialogs/calendar-details-dialog.slint:82 msgid "Close calendar details" msgstr "" @@ -1786,11 +2174,6 @@ msgstr "" msgid "Open meeting link" msgstr "" -#: ui/dialogs/calendar-details-dialog.slint:209 -#: ui/dialogs/calendar-details-dialog.slint:209 ui/views/contacts-view.slint:600 -msgid "Delete" -msgstr "" - #: ui/dialogs/calendar-details-dialog.slint:219 #: ui/dialogs/calendar-details-dialog.slint:219 msgid "Edit" @@ -1824,7 +2207,7 @@ msgstr "" msgid "START TIME" msgstr "" -#: ui/dialogs/calendar-event-dialog.slint:53 ui/views/contacts-view.slint:681 +#: ui/dialogs/calendar-event-dialog.slint:53 ui/views/contacts-view.slint:623 msgid "YYYY-MM-DD" msgstr "" @@ -1894,7 +2277,7 @@ msgid "Inside “{}”" msgstr "" #: ui/dialogs/oauth-setup-dialog.slint:27 -#: ui/dialogs/oauth-setup-dialog.slint:27 ui/views/onboarding-view.slint:157 +#: ui/dialogs/oauth-setup-dialog.slint:27 ui/views/onboarding-view.slint:175 msgid "Sign-in settings" msgstr "" @@ -1958,7 +2341,7 @@ msgstr "" msgid "Microsoft client ID" msgstr "" -#: ui/dialogs/oauth-setup-dialog.slint:95 ui/dialogs/settings-dialog.slint:762 +#: ui/dialogs/oauth-setup-dialog.slint:95 ui/dialogs/settings-dialog.slint:787 msgid "Microsoft Entra desktop client ID" msgstr "" @@ -2000,7 +2383,7 @@ msgstr "" #: ui/dialogs/rendering-info-dialog.slint:25 #: ui/dialogs/rendering-info-dialog.slint:25 -#: ui/dialogs/rendering-info-dialog.slint:25 ui/views/mail-reading-pane.slint:1251 +#: ui/dialogs/rendering-info-dialog.slint:25 ui/views/mail-reading-pane.slint:1309 msgid "About email rendering" msgstr "" @@ -2025,673 +2408,687 @@ msgstr "" msgid "Report issue" msgstr "" -#: ui/dialogs/settings-dialog.slint:109 +#: ui/dialogs/settings-dialog.slint:122 msgid "Every minute" msgstr "" -#: ui/dialogs/settings-dialog.slint:110 +#: ui/dialogs/settings-dialog.slint:123 msgid "Every 5 minutes" msgstr "" -#: ui/dialogs/settings-dialog.slint:111 +#: ui/dialogs/settings-dialog.slint:124 msgid "Every 15 minutes" msgstr "" -#: ui/dialogs/settings-dialog.slint:115 +#: ui/dialogs/settings-dialog.slint:128 msgid "Important mail" msgstr "" -#: ui/dialogs/settings-dialog.slint:116 +#: ui/dialogs/settings-dialog.slint:129 msgid "All new mail" msgstr "" -#: ui/dialogs/settings-dialog.slint:119 +#: ui/dialogs/settings-dialog.slint:132 msgid "1 month" msgstr "" -#: ui/dialogs/settings-dialog.slint:120 +#: ui/dialogs/settings-dialog.slint:133 msgid "3 months" msgstr "" -#: ui/dialogs/settings-dialog.slint:121 +#: ui/dialogs/settings-dialog.slint:134 msgid "6 months · Recommended" msgstr "" -#: ui/dialogs/settings-dialog.slint:122 +#: ui/dialogs/settings-dialog.slint:135 msgid "1 year" msgstr "" -#: ui/dialogs/settings-dialog.slint:123 ui/views/mail-list-pane.slint:260 +#: ui/dialogs/settings-dialog.slint:136 ui/views/mail-list-pane.slint:208 msgid "All mail" msgstr "" -#: ui/dialogs/settings-dialog.slint:217 ui/dialogs/settings-dialog.slint:1087 +#: ui/dialogs/settings-dialog.slint:233 ui/dialogs/settings-dialog.slint:1112 msgid "SETTINGS" msgstr "" -#: ui/dialogs/settings-dialog.slint:219 ui/dialogs/settings-dialog.slint:246 -#: ui/dialogs/settings-dialog.slint:219 ui/dialogs/settings-dialog.slint:246 +#: ui/dialogs/settings-dialog.slint:235 ui/dialogs/settings-dialog.slint:262 +#: ui/dialogs/settings-dialog.slint:235 ui/dialogs/settings-dialog.slint:262 msgid "General" msgstr "" -#: ui/dialogs/settings-dialog.slint:220 ui/dialogs/settings-dialog.slint:542 -#: ui/dialogs/settings-dialog.slint:220 ui/dialogs/settings-dialog.slint:542 +#: ui/dialogs/settings-dialog.slint:236 ui/dialogs/settings-dialog.slint:558 +#: ui/dialogs/settings-dialog.slint:236 ui/dialogs/settings-dialog.slint:558 msgid "Accounts" msgstr "" -#: ui/dialogs/settings-dialog.slint:221 ui/dialogs/settings-dialog.slint:848 -#: ui/dialogs/settings-dialog.slint:221 ui/dialogs/settings-dialog.slint:848 +#: ui/dialogs/settings-dialog.slint:237 ui/dialogs/settings-dialog.slint:873 +#: ui/dialogs/settings-dialog.slint:237 ui/dialogs/settings-dialog.slint:873 msgid "Notifications" msgstr "" -#: ui/dialogs/settings-dialog.slint:222 ui/dialogs/settings-dialog.slint:1091 +#: ui/dialogs/settings-dialog.slint:238 ui/dialogs/settings-dialog.slint:1116 msgid "Shortcuts" msgstr "" -#: ui/dialogs/settings-dialog.slint:223 ui/dialogs/settings-dialog.slint:792 -#: ui/dialogs/settings-dialog.slint:223 ui/dialogs/settings-dialog.slint:792 ui/dialogs/settings-dialog.slint:1092 +#: ui/dialogs/settings-dialog.slint:239 ui/dialogs/settings-dialog.slint:817 +#: ui/dialogs/settings-dialog.slint:239 ui/dialogs/settings-dialog.slint:817 ui/dialogs/settings-dialog.slint:1117 msgid "Data management" msgstr "" -#: ui/dialogs/settings-dialog.slint:224 ui/dialogs/settings-dialog.slint:922 -#: ui/dialogs/settings-dialog.slint:224 ui/dialogs/settings-dialog.slint:922 +#: ui/dialogs/settings-dialog.slint:240 ui/dialogs/settings-dialog.slint:947 +#: ui/dialogs/settings-dialog.slint:240 ui/dialogs/settings-dialog.slint:947 msgid "About" msgstr "" -#: ui/dialogs/settings-dialog.slint:247 +#: ui/dialogs/settings-dialog.slint:263 msgid "Manage appearance, privacy, and synchronization." msgstr "" -#: ui/dialogs/settings-dialog.slint:293 +#: ui/dialogs/settings-dialog.slint:309 msgid "Interface" msgstr "" -#: ui/dialogs/settings-dialog.slint:296 +#: ui/dialogs/settings-dialog.slint:312 msgid "Monochrome sidebar icons" msgstr "" -#: ui/dialogs/settings-dialog.slint:297 +#: ui/dialogs/settings-dialog.slint:313 msgid "Use one neutral color for mailbox and folder icons." msgstr "" -#: ui/dialogs/settings-dialog.slint:309 +#: ui/dialogs/settings-dialog.slint:325 msgid "No avatars" msgstr "" -#: ui/dialogs/settings-dialog.slint:310 +#: ui/dialogs/settings-dialog.slint:326 msgid "Hide sender images in the message list and reader." msgstr "" -#: ui/dialogs/settings-dialog.slint:323 ui/dialogs/settings-dialog.slint:339 +#: ui/dialogs/settings-dialog.slint:339 ui/dialogs/settings-dialog.slint:355 msgid "Workspace layout" msgstr "" -#: ui/dialogs/settings-dialog.slint:330 ui/dialogs/settings-dialog.slint:344 +#: ui/dialogs/settings-dialog.slint:346 ui/dialogs/settings-dialog.slint:360 msgid "Minimal" msgstr "" -#: ui/dialogs/settings-dialog.slint:362 +#: ui/dialogs/settings-dialog.slint:378 msgid "Language" msgstr "" -#: ui/dialogs/settings-dialog.slint:366 +#: ui/dialogs/settings-dialog.slint:382 msgid "System language" msgstr "" -#: ui/dialogs/settings-dialog.slint:367 +#: ui/dialogs/settings-dialog.slint:383 msgid "Spanish" msgstr "" -#: ui/dialogs/settings-dialog.slint:368 +#: ui/dialogs/settings-dialog.slint:384 msgid "English" msgstr "" -#: ui/dialogs/settings-dialog.slint:385 +#: ui/dialogs/settings-dialog.slint:401 msgid "Inbox behavior" msgstr "" -#: ui/dialogs/settings-dialog.slint:388 +#: ui/dialogs/settings-dialog.slint:404 msgid "Unified Inbox" msgstr "" -#: ui/dialogs/settings-dialog.slint:390 +#: ui/dialogs/settings-dialog.slint:406 msgid "DEFAULT" msgstr "" -#: ui/dialogs/settings-dialog.slint:392 +#: ui/dialogs/settings-dialog.slint:408 msgid "Combines incoming messages from every connected account." msgstr "" -#: ui/dialogs/settings-dialog.slint:401 +#: ui/dialogs/settings-dialog.slint:417 msgid "Mark messages as read when opened" msgstr "" -#: ui/dialogs/settings-dialog.slint:402 +#: ui/dialogs/settings-dialog.slint:418 msgid "Turn this off to keep unread status until you change it manually." msgstr "" -#: ui/dialogs/settings-dialog.slint:429 +#: ui/dialogs/settings-dialog.slint:445 msgid "Remote images" msgstr "" -#: ui/dialogs/settings-dialog.slint:430 +#: ui/dialogs/settings-dialog.slint:446 msgid "Load sender favicons and images embedded in message bodies." msgstr "" -#: ui/dialogs/settings-dialog.slint:430 +#: ui/dialogs/settings-dialog.slint:446 msgid "Unavailable in this minimal build." msgstr "" -#: ui/dialogs/settings-dialog.slint:431 +#: ui/dialogs/settings-dialog.slint:447 msgid "Off by default to block tracking pixels and minimize memory use." msgstr "" -#: ui/dialogs/settings-dialog.slint:457 +#: ui/dialogs/settings-dialog.slint:473 msgid "Keep running in system tray" msgstr "" -#: ui/dialogs/settings-dialog.slint:458 +#: ui/dialogs/settings-dialog.slint:474 msgid "Closing the window hides Flectar Mail while mail continues syncing." msgstr "" -#: ui/dialogs/settings-dialog.slint:459 +#: ui/dialogs/settings-dialog.slint:475 msgid "Disabled by default; Quit from the tray menu stops the app." msgstr "" -#: ui/dialogs/settings-dialog.slint:484 +#: ui/dialogs/settings-dialog.slint:500 msgid "Sync and notifications" msgstr "" -#: ui/dialogs/settings-dialog.slint:487 +#: ui/dialogs/settings-dialog.slint:503 msgid "Check for new mail" msgstr "" -#: ui/dialogs/settings-dialog.slint:488 +#: ui/dialogs/settings-dialog.slint:504 msgid "How often to refresh when an instant provider update is unavailable." msgstr "" -#: ui/dialogs/settings-dialog.slint:498 ui/dialogs/settings-dialog.slint:865 +#: ui/dialogs/settings-dialog.slint:514 ui/dialogs/settings-dialog.slint:890 msgid "Desktop notifications" msgstr "" -#: ui/dialogs/settings-dialog.slint:499 ui/dialogs/settings-dialog.slint:866 +#: ui/dialogs/settings-dialog.slint:515 ui/dialogs/settings-dialog.slint:891 msgid "Choose which incoming messages can alert you." msgstr "" -#: ui/dialogs/settings-dialog.slint:512 +#: ui/dialogs/settings-dialog.slint:528 msgid "Provider synchronization is ready." msgstr "" -#: ui/dialogs/settings-dialog.slint:520 ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:536 ui/dialogs/settings-dialog.slint:694 msgid "Syncing…" msgstr "" -#: ui/dialogs/settings-dialog.slint:520 +#: ui/dialogs/settings-dialog.slint:536 msgid "Sync now" msgstr "" -#: ui/dialogs/settings-dialog.slint:543 +#: ui/dialogs/settings-dialog.slint:559 msgid "" "Manage connected mailboxes and add providers. Drag an account to change its " "order everywhere." msgstr "" -#: ui/dialogs/settings-dialog.slint:563 ui/dialogs/settings-dialog.slint:607 +#: ui/dialogs/settings-dialog.slint:578 ui/dialogs/settings-dialog.slint:622 msgid "Add Gmail" msgstr "" -#: ui/dialogs/settings-dialog.slint:575 ui/dialogs/settings-dialog.slint:608 +#: ui/dialogs/settings-dialog.slint:590 ui/dialogs/settings-dialog.slint:623 msgid "Add Outlook" msgstr "" -#: ui/dialogs/settings-dialog.slint:587 ui/dialogs/settings-dialog.slint:609 +#: ui/dialogs/settings-dialog.slint:602 ui/dialogs/settings-dialog.slint:624 msgid "Add mail account" msgstr "" -#: ui/dialogs/settings-dialog.slint:600 ui/dialogs/settings-dialog.slint:610 -#: ui/dialogs/settings-dialog.slint:600 ui/dialogs/settings-dialog.slint:610 +#: ui/dialogs/settings-dialog.slint:615 ui/dialogs/settings-dialog.slint:625 +#: ui/dialogs/settings-dialog.slint:615 ui/dialogs/settings-dialog.slint:625 msgid "Cancel sign-in" msgstr "" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Offline · retrying automatically" msgstr "" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Sign-in required" msgstr "" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Connected · JMAP" msgstr "" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Connected · IMAP + SMTP" msgstr "" -#: ui/dialogs/settings-dialog.slint:673 +#: ui/dialogs/settings-dialog.slint:694 msgid "Account status: {}" msgstr "" -#: ui/dialogs/settings-dialog.slint:677 +#: ui/dialogs/settings-dialog.slint:698 msgid "Mail synchronization error: {}" msgstr "" -#: ui/dialogs/settings-dialog.slint:686 +#: ui/dialogs/settings-dialog.slint:707 msgid "Reconnect" msgstr "" -#: ui/dialogs/settings-dialog.slint:716 +#: ui/dialogs/settings-dialog.slint:740 msgid "Add calendar" msgstr "" -#: ui/dialogs/settings-dialog.slint:716 +#: ui/dialogs/settings-dialog.slint:740 msgid "Retry calendar" msgstr "" -#: ui/dialogs/settings-dialog.slint:723 +#: ui/dialogs/settings-dialog.slint:747 msgid "Set up CalDAV" msgstr "" -#: ui/dialogs/settings-dialog.slint:742 +#: ui/dialogs/settings-dialog.slint:766 msgid "Mail history" msgstr "" -#: ui/dialogs/settings-dialog.slint:744 +#: ui/dialogs/settings-dialog.slint:768 msgid "" "Download the full mailbox. Large accounts can take considerable time and " "storage." msgstr "" -#: ui/dialogs/settings-dialog.slint:745 +#: ui/dialogs/settings-dialog.slint:769 msgid "How far back to download. Mail already stored on this device is kept." msgstr "" -#: ui/dialogs/settings-dialog.slint:756 +#: ui/dialogs/settings-dialog.slint:781 msgid "OAuth app credentials" msgstr "" -#: ui/dialogs/settings-dialog.slint:756 +#: ui/dialogs/settings-dialog.slint:781 msgid "Custom desktop app credentials are configured." msgstr "" -#: ui/dialogs/settings-dialog.slint:756 +#: ui/dialogs/settings-dialog.slint:781 msgid "Advanced: use your own Gmail or Microsoft desktop app registration." msgstr "" -#: ui/dialogs/settings-dialog.slint:757 +#: ui/dialogs/settings-dialog.slint:782 msgid "Hide" msgstr "" -#: ui/dialogs/settings-dialog.slint:757 +#: ui/dialogs/settings-dialog.slint:782 msgid "Configure" msgstr "" -#: ui/dialogs/settings-dialog.slint:760 ui/dialogs/settings-dialog.slint:761 +#: ui/dialogs/settings-dialog.slint:785 ui/dialogs/settings-dialog.slint:786 msgid "Gmail desktop client ID" msgstr "" -#: ui/dialogs/settings-dialog.slint:760 ui/dialogs/settings-dialog.slint:761 +#: ui/dialogs/settings-dialog.slint:785 ui/dialogs/settings-dialog.slint:786 msgid "Gmail client secret (optional)" msgstr "" -#: ui/dialogs/settings-dialog.slint:760 +#: ui/dialogs/settings-dialog.slint:785 msgid "Save Gmail" msgstr "" -#: ui/dialogs/settings-dialog.slint:761 +#: ui/dialogs/settings-dialog.slint:786 msgid "Save Gmail settings" msgstr "" -#: ui/dialogs/settings-dialog.slint:762 +#: ui/dialogs/settings-dialog.slint:787 msgid "Save Outlook" msgstr "" -#: ui/dialogs/settings-dialog.slint:763 +#: ui/dialogs/settings-dialog.slint:788 msgid "Outlook public desktop clients use PKCE and do not need a client secret." msgstr "" -#: ui/dialogs/settings-dialog.slint:769 +#: ui/dialogs/settings-dialog.slint:794 msgid "Connect CalDAV" msgstr "" -#: ui/dialogs/settings-dialog.slint:769 +#: ui/dialogs/settings-dialog.slint:794 msgid "" "Works with iCloud, Fastmail, Nextcloud, and standards-compliant CalDAV " "servers." msgstr "" -#: ui/dialogs/settings-dialog.slint:772 +#: ui/dialogs/settings-dialog.slint:797 msgid "https://calendar.example.com/" msgstr "" -#: ui/dialogs/settings-dialog.slint:773 ui/dialogs/settings-dialog.slint:1170 -#: ui/dialogs/settings-dialog.slint:773 ui/dialogs/settings-dialog.slint:1170 +#: ui/dialogs/settings-dialog.slint:798 ui/dialogs/settings-dialog.slint:1201 +#: ui/dialogs/settings-dialog.slint:798 ui/dialogs/settings-dialog.slint:1201 msgid "Username" msgstr "" -#: ui/dialogs/settings-dialog.slint:773 +#: ui/dialogs/settings-dialog.slint:798 msgid "App password" msgstr "" -#: ui/dialogs/settings-dialog.slint:774 +#: ui/dialogs/settings-dialog.slint:799 ui/views/files-view.slint:586 msgid "Connect" msgstr "" -#: ui/dialogs/settings-dialog.slint:795 +#: ui/dialogs/settings-dialog.slint:820 msgid "" "Transfer your setup, export a full snapshot, or remove local data from this " "device." msgstr "" -#: ui/dialogs/settings-dialog.slint:795 +#: ui/dialogs/settings-dialog.slint:820 msgid "Remove local data from this device." msgstr "" -#: ui/dialogs/settings-dialog.slint:806 +#: ui/dialogs/settings-dialog.slint:831 msgid "Database snapshot" msgstr "" -#: ui/dialogs/settings-dialog.slint:806 +#: ui/dialogs/settings-dialog.slint:831 msgid "" "Export verified copies of the mail and calendar databases with their schema " "versions." msgstr "" -#: ui/dialogs/settings-dialog.slint:808 +#: ui/dialogs/settings-dialog.slint:833 msgid "" "The snapshot contains private local mail and settings, but not passwords or " "OAuth tokens. Store it securely." msgstr "" -#: ui/dialogs/settings-dialog.slint:809 +#: ui/dialogs/settings-dialog.slint:834 msgid "Export snapshot" msgstr "" -#: ui/dialogs/settings-dialog.slint:816 +#: ui/dialogs/settings-dialog.slint:841 msgid "Transfer account setup" msgstr "" -#: ui/dialogs/settings-dialog.slint:816 +#: ui/dialogs/settings-dialog.slint:841 msgid "Import a backup or export one connected account and your preferences." msgid_plural "" "Import a backup or export {n} connected accounts and your preferences." msgstr[0] "" msgstr[1] "" -#: ui/dialogs/settings-dialog.slint:818 +#: ui/dialogs/settings-dialog.slint:843 msgid "" "Passwords and OAuth tokens stay in the system keyring. A restored device " "asks you to sign in securely." msgstr "" -#: ui/dialogs/settings-dialog.slint:825 +#: ui/dialogs/settings-dialog.slint:850 msgid "Import" msgstr "" -#: ui/dialogs/settings-dialog.slint:826 +#: ui/dialogs/settings-dialog.slint:851 msgid "Export" msgstr "" -#: ui/dialogs/settings-dialog.slint:833 +#: ui/dialogs/settings-dialog.slint:858 msgid "Delete all local data" msgstr "" -#: ui/dialogs/settings-dialog.slint:833 +#: ui/dialogs/settings-dialog.slint:858 msgid "" "Remove accounts, downloaded mail, calendars, contacts, preferences, and " "credentials from this device." msgstr "" -#: ui/dialogs/settings-dialog.slint:835 +#: ui/dialogs/settings-dialog.slint:860 msgid "This does not delete anything from Gmail, Outlook, or your IMAP server." msgstr "" -#: ui/dialogs/settings-dialog.slint:836 ui/dialogs/settings-dialog.slint:1236 +#: ui/dialogs/settings-dialog.slint:861 ui/dialogs/settings-dialog.slint:1285 msgid "Delete everything" msgstr "" -#: ui/dialogs/settings-dialog.slint:849 +#: ui/dialogs/settings-dialog.slint:874 msgid "Choose how Flectar Mail gets your attention." msgstr "" -#: ui/dialogs/settings-dialog.slint:876 +#: ui/dialogs/settings-dialog.slint:901 msgid "Notification sounds" msgstr "" -#: ui/dialogs/settings-dialog.slint:877 +#: ui/dialogs/settings-dialog.slint:902 msgid "Play a sound for new mail and sent messages." msgstr "" -#: ui/dialogs/settings-dialog.slint:896 +#: ui/dialogs/settings-dialog.slint:921 msgid "Keyboard shortcuts" msgstr "" -#: ui/dialogs/settings-dialog.slint:897 +#: ui/dialogs/settings-dialog.slint:922 msgid "Move through mail without leaving the keyboard." msgstr "" -#: ui/dialogs/settings-dialog.slint:900 +#: ui/dialogs/settings-dialog.slint:925 msgid "Compose a new message" msgstr "" -#: ui/dialogs/settings-dialog.slint:900 +#: ui/dialogs/settings-dialog.slint:925 msgid "C" msgstr "" -#: ui/dialogs/settings-dialog.slint:901 ui/views/mail-list-pane.slint:258 +#: ui/dialogs/settings-dialog.slint:926 ui/views/mail-list-pane.slint:206 msgid "Search mail" msgstr "" -#: ui/dialogs/settings-dialog.slint:901 +#: ui/dialogs/settings-dialog.slint:926 msgid "/" msgstr "" -#: ui/dialogs/settings-dialog.slint:902 +#: ui/dialogs/settings-dialog.slint:927 msgid "Archive selected message" msgstr "" -#: ui/dialogs/settings-dialog.slint:902 +#: ui/dialogs/settings-dialog.slint:927 msgid "E" msgstr "" -#: ui/dialogs/settings-dialog.slint:903 +#: ui/dialogs/settings-dialog.slint:928 msgid "Mark as spam" msgstr "" -#: ui/dialogs/settings-dialog.slint:903 +#: ui/dialogs/settings-dialog.slint:928 msgid "!" msgstr "" -#: ui/dialogs/settings-dialog.slint:904 ui/views/mail-list-pane.slint:537 -#: ui/dialogs/settings-dialog.slint:904 ui/views/mail-list-pane.slint:537 ui/views/mail-reading-pane.slint:564 +#: ui/dialogs/settings-dialog.slint:929 ui/views/mail-list-pane.slint:538 +#: ui/dialogs/settings-dialog.slint:929 ui/views/mail-list-pane.slint:538 ui/views/mail-reading-pane.slint:576 msgid "Move to trash" msgstr "" -#: ui/dialogs/settings-dialog.slint:904 +#: ui/dialogs/settings-dialog.slint:929 msgid "#" msgstr "" -#: ui/dialogs/settings-dialog.slint:923 +#: ui/dialogs/settings-dialog.slint:948 msgid "Flectar Mail, its makers, and its community." msgstr "" -#: ui/dialogs/settings-dialog.slint:939 ui/views/onboarding-view.slint:74 +#: ui/dialogs/settings-dialog.slint:964 ui/views/onboarding-view.slint:91 msgid "Flectar Mail logo" msgstr "" -#: ui/dialogs/settings-dialog.slint:959 +#: ui/dialogs/settings-dialog.slint:984 msgid "A native mail client for a calm, private inbox." msgstr "" -#: ui/dialogs/settings-dialog.slint:971 +#: ui/dialogs/settings-dialog.slint:996 msgid "Version 0.1.0" msgstr "" -#: ui/dialogs/settings-dialog.slint:982 +#: ui/dialogs/settings-dialog.slint:1007 msgid "Credits" msgstr "" -#: ui/dialogs/settings-dialog.slint:982 +#: ui/dialogs/settings-dialog.slint:1007 msgid "Built by Flectar with help from the open-source community." msgstr "" -#: ui/dialogs/settings-dialog.slint:990 +#: ui/dialogs/settings-dialog.slint:1015 msgid "Flectar" msgstr "" -#: ui/dialogs/settings-dialog.slint:991 +#: ui/dialogs/settings-dialog.slint:1016 msgid "Company website" msgstr "" -#: ui/dialogs/settings-dialog.slint:1001 +#: ui/dialogs/settings-dialog.slint:1026 msgid "Open-source contributors" msgstr "" -#: ui/dialogs/settings-dialog.slint:1002 +#: ui/dialogs/settings-dialog.slint:1027 msgid "Meet the people who have contributed on GitHub" msgstr "" -#: ui/dialogs/settings-dialog.slint:1015 +#: ui/dialogs/settings-dialog.slint:1040 msgid "License" msgstr "" -#: ui/dialogs/settings-dialog.slint:1015 +#: ui/dialogs/settings-dialog.slint:1040 msgid "AGPL-3.0-only." msgstr "" -#: ui/dialogs/settings-dialog.slint:1015 +#: ui/dialogs/settings-dialog.slint:1040 msgid "" "Flectar Mail is released under the GNU Affero General Public License v3.0 " "only." msgstr "" -#: ui/dialogs/settings-dialog.slint:1021 +#: ui/dialogs/settings-dialog.slint:1046 msgid "Third-party components retain their own licenses and notices." msgstr "" -#: ui/dialogs/settings-dialog.slint:1042 +#: ui/dialogs/settings-dialog.slint:1067 msgid "Settings sections" msgstr "" -#: ui/dialogs/settings-dialog.slint:1054 +#: ui/dialogs/settings-dialog.slint:1079 msgid "Close settings" msgstr "" -#: ui/dialogs/settings-dialog.slint:1071 +#: ui/dialogs/settings-dialog.slint:1096 msgid "Close settings sections" msgstr "" -#: ui/dialogs/settings-dialog.slint:1106 +#: ui/dialogs/settings-dialog.slint:1131 msgid "Edit mail account" msgstr "" -#: ui/dialogs/settings-dialog.slint:1106 ui/dialogs/settings-dialog.slint:1151 -#: ui/dialogs/settings-dialog.slint:1106 ui/dialogs/settings-dialog.slint:1151 +#: ui/dialogs/settings-dialog.slint:1131 ui/dialogs/settings-dialog.slint:1179 +#: ui/dialogs/settings-dialog.slint:1131 ui/dialogs/settings-dialog.slint:1179 msgid "Connect a mail account" msgstr "" -#: ui/dialogs/settings-dialog.slint:1151 +#: ui/dialogs/settings-dialog.slint:1179 msgid "Edit {}" msgstr "" -#: ui/dialogs/settings-dialog.slint:1152 +#: ui/dialogs/settings-dialog.slint:1180 msgid "Uses JMAP for mail sync and sending, with automatic discovery." msgstr "" -#: ui/dialogs/settings-dialog.slint:1152 +#: ui/dialogs/settings-dialog.slint:1180 msgid "Uses separate IMAP and SMTP servers for mail sync and sending." msgstr "" -#: ui/dialogs/settings-dialog.slint:1169 ui/dialogs/settings-dialog.slint:1175 -#: ui/dialogs/settings-dialog.slint:1169 ui/dialogs/settings-dialog.slint:1175 +#: ui/dialogs/settings-dialog.slint:1200 ui/dialogs/settings-dialog.slint:1207 +#: ui/dialogs/settings-dialog.slint:1200 ui/dialogs/settings-dialog.slint:1207 ui/views/onboarding-view.slint:234 msgid "Email address" msgstr "" -#: ui/dialogs/settings-dialog.slint:1169 ui/dialogs/settings-dialog.slint:1175 -#: ui/dialogs/settings-dialog.slint:1169 ui/dialogs/settings-dialog.slint:1175 +#: ui/dialogs/settings-dialog.slint:1200 ui/dialogs/settings-dialog.slint:1207 +#: ui/dialogs/settings-dialog.slint:1200 ui/dialogs/settings-dialog.slint:1207 msgid "name@example.com" msgstr "" -#: ui/dialogs/settings-dialog.slint:1170 ui/dialogs/settings-dialog.slint:1176 +#: ui/dialogs/settings-dialog.slint:1201 ui/dialogs/settings-dialog.slint:1208 msgid "Usually your email address" msgstr "" -#: ui/dialogs/settings-dialog.slint:1178 ui/views/onboarding-view.slint:212 +#: ui/dialogs/settings-dialog.slint:1210 ui/views/onboarding-view.slint:249 +#: ui/dialogs/settings-dialog.slint:1210 ui/views/onboarding-view.slint:249 msgid "Password or app password" msgstr "" -#: ui/dialogs/settings-dialog.slint:1178 +#: ui/dialogs/settings-dialog.slint:1210 msgid "Stored securely in your system keyring" msgstr "" -#: ui/dialogs/settings-dialog.slint:1182 ui/views/onboarding-view.slint:221 +#: ui/dialogs/settings-dialog.slint:1215 ui/views/onboarding-view.slint:263 msgid "IMAP + SMTP" msgstr "" -#: ui/dialogs/settings-dialog.slint:1183 ui/views/onboarding-view.slint:228 +#: ui/dialogs/settings-dialog.slint:1216 ui/views/onboarding-view.slint:271 msgid "JMAP" msgstr "" -#: ui/dialogs/settings-dialog.slint:1187 +#: ui/dialogs/settings-dialog.slint:1220 msgid "Server address · Optional" msgstr "" -#: ui/dialogs/settings-dialog.slint:1189 +#: ui/dialogs/settings-dialog.slint:1222 msgid "Discovered from your email domain" msgstr "" -#: ui/dialogs/settings-dialog.slint:1196 ui/views/onboarding-view.slint:251 +#: ui/dialogs/settings-dialog.slint:1230 ui/views/onboarding-view.slint:296 +#: ui/dialogs/settings-dialog.slint:1230 ui/views/onboarding-view.slint:296 msgid "IMAP server" msgstr "" -#: ui/dialogs/settings-dialog.slint:1196 +#: ui/dialogs/settings-dialog.slint:1230 msgid "imap.example.com" msgstr "" -#: ui/dialogs/settings-dialog.slint:1197 ui/dialogs/settings-dialog.slint:1203 +#: ui/dialogs/settings-dialog.slint:1231 ui/dialogs/settings-dialog.slint:1238 +#: ui/dialogs/settings-dialog.slint:1231 ui/dialogs/settings-dialog.slint:1238 ui/views/onboarding-view.slint:329 msgid "Port" msgstr "" -#: ui/dialogs/settings-dialog.slint:1197 ui/views/onboarding-view.slint:258 +#: ui/dialogs/settings-dialog.slint:1231 ui/views/onboarding-view.slint:309 msgid "993" msgstr "" -#: ui/dialogs/settings-dialog.slint:1202 ui/views/onboarding-view.slint:268 +#: ui/dialogs/settings-dialog.slint:1237 ui/views/onboarding-view.slint:320 +#: ui/dialogs/settings-dialog.slint:1237 ui/views/onboarding-view.slint:320 msgid "SMTP server" msgstr "" -#: ui/dialogs/settings-dialog.slint:1202 +#: ui/dialogs/settings-dialog.slint:1237 msgid "smtp.example.com" msgstr "" -#: ui/dialogs/settings-dialog.slint:1203 ui/views/onboarding-view.slint:275 +#: ui/dialogs/settings-dialog.slint:1238 ui/views/onboarding-view.slint:333 msgid "465" msgstr "" -#: ui/dialogs/settings-dialog.slint:1215 +#: ui/dialogs/settings-dialog.slint:1241 ui/views/onboarding-view.slint:338 +msgid "" +"Proton Bridge: use the local servers, ports and password shown in Bridge, " +"and match its encryption settings." +msgstr "" + +#: ui/dialogs/settings-dialog.slint:1265 ui/views/onboarding-view.slint:351 +msgid "Connecting…" +msgstr "" + +#: ui/dialogs/settings-dialog.slint:1265 msgid "Save account" msgstr "" -#: ui/dialogs/settings-dialog.slint:1215 ui/views/onboarding-view.slint:282 +#: ui/dialogs/settings-dialog.slint:1265 ui/views/onboarding-view.slint:351 msgid "Connect account" msgstr "" -#: ui/dialogs/settings-dialog.slint:1233 +#: ui/dialogs/settings-dialog.slint:1282 msgid "Delete all local data?" msgstr "" -#: ui/dialogs/settings-dialog.slint:1234 +#: ui/dialogs/settings-dialog.slint:1283 msgid "" "This removes every account, downloaded message, calendar, contact, " "preference, and credential stored by Flectar Mail on this device." msgstr "" -#: ui/dialogs/settings-dialog.slint:1235 +#: ui/dialogs/settings-dialog.slint:1284 msgid "Mail and events on Gmail, Outlook, and IMAP servers will not be deleted." msgstr "" -#: ui/dialogs/settings-dialog.slint:1237 +#: ui/dialogs/settings-dialog.slint:1286 msgid "Keep my data" msgstr "" @@ -2703,220 +3100,640 @@ msgstr "" msgid "Quit" msgstr "" -#: ui/views/contacts-view.slint:205 ui/views/contacts-view.slint:519 -#: ui/views/contacts-view.slint:205 ui/views/contacts-view.slint:519 ui/views/contacts-view.slint:648 -#: ui/views/contacts-view.slint:205 ui/views/contacts-view.slint:519 ui/views/contacts-view.slint:648 +#: ui/views/contacts-view.slint:147 ui/views/contacts-view.slint:461 +#: ui/views/contacts-view.slint:147 ui/views/contacts-view.slint:461 ui/views/contacts-view.slint:590 +#: ui/views/contacts-view.slint:147 ui/views/contacts-view.slint:461 ui/views/contacts-view.slint:590 msgid "New contact" msgstr "" -#: ui/views/contacts-view.slint:245 +#: ui/views/contacts-view.slint:187 msgid "Unified contacts" msgstr "" -#: ui/views/contacts-view.slint:264 ui/views/contacts-view.slint:336 -#: ui/views/contacts-view.slint:264 ui/views/contacts-view.slint:336 +#: ui/views/contacts-view.slint:206 ui/views/contacts-view.slint:278 +#: ui/views/contacts-view.slint:206 ui/views/contacts-view.slint:278 msgid "All contacts" msgstr "" -#: ui/views/contacts-view.slint:265 ui/views/contacts-view.slint:337 -#: ui/views/contacts-view.slint:265 ui/views/contacts-view.slint:337 +#: ui/views/contacts-view.slint:207 ui/views/contacts-view.slint:279 +#: ui/views/contacts-view.slint:207 ui/views/contacts-view.slint:279 msgid "Favorites" msgstr "" -#: ui/views/contacts-view.slint:286 +#: ui/views/contacts-view.slint:228 msgid "People discovered from your conversations appear here automatically." msgstr "" -#: ui/views/contacts-view.slint:345 +#: ui/views/contacts-view.slint:287 msgid "Show contact groups" msgstr "" -#: ui/views/contacts-view.slint:345 +#: ui/views/contacts-view.slint:287 msgid "Hide contact groups" msgstr "" -#: ui/views/contacts-view.slint:359 +#: ui/views/contacts-view.slint:301 msgid "Search contacts" msgstr "" -#: ui/views/contacts-view.slint:365 +#: ui/views/contacts-view.slint:307 msgid "Filter contacts" msgstr "" -#: ui/views/contacts-view.slint:367 +#: ui/views/contacts-view.slint:309 msgid "Reload contacts" msgstr "" -#: ui/views/contacts-view.slint:478 +#: ui/views/contacts-view.slint:420 msgid "Open {}" msgstr "" -#: ui/views/contacts-view.slint:493 +#: ui/views/contacts-view.slint:435 msgid "No contacts yet. Create one or sync some mail." msgstr "" -#: ui/views/contacts-view.slint:493 +#: ui/views/contacts-view.slint:435 msgid "No contacts match this search." msgstr "" -#: ui/views/contacts-view.slint:563 +#: ui/views/contacts-view.slint:505 msgid "Back to contacts" msgstr "" -#: ui/views/contacts-view.slint:571 +#: ui/views/contacts-view.slint:513 msgid "Contact details" msgstr "" -#: ui/views/contacts-view.slint:585 +#: ui/views/contacts-view.slint:527 msgid "Remove from favorites" msgstr "" -#: ui/views/contacts-view.slint:585 +#: ui/views/contacts-view.slint:527 msgid "Add to favorites" msgstr "" -#: ui/views/contacts-view.slint:589 +#: ui/views/contacts-view.slint:531 msgid "Delete this contact?" msgstr "" -#: ui/views/contacts-view.slint:612 +#: ui/views/contacts-view.slint:554 msgid "Delete contact" msgstr "" -#: ui/views/contacts-view.slint:666 +#: ui/views/contacts-view.slint:608 msgid "NAME" msgstr "" -#: ui/views/contacts-view.slint:667 +#: ui/views/contacts-view.slint:609 msgid "Full name" msgstr "" -#: ui/views/contacts-view.slint:668 +#: ui/views/contacts-view.slint:610 msgid "EMAIL" msgstr "" -#: ui/views/contacts-view.slint:675 +#: ui/views/contacts-view.slint:617 msgid "PHONE" msgstr "" -#: ui/views/contacts-view.slint:676 +#: ui/views/contacts-view.slint:618 msgid "+1 555 0100" msgstr "" -#: ui/views/contacts-view.slint:680 +#: ui/views/contacts-view.slint:622 msgid "BIRTHDAY" msgstr "" -#: ui/views/contacts-view.slint:689 +#: ui/views/contacts-view.slint:631 msgid "COMPANY" msgstr "" -#: ui/views/contacts-view.slint:690 +#: ui/views/contacts-view.slint:632 msgid "Company" msgstr "" -#: ui/views/contacts-view.slint:694 +#: ui/views/contacts-view.slint:636 msgid "JOB TITLE" msgstr "" -#: ui/views/contacts-view.slint:695 +#: ui/views/contacts-view.slint:637 msgid "Role" msgstr "" -#: ui/views/contacts-view.slint:699 +#: ui/views/contacts-view.slint:641 msgid "WEBSITE" msgstr "" -#: ui/views/contacts-view.slint:701 +#: ui/views/contacts-view.slint:643 msgid "ADDRESS" msgstr "" -#: ui/views/contacts-view.slint:702 +#: ui/views/contacts-view.slint:644 msgid "Postal address" msgstr "" -#: ui/views/contacts-view.slint:703 +#: ui/views/contacts-view.slint:645 msgid "TAGS" msgstr "" -#: ui/views/contacts-view.slint:704 +#: ui/views/contacts-view.slint:646 msgid "customer, design, london" msgstr "" -#: ui/views/contacts-view.slint:705 +#: ui/views/contacts-view.slint:647 msgid "NOTES" msgstr "" -#: ui/views/contacts-view.slint:706 +#: ui/views/contacts-view.slint:648 msgid "Notes about this contact" msgstr "" -#: ui/views/contacts-view.slint:714 +#: ui/views/contacts-view.slint:656 msgid "Send email" msgstr "" -#: ui/views/contacts-view.slint:721 +#: ui/views/contacts-view.slint:663 msgid "Save contact" msgstr "" -#: ui/views/contacts-view.slint:751 +#: ui/views/contacts-view.slint:693 msgid "Select a contact" msgstr "" -#: ui/views/contacts-view.slint:752 +#: ui/views/contacts-view.slint:694 msgid "View and edit contact details here." msgstr "" -#: ui/views/mail-list-pane.slint:227 +#: ui/views/files-view.slint:32 +msgid "All accounts" +msgstr "" + +#: ui/views/files-view.slint:51 ui/views/files-view.slint:103 +#: ui/views/files-view.slint:51 ui/views/files-view.slint:103 ui/views/files-view.slint:267 +msgid "Email attachments" +msgstr "" + +#: ui/views/files-view.slint:92 ui/views/files-view.slint:569 +msgid "Connect storage" +msgstr "" + +#: ui/views/files-view.slint:129 ui/views/files-view.slint:267 +msgid "Server storage" +msgstr "" + +#: ui/views/files-view.slint:135 +msgid "STORAGE SPACE" +msgstr "" + +#: ui/views/files-view.slint:146 ui/views/files-view.slint:385 +msgid "Sharing activity" +msgstr "" + +#: ui/views/files-view.slint:147 +msgid "Transfers" +msgstr "" + +#: ui/views/files-view.slint:271 ui/views/files-view.slint:433 +msgid "File sources" +msgstr "" + +#: ui/views/files-view.slint:274 +msgid "Upload" +msgstr "" + +#: ui/views/files-view.slint:285 +msgid "Search files" +msgstr "" + +#: ui/views/files-view.slint:287 +msgid "Refresh" +msgstr "" + +#: ui/views/files-view.slint:288 ui/views/files-view.slint:295 +#: ui/views/files-view.slint:288 ui/views/files-view.slint:295 +msgid "Filter and sort files" +msgstr "" + +#: ui/views/files-view.slint:291 ui/views/files-view.slint:315 +msgid "Synchronized mail" +msgstr "" + +#: ui/views/files-view.slint:292 ui/views/files-view.slint:315 +msgid "All mail on server (JMAP)" +msgstr "" + +#: ui/views/files-view.slint:293 +msgid "Index cached text" +msgstr "" + +#: ui/views/files-view.slint:314 ui/views/onboarding-view.slint:370 +msgid "Back" +msgstr "" + +#: ui/views/files-view.slint:323 +msgid "Loading files…" +msgstr "" + +#: ui/views/files-view.slint:323 +msgid "No files to show" +msgstr "" + +#: ui/views/files-view.slint:324 +msgid "Attachments from synchronized messages will appear here." +msgstr "" + +#: ui/views/files-view.slint:324 +msgid "Upload a file or create a folder to get started." +msgstr "" + +#: ui/views/files-view.slint:324 +msgid "Select an account and connect its storage." +msgstr "" + +#: ui/views/files-view.slint:374 +msgid "Load more" +msgstr "" + +#: ui/views/files-view.slint:388 +msgid "Back to files" +msgstr "" + +#: ui/views/files-view.slint:397 +msgid "Open folder" +msgstr "" + +#: ui/views/files-view.slint:398 +msgid "Manage" +msgstr "" + +#: ui/views/files-view.slint:399 +msgid "Preview" +msgstr "" + +#: ui/views/files-view.slint:400 +msgid "Details" +msgstr "" + +#: ui/views/files-view.slint:407 +msgid "Select a file to preview" +msgstr "" + +#: ui/views/files-view.slint:408 +msgid "PDFs, images and text appear here. Other formats can be downloaded." +msgstr "" + +#: ui/views/files-view.slint:408 +msgid "Images and text appear here. Other formats can be downloaded." +msgstr "" + +#: ui/views/files-view.slint:411 +msgid "Clear displayed activity" +msgstr "" + +#: ui/views/files-view.slint:453 +msgid "Delete permanently?" +msgstr "" + +#: ui/views/files-view.slint:453 +msgid "Share file or folder" +msgstr "" + +#: ui/views/files-view.slint:453 ui/views/files-view.slint:467 +msgid "Rename" +msgstr "" + +#: ui/views/files-view.slint:453 +msgid "Move to folder" +msgstr "" + +#: ui/views/files-view.slint:453 +msgid "Copy to folder" +msgstr "" + +#: ui/views/files-view.slint:453 +msgid "File properties" +msgstr "" + +#: ui/views/files-view.slint:453 +msgid "Lock file or folder" +msgstr "" + +#: ui/views/files-view.slint:453 +msgid "Manage file" +msgstr "" + +#: ui/views/files-view.slint:456 +msgid "Files and folders" +msgstr "" + +#: ui/views/files-view.slint:456 +msgid "Folders" +msgstr "" + +#: ui/views/files-view.slint:457 ui/views/files-view.slint:478 +msgid "Name" +msgstr "" + +#: ui/views/files-view.slint:457 +msgid "Size" +msgstr "" + +#: ui/views/files-view.slint:457 +msgid "Kind" +msgstr "" + +#: ui/views/files-view.slint:458 +msgid "Descending order" +msgstr "" + +#: ui/views/files-view.slint:459 +msgid "Minimum bytes" +msgstr "" + +#: ui/views/files-view.slint:459 +msgid "Minimum bytes (optional)" +msgstr "" + +#: ui/views/files-view.slint:460 +msgid "Maximum bytes" +msgstr "" + +#: ui/views/files-view.slint:460 +msgid "Maximum bytes (optional)" +msgstr "" + +#: ui/views/files-view.slint:461 +msgid "Reset filters" +msgstr "" + +#: ui/views/files-view.slint:465 +msgid "Keep offline" +msgstr "" + +#: ui/views/files-view.slint:466 +msgid "Release offline copy" +msgstr "" + +#: ui/views/files-view.slint:468 +msgid "Move…" +msgstr "" + +#: ui/views/files-view.slint:469 +msgid "Copy…" +msgstr "" + +#: ui/views/files-view.slint:470 +msgid "Replace content…" +msgstr "" + +#: ui/views/files-view.slint:471 +msgid "Properties…" +msgstr "" + +#: ui/views/files-view.slint:472 +msgid "Sharing…" +msgstr "" + +#: ui/views/files-view.slint:473 +msgid "Release lock" +msgstr "" + +#: ui/views/files-view.slint:473 +msgid "Lock…" +msgstr "" + +#: ui/views/files-view.slint:474 +msgid "Renew lock for 5 minutes" +msgstr "" + +#: ui/views/files-view.slint:475 +msgid "Delete…" +msgstr "" + +#: ui/views/files-view.slint:477 +msgid "" +"This permanently deletes “{}” and all of its contents from storage. This " +"cannot be undone." +msgstr "" + +#: ui/views/files-view.slint:482 +msgid "" +"Destination folder path (for example /Documents). Leave empty for the " +"current folder, or the root of another storage space." +msgstr "" + +#: ui/views/files-view.slint:483 +msgid "Destination folder" +msgstr "" + +#: ui/views/files-view.slint:487 +msgid "Enter the recipient’s principal URL on this server." +msgstr "" + +#: ui/views/files-view.slint:487 +msgid "Enter the recipient’s email or principal ID on this server." +msgstr "" + +#: ui/views/files-view.slint:488 +msgid "Recipient" +msgstr "" + +#: ui/views/files-view.slint:489 +msgid "Allow editing" +msgstr "" + +#: ui/views/files-view.slint:489 +msgid "Modify content" +msgstr "" + +#: ui/views/files-view.slint:492 +msgid "Modify properties" +msgstr "" + +#: ui/views/files-view.slint:493 +msgid "Add children" +msgstr "" + +#: ui/views/files-view.slint:494 +msgid "Rename and move" +msgstr "" + +#: ui/views/files-view.slint:496 +msgid "Manage sharing" +msgstr "" + +#: ui/views/files-view.slint:498 +msgid "Remove recipient’s access" +msgstr "" + +#: ui/views/files-view.slint:502 +msgid "Shared lock" +msgstr "" + +#: ui/views/files-view.slint:503 +msgid "Include folder contents" +msgstr "" + +#: ui/views/files-view.slint:504 +msgid "Locks expire after 5 minutes unless renewed." +msgstr "" + +#: ui/views/files-view.slint:508 +msgid "If a name already exists" +msgstr "" + +#: ui/views/files-view.slint:509 +msgid "Stop and report conflict" +msgstr "" + +#: ui/views/files-view.slint:509 +msgid "Keep both with a new name" +msgstr "" + +#: ui/views/files-view.slint:509 +msgid "Replace existing" +msgstr "" + +#: ui/views/files-view.slint:509 +msgid "Keep newest" +msgstr "" + +#: ui/views/files-view.slint:510 +msgid "This can replace existing content at the destination." +msgstr "" + +#: ui/views/files-view.slint:511 +msgid "Compare names without case" +msgstr "" + +#: ui/views/files-view.slint:517 +msgid "Property namespace" +msgstr "" + +#: ui/views/files-view.slint:518 +msgid "Property name" +msgstr "" + +#: ui/views/files-view.slint:519 +msgid "Value" +msgstr "" + +#: ui/views/files-view.slint:520 +msgid "Remove property" +msgstr "" + +#: ui/views/files-view.slint:524 +msgid "Dates use UTC, for example 2026-09-09T12:00:00Z." +msgstr "" + +#: ui/views/files-view.slint:525 +msgid "Created" +msgstr "" + +#: ui/views/files-view.slint:526 +msgid "Modified" +msgstr "" + +#: ui/views/files-view.slint:527 +msgid "Accessed" +msgstr "" + +#: ui/views/files-view.slint:528 +msgid "Executable" +msgstr "" + +#: ui/views/files-view.slint:529 +msgid "Subscription changes are unavailable on this server version." +msgstr "" + +#: ui/views/files-view.slint:535 +msgid "Delete permanently" +msgstr "" + +#: ui/views/files-view.slint:576 +msgid "Account" +msgstr "" + +#: ui/views/files-view.slint:581 +msgid "Uses the selected account’s saved credentials." +msgstr "" + +#: ui/views/files-view.slint:585 +msgid "Storage URL" +msgstr "" + +#: ui/views/files-view.slint:599 +msgid "Transfers and operations" +msgstr "" + +#: ui/views/files-view.slint:600 +msgid "" +"Interrupted changes need review before retrying. Refresh storage and inspect " +"the destination first." +msgstr "" + +#: ui/views/files-view.slint:607 +msgid "Resume" +msgstr "" + +#: ui/views/files-view.slint:608 +msgid "Dismiss" +msgstr "" + +#: ui/views/files-view.slint:609 +msgid "Reviewed as completed" +msgstr "" + +#: ui/views/mail-list-pane.slint:159 msgid "Show folders" msgstr "" -#: ui/views/mail-list-pane.slint:227 +#: ui/views/mail-list-pane.slint:159 msgid "Hide folders" msgstr "" -#: ui/views/mail-list-pane.slint:251 +#: ui/views/mail-list-pane.slint:197 msgid "Select all messages on this page" msgstr "" -#: ui/views/mail-list-pane.slint:261 +#: ui/views/mail-list-pane.slint:209 msgid "Unread" msgstr "" -#: ui/views/mail-list-pane.slint:262 +#: ui/views/mail-list-pane.slint:210 msgid "Starred" msgstr "" -#: ui/views/mail-list-pane.slint:263 ui/views/mail-list-pane.slint:329 +#: ui/views/mail-list-pane.slint:211 ui/views/mail-list-pane.slint:281 msgid "Has attachments" msgstr "" -#: ui/views/mail-list-pane.slint:266 +#: ui/views/mail-list-pane.slint:214 msgid "Filter mail" msgstr "" -#: ui/views/mail-list-pane.slint:269 +#: ui/views/mail-list-pane.slint:217 msgid "Synchronizing mail" msgstr "" -#: ui/views/mail-list-pane.slint:269 +#: ui/views/mail-list-pane.slint:217 msgid "Reload mail" msgstr "" -#: ui/views/mail-list-pane.slint:294 +#: ui/views/mail-list-pane.slint:242 msgid "{} selected" msgstr "" -#: ui/views/mail-list-pane.slint:327 +#: ui/views/mail-list-pane.slint:279 msgid "{}, {}" msgstr "" -#: ui/views/mail-list-pane.slint:327 ui/views/mail-list-pane.slint:429 -#: ui/views/mail-list-pane.slint:327 ui/views/mail-list-pane.slint:429 +#: ui/views/mail-list-pane.slint:279 ui/views/mail-list-pane.slint:397 +#: ui/views/mail-list-pane.slint:279 ui/views/mail-list-pane.slint:397 msgid "(No subject)" msgstr "" -#: ui/views/mail-list-pane.slint:330 +#: ui/views/mail-list-pane.slint:282 msgid "Replied" msgstr "" @@ -2924,299 +3741,287 @@ msgstr "" msgid "Deselect {}" msgstr "" -#: ui/views/mail-list-pane.slint:533 ui/views/mail-reading-pane.slint:303 +#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:314 msgid "Remove star" msgstr "" -#: ui/views/mail-list-pane.slint:533 ui/views/mail-reading-pane.slint:303 +#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:314 msgid "Star message" msgstr "" -#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:316 -#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:316 +#: ui/views/mail-list-pane.slint:535 ui/views/mail-reading-pane.slint:327 +#: ui/views/mail-list-pane.slint:535 ui/views/mail-reading-pane.slint:327 msgid "Mark as read" msgstr "" -#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:316 -#: ui/views/mail-list-pane.slint:534 ui/views/mail-reading-pane.slint:316 +#: ui/views/mail-list-pane.slint:535 ui/views/mail-reading-pane.slint:327 +#: ui/views/mail-list-pane.slint:535 ui/views/mail-reading-pane.slint:327 msgid "Mark as unread" msgstr "" -#: ui/views/mail-list-pane.slint:535 ui/views/mail-reading-pane.slint:562 +#: ui/views/mail-list-pane.slint:536 ui/views/mail-reading-pane.slint:574 msgid "Archive" msgstr "" -#: ui/views/mail-list-pane.slint:536 ui/views/mail-reading-pane.slint:325 -#: ui/views/mail-list-pane.slint:536 ui/views/mail-reading-pane.slint:325 +#: ui/views/mail-list-pane.slint:537 ui/views/mail-reading-pane.slint:336 +#: ui/views/mail-list-pane.slint:537 ui/views/mail-reading-pane.slint:336 msgid "Move to spam" msgstr "" -#: ui/views/mail-list-pane.slint:554 ui/views/onboarding-view.slint:92 +#: ui/views/mail-list-pane.slint:555 ui/views/onboarding-view.slint:109 msgid "Opening your mailbox…" msgstr "" -#: ui/views/mail-list-pane.slint:554 +#: ui/views/mail-list-pane.slint:555 msgid "Nothing here" msgstr "" -#: ui/views/mail-list-pane.slint:555 +#: ui/views/mail-list-pane.slint:556 msgid "Try another folder or search term." msgstr "" -#: ui/views/mail-reading-pane.slint:257 +#: ui/views/mail-reading-pane.slint:268 msgid "Close message preview" msgstr "" -#: ui/views/mail-reading-pane.slint:267 +#: ui/views/mail-reading-pane.slint:278 msgid "Previous message" msgstr "" -#: ui/views/mail-reading-pane.slint:277 +#: ui/views/mail-reading-pane.slint:288 msgid "Next message" msgstr "" -#: ui/views/mail-reading-pane.slint:290 +#: ui/views/mail-reading-pane.slint:301 msgid "Archive message" msgstr "" -#: ui/views/mail-reading-pane.slint:353 +#: ui/views/mail-reading-pane.slint:364 msgid "Manage labels" msgstr "" -#: ui/views/mail-reading-pane.slint:396 +#: ui/views/mail-reading-pane.slint:407 msgid "Search labels" msgstr "" -#: ui/views/mail-reading-pane.slint:413 +#: ui/views/mail-reading-pane.slint:424 msgid "Available labels" msgstr "" -#: ui/views/mail-reading-pane.slint:480 ui/views/mail-reading-pane.slint:1317 +#: ui/views/mail-reading-pane.slint:491 ui/views/mail-reading-pane.slint:1375 msgid "Edit label" msgstr "" -#: ui/views/mail-reading-pane.slint:496 +#: ui/views/mail-reading-pane.slint:507 msgid "No matching labels" msgstr "" -#: ui/views/mail-reading-pane.slint:508 +#: ui/views/mail-reading-pane.slint:519 msgid "Create label…" msgstr "" -#: ui/views/mail-reading-pane.slint:536 +#: ui/views/mail-reading-pane.slint:547 msgid "More message actions" msgstr "" -#: ui/views/mail-reading-pane.slint:540 ui/views/mail-reading-pane.slint:1210 +#: ui/views/mail-reading-pane.slint:551 ui/views/mail-reading-pane.slint:1268 msgid "Continue editing" msgstr "" -#: ui/views/mail-reading-pane.slint:544 +#: ui/views/mail-reading-pane.slint:555 +msgid "Decrypt / verify OpenPGP" +msgstr "" + +#: ui/views/mail-reading-pane.slint:556 msgid "Print message" msgstr "" -#: ui/views/mail-reading-pane.slint:545 ui/views/mail-reading-pane.slint:1220 -#: ui/views/mail-reading-pane.slint:545 ui/views/mail-reading-pane.slint:1220 +#: ui/views/mail-reading-pane.slint:557 ui/views/mail-reading-pane.slint:1278 +#: ui/views/mail-reading-pane.slint:557 ui/views/mail-reading-pane.slint:1278 msgid "Open in browser" msgstr "" -#: ui/views/mail-reading-pane.slint:548 ui/views/mail-reading-pane.slint:555 +#: ui/views/mail-reading-pane.slint:560 ui/views/mail-reading-pane.slint:567 msgid "Show rendered message" msgstr "" -#: ui/views/mail-reading-pane.slint:548 +#: ui/views/mail-reading-pane.slint:560 msgid "Show source" msgstr "" -#: ui/views/mail-reading-pane.slint:555 +#: ui/views/mail-reading-pane.slint:567 msgid "View plain text" msgstr "" -#: ui/views/mail-reading-pane.slint:663 +#: ui/views/mail-reading-pane.slint:679 msgid "Verified brand with BIMI authority evidence" msgstr "" -#: ui/views/mail-reading-pane.slint:665 +#: ui/views/mail-reading-pane.slint:681 msgid "BIMI-authenticated sender" msgstr "" -#: ui/views/mail-reading-pane.slint:667 +#: ui/views/mail-reading-pane.slint:683 msgid "Sender authenticated by Microsoft" msgstr "" -#: ui/views/mail-reading-pane.slint:668 +#: ui/views/mail-reading-pane.slint:684 msgid "Sender domain authenticated with DMARC and DKIM" msgstr "" -#: ui/views/mail-reading-pane.slint:675 -msgid "to {} · {}" -msgstr "" - -#: ui/views/mail-reading-pane.slint:690 -msgid "Zoom out" -msgstr "" - -#: ui/views/mail-reading-pane.slint:691 -msgid "Reset zoom ({}%)" +#: ui/views/mail-reading-pane.slint:696 +msgid "to {}" msgstr "" -#: ui/views/mail-reading-pane.slint:700 -msgid "Zoom percentage" +#: ui/views/mail-reading-pane.slint:726 ui/views/mail-reading-pane.slint:1128 +msgid "Attachments" msgstr "" -#: ui/views/mail-reading-pane.slint:707 -msgid "Zoom in" -msgstr "" - -#: ui/views/mail-reading-pane.slint:708 -msgid "Fit to width" -msgstr "" - -#: ui/views/mail-reading-pane.slint:710 ui/views/mail-reading-pane.slint:716 +#: ui/views/mail-reading-pane.slint:736 ui/views/mail-reading-pane.slint:742 msgid "Find in message" msgstr "" -#: ui/views/mail-reading-pane.slint:711 +#: ui/views/mail-reading-pane.slint:737 msgid "Show original layout" msgstr "" -#: ui/views/mail-reading-pane.slint:711 +#: ui/views/mail-reading-pane.slint:737 msgid "Reader mode" msgstr "" -#: ui/views/mail-reading-pane.slint:718 +#: ui/views/mail-reading-pane.slint:744 msgid "Previous match" msgstr "" -#: ui/views/mail-reading-pane.slint:719 +#: ui/views/mail-reading-pane.slint:745 msgid "Next match" msgstr "" -#: ui/views/mail-reading-pane.slint:720 +#: ui/views/mail-reading-pane.slint:746 msgid "Close find" msgstr "" -#: ui/views/mail-reading-pane.slint:724 +#: ui/views/mail-reading-pane.slint:750 msgid "Some images could not be loaded." msgstr "" -#: ui/views/mail-reading-pane.slint:725 ui/views/mail-reading-pane.slint:1011 +#: ui/views/mail-reading-pane.slint:751 ui/views/mail-reading-pane.slint:1037 msgid "Retry images" msgstr "" -#: ui/views/mail-reading-pane.slint:730 +#: ui/views/mail-reading-pane.slint:756 msgid "Reload message" msgstr "" -#: ui/views/mail-reading-pane.slint:785 +#: ui/views/mail-reading-pane.slint:811 msgid "Remote images were blocked to protect your privacy." msgstr "" -#: ui/views/mail-reading-pane.slint:794 +#: ui/views/mail-reading-pane.slint:820 msgid "Load images" msgstr "" -#: ui/views/mail-reading-pane.slint:824 +#: ui/views/mail-reading-pane.slint:850 msgid "Loading image…" msgstr "" -#: ui/views/mail-reading-pane.slint:824 +#: ui/views/mail-reading-pane.slint:850 msgid "Blocked image" msgstr "" -#: ui/views/mail-reading-pane.slint:824 +#: ui/views/mail-reading-pane.slint:850 msgid "Image exceeds limits" msgstr "" -#: ui/views/mail-reading-pane.slint:824 +#: ui/views/mail-reading-pane.slint:850 msgid "Image unavailable" msgstr "" -#: ui/views/mail-reading-pane.slint:960 +#: ui/views/mail-reading-pane.slint:986 msgid "Show rendered email" msgstr "" -#: ui/views/mail-reading-pane.slint:983 +#: ui/views/mail-reading-pane.slint:1009 msgid "Copy this HTML into your GitHub rendering issue." msgstr "" -#: ui/views/mail-reading-pane.slint:1000 +#: ui/views/mail-reading-pane.slint:1026 msgid "Show email" msgstr "" -#: ui/views/mail-reading-pane.slint:1010 +#: ui/views/mail-reading-pane.slint:1036 msgid "View original plain text" msgstr "" -#: ui/views/mail-reading-pane.slint:1012 +#: ui/views/mail-reading-pane.slint:1038 msgid "Copy formatted selection" msgstr "" -#: ui/views/mail-reading-pane.slint:1013 +#: ui/views/mail-reading-pane.slint:1039 msgid "Caret browsing" msgstr "" -#: ui/views/mail-reading-pane.slint:1014 +#: ui/views/mail-reading-pane.slint:1040 msgid "Use dark reader" msgstr "" -#: ui/views/mail-reading-pane.slint:1016 +#: ui/views/mail-reading-pane.slint:1042 msgid "Copy selected text" msgstr "" -#: ui/views/mail-reading-pane.slint:1026 +#: ui/views/mail-reading-pane.slint:1052 msgid "Copy all email text" msgstr "" -#: ui/views/mail-reading-pane.slint:1036 +#: ui/views/mail-reading-pane.slint:1062 msgid "Select all email text" msgstr "" -#: ui/views/mail-reading-pane.slint:1052 +#: ui/views/mail-reading-pane.slint:1078 msgid "Link: {}" msgstr "" -#: ui/views/mail-reading-pane.slint:1091 +#: ui/views/mail-reading-pane.slint:1117 msgid "Heading: {}" msgstr "" -#: ui/views/mail-reading-pane.slint:1285 +#: ui/views/mail-reading-pane.slint:1343 msgid "No message selected" msgstr "" -#: ui/views/mail-reading-pane.slint:1286 +#: ui/views/mail-reading-pane.slint:1344 msgid "Choose an email to read it here." msgstr "" -#: ui/views/mail-reading-pane.slint:1317 +#: ui/views/mail-reading-pane.slint:1375 msgid "Create label" msgstr "" -#: ui/views/mail-reading-pane.slint:1324 +#: ui/views/mail-reading-pane.slint:1382 msgid "Label name" msgstr "" -#: ui/views/mail-reading-pane.slint:1332 +#: ui/views/mail-reading-pane.slint:1390 msgid "Color" msgstr "" -#: ui/views/mail-reading-pane.slint:1342 +#: ui/views/mail-reading-pane.slint:1400 msgid "Blue" msgstr "" -#: ui/views/mail-reading-pane.slint:1344 +#: ui/views/mail-reading-pane.slint:1402 msgid "Pink" msgstr "" -#: ui/views/mail-reading-pane.slint:1345 +#: ui/views/mail-reading-pane.slint:1403 msgid "Red" msgstr "" -#: ui/views/mail-reading-pane.slint:1346 +#: ui/views/mail-reading-pane.slint:1404 msgid "Amber" msgstr "" -#: ui/views/mail-reading-pane.slint:1349 +#: ui/views/mail-reading-pane.slint:1407 msgid "Slate" msgstr "" @@ -3224,58 +4029,58 @@ msgstr "" msgid "Close folders" msgstr "" -#: ui/views/onboarding-view.slint:103 +#: ui/views/onboarding-view.slint:121 msgid "Connect an account to get started." msgstr "" -#: ui/views/onboarding-view.slint:110 +#: ui/views/onboarding-view.slint:128 msgid "Continue with Gmail" msgstr "" -#: ui/views/onboarding-view.slint:121 +#: ui/views/onboarding-view.slint:139 msgid "Continue with Outlook" msgstr "" -#: ui/views/onboarding-view.slint:132 +#: ui/views/onboarding-view.slint:150 msgid "Connect another provider" msgstr "" -#: ui/views/onboarding-view.slint:147 +#: ui/views/onboarding-view.slint:165 msgid "Add your own app keys to enable Gmail and Outlook." msgstr "" -#: ui/views/onboarding-view.slint:148 +#: ui/views/onboarding-view.slint:166 msgid "Add your own app keys to enable Gmail." msgstr "" -#: ui/views/onboarding-view.slint:149 +#: ui/views/onboarding-view.slint:167 msgid "Add your own app key to enable Outlook." msgstr "" -#: ui/views/onboarding-view.slint:165 +#: ui/views/onboarding-view.slint:183 msgid "Already used Flectar Mail?" msgstr "" -#: ui/views/onboarding-view.slint:172 +#: ui/views/onboarding-view.slint:190 msgid "Import a backup" msgstr "" -#: ui/views/onboarding-view.slint:196 +#: ui/views/onboarding-view.slint:221 msgid "Connect using JMAP for mail sync and sending." msgstr "" -#: ui/views/onboarding-view.slint:196 -msgid "Connect using IMAP for mail sync and SMTP for sending." +#: ui/views/onboarding-view.slint:221 +msgid "Connect using IMAP and SMTP. POP3 is not supported." msgstr "" -#: ui/views/onboarding-view.slint:236 -msgid "Server address (optional)" +#: ui/views/onboarding-view.slint:240 ui/views/onboarding-view.slint:243 +msgid "Username (defaults to email)" msgstr "" -#: ui/views/onboarding-view.slint:240 -msgid "Leave blank to discover /.well-known/jmap from your email domain." +#: ui/views/onboarding-view.slint:280 +msgid "Server address (optional)" msgstr "" -#: ui/views/onboarding-view.slint:301 -msgid "Back" +#: ui/views/onboarding-view.slint:284 +msgid "Leave blank to discover /.well-known/jmap from your email domain." msgstr "" diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json new file mode 100644 index 0000000..ddfadb6 --- /dev/null +++ b/packaging/flatpak/cargo-sources.json @@ -0,0 +1,12670 @@ +[ + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ab_glyph/ab_glyph-0.2.32.crate", + "sha256": "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2", + "dest": "cargo/vendor/ab_glyph-0.2.32" + }, + { + "type": "inline", + "contents": "{\"package\": \"01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2\", \"files\": {}}", + "dest": "cargo/vendor/ab_glyph-0.2.32", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ab_glyph_rasterizer/ab_glyph_rasterizer-0.1.10.crate", + "sha256": "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618", + "dest": "cargo/vendor/ab_glyph_rasterizer-0.1.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618\", \"files\": {}}", + "dest": "cargo/vendor/ab_glyph_rasterizer-0.1.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/accesskit/accesskit-0.24.1.crate", + "sha256": "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320", + "dest": "cargo/vendor/accesskit-0.24.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320\", \"files\": {}}", + "dest": "cargo/vendor/accesskit-0.24.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/adler2/adler2-2.0.1.crate", + "sha256": "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa", + "dest": "cargo/vendor/adler2-2.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa\", \"files\": {}}", + "dest": "cargo/vendor/adler2-2.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ahash/ahash-0.8.12.crate", + "sha256": "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75", + "dest": "cargo/vendor/ahash-0.8.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75\", \"files\": {}}", + "dest": "cargo/vendor/ahash-0.8.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aho-corasick/aho-corasick-1.1.5.crate", + "sha256": "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba", + "dest": "cargo/vendor/aho-corasick-1.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba\", \"files\": {}}", + "dest": "cargo/vendor/aho-corasick-1.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aligned-vec/aligned-vec-0.6.4.crate", + "sha256": "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b", + "dest": "cargo/vendor/aligned-vec-0.6.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b\", \"files\": {}}", + "dest": "cargo/vendor/aligned-vec-0.6.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/alloc-no-stdlib/alloc-no-stdlib-2.0.4.crate", + "sha256": "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3", + "dest": "cargo/vendor/alloc-no-stdlib-2.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3\", \"files\": {}}", + "dest": "cargo/vendor/alloc-no-stdlib-2.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/allocator-api2/allocator-api2-0.2.21.crate", + "sha256": "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923", + "dest": "cargo/vendor/allocator-api2-0.2.21" + }, + { + "type": "inline", + "contents": "{\"package\": \"683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923\", \"files\": {}}", + "dest": "cargo/vendor/allocator-api2-0.2.21", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ammonia/ammonia-4.1.4.crate", + "sha256": "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d", + "dest": "cargo/vendor/ammonia-4.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d\", \"files\": {}}", + "dest": "cargo/vendor/ammonia-4.1.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/android-activity/android-activity-0.6.1.crate", + "sha256": "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd", + "dest": "cargo/vendor/android-activity-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd\", \"files\": {}}", + "dest": "cargo/vendor/android-activity-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/android-build/android-build-0.1.4.crate", + "sha256": "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7", + "dest": "cargo/vendor/android-build-0.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7\", \"files\": {}}", + "dest": "cargo/vendor/android-build-0.1.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/android-properties/android-properties-0.2.2.crate", + "sha256": "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04", + "dest": "cargo/vendor/android-properties-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04\", \"files\": {}}", + "dest": "cargo/vendor/android-properties-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/android_system_properties/android_system_properties-0.1.6.crate", + "sha256": "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc", + "dest": "cargo/vendor/android_system_properties-0.1.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc\", \"files\": {}}", + "dest": "cargo/vendor/android_system_properties-0.1.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/annotate-snippets/annotate-snippets-0.12.16.crate", + "sha256": "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1", + "dest": "cargo/vendor/annotate-snippets-0.12.16" + }, + { + "type": "inline", + "contents": "{\"package\": \"f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1\", \"files\": {}}", + "dest": "cargo/vendor/annotate-snippets-0.12.16", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anstyle/anstyle-1.0.14.crate", + "sha256": "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000", + "dest": "cargo/vendor/anstyle-1.0.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000\", \"files\": {}}", + "dest": "cargo/vendor/anstyle-1.0.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anyhow/anyhow-1.0.104.crate", + "sha256": "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470", + "dest": "cargo/vendor/anyhow-1.0.104" + }, + { + "type": "inline", + "contents": "{\"package\": \"330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470\", \"files\": {}}", + "dest": "cargo/vendor/anyhow-1.0.104", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anyrender/anyrender-0.13.0.crate", + "sha256": "e00e1af5c6ac9b1e8ed8e431529a1024b7386d5385dce2a83494da3d39a6bab5", + "dest": "cargo/vendor/anyrender-0.13.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e00e1af5c6ac9b1e8ed8e431529a1024b7386d5385dce2a83494da3d39a6bab5\", \"files\": {}}", + "dest": "cargo/vendor/anyrender-0.13.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anyrender_svg/anyrender_svg-0.14.0.crate", + "sha256": "f9a06a9cab20563ae5b392239db09d42222820fd3706fe483db7d3cb25546c24", + "dest": "cargo/vendor/anyrender_svg-0.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"f9a06a9cab20563ae5b392239db09d42222820fd3706fe483db7d3cb25546c24\", \"files\": {}}", + "dest": "cargo/vendor/anyrender_svg-0.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anyrender_vello/anyrender_vello-0.14.0.crate", + "sha256": "f1301d68b8d7a9333c242225bb026dc08f70a5b0ab096087866c06ceab227de5", + "dest": "cargo/vendor/anyrender_vello-0.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"f1301d68b8d7a9333c242225bb026dc08f70a5b0ab096087866c06ceab227de5\", \"files\": {}}", + "dest": "cargo/vendor/anyrender_vello-0.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anyrender_vello_cpu/anyrender_vello_cpu-0.17.0.crate", + "sha256": "6fb392bf738b1e50c50fc82824ff3f875f15632717ba732fe68e2fe6e1011e6c", + "dest": "cargo/vendor/anyrender_vello_cpu-0.17.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6fb392bf738b1e50c50fc82824ff3f875f15632717ba732fe68e2fe6e1011e6c\", \"files\": {}}", + "dest": "cargo/vendor/anyrender_vello_cpu-0.17.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/app_units/app_units-0.7.8.crate", + "sha256": "467b60e4ee6761cd6fd4e03ea58acefc8eec0d1b1def995c1b3b783fa7be8a60", + "dest": "cargo/vendor/app_units-0.7.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"467b60e4ee6761cd6fd4e03ea58acefc8eec0d1b1def995c1b3b783fa7be8a60\", \"files\": {}}", + "dest": "cargo/vendor/app_units-0.7.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/approx/approx-0.5.1.crate", + "sha256": "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6", + "dest": "cargo/vendor/approx-0.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6\", \"files\": {}}", + "dest": "cargo/vendor/approx-0.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/arbitrary/arbitrary-1.4.2.crate", + "sha256": "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1", + "dest": "cargo/vendor/arbitrary-1.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1\", \"files\": {}}", + "dest": "cargo/vendor/arbitrary-1.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/arboard/arboard-3.6.1.crate", + "sha256": "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf", + "dest": "cargo/vendor/arboard-3.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf\", \"files\": {}}", + "dest": "cargo/vendor/arboard-3.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/arg_enum_proc_macro/arg_enum_proc_macro-0.3.4.crate", + "sha256": "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea", + "dest": "cargo/vendor/arg_enum_proc_macro-0.3.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea\", \"files\": {}}", + "dest": "cargo/vendor/arg_enum_proc_macro-0.3.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/arrayref/arrayref-0.3.9.crate", + "sha256": "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb", + "dest": "cargo/vendor/arrayref-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb\", \"files\": {}}", + "dest": "cargo/vendor/arrayref-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/arrayvec/arrayvec-0.7.8.crate", + "sha256": "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56", + "dest": "cargo/vendor/arrayvec-0.7.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56\", \"files\": {}}", + "dest": "cargo/vendor/arrayvec-0.7.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/as-raw-xcb-connection/as-raw-xcb-connection-1.0.1.crate", + "sha256": "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b", + "dest": "cargo/vendor/as-raw-xcb-connection-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b\", \"files\": {}}", + "dest": "cargo/vendor/as-raw-xcb-connection-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ash/ash-0.38.0+1.3.281.crate", + "sha256": "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f", + "dest": "cargo/vendor/ash-0.38.0+1.3.281" + }, + { + "type": "inline", + "contents": "{\"package\": \"0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f\", \"files\": {}}", + "dest": "cargo/vendor/ash-0.38.0+1.3.281", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ashpd/ashpd-0.11.1.crate", + "sha256": "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39", + "dest": "cargo/vendor/ashpd-0.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39\", \"files\": {}}", + "dest": "cargo/vendor/ashpd-0.11.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-broadcast/async-broadcast-0.7.2.crate", + "sha256": "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532", + "dest": "cargo/vendor/async-broadcast-0.7.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532\", \"files\": {}}", + "dest": "cargo/vendor/async-broadcast-0.7.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-channel/async-channel-1.9.0.crate", + "sha256": "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35", + "dest": "cargo/vendor/async-channel-1.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35\", \"files\": {}}", + "dest": "cargo/vendor/async-channel-1.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-channel/async-channel-2.5.0.crate", + "sha256": "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2", + "dest": "cargo/vendor/async-channel-2.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2\", \"files\": {}}", + "dest": "cargo/vendor/async-channel-2.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-compression/async-compression-0.4.43.crate", + "sha256": "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8", + "dest": "cargo/vendor/async-compression-0.4.43" + }, + { + "type": "inline", + "contents": "{\"package\": \"3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8\", \"files\": {}}", + "dest": "cargo/vendor/async-compression-0.4.43", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-executor/async-executor-1.14.0.crate", + "sha256": "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a", + "dest": "cargo/vendor/async-executor-1.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a\", \"files\": {}}", + "dest": "cargo/vendor/async-executor-1.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-imap/async-imap-0.11.3.crate", + "sha256": "9a6728e0f7931b36d725ac234fcb02539e9f7888dbeaaa8a18d9ea5792181570", + "dest": "cargo/vendor/async-imap-0.11.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"9a6728e0f7931b36d725ac234fcb02539e9f7888dbeaaa8a18d9ea5792181570\", \"files\": {}}", + "dest": "cargo/vendor/async-imap-0.11.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-io/async-io-2.6.0.crate", + "sha256": "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc", + "dest": "cargo/vendor/async-io-2.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc\", \"files\": {}}", + "dest": "cargo/vendor/async-io-2.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-lock/async-lock-3.4.2.crate", + "sha256": "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311", + "dest": "cargo/vendor/async-lock-3.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311\", \"files\": {}}", + "dest": "cargo/vendor/async-lock-3.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-process/async-process-2.5.0.crate", + "sha256": "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75", + "dest": "cargo/vendor/async-process-2.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75\", \"files\": {}}", + "dest": "cargo/vendor/async-process-2.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-recursion/async-recursion-1.1.1.crate", + "sha256": "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11", + "dest": "cargo/vendor/async-recursion-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11\", \"files\": {}}", + "dest": "cargo/vendor/async-recursion-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-signal/async-signal-0.2.14.crate", + "sha256": "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485", + "dest": "cargo/vendor/async-signal-0.2.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485\", \"files\": {}}", + "dest": "cargo/vendor/async-signal-0.2.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-stream/async-stream-0.3.6.crate", + "sha256": "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476", + "dest": "cargo/vendor/async-stream-0.3.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476\", \"files\": {}}", + "dest": "cargo/vendor/async-stream-0.3.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-stream-impl/async-stream-impl-0.3.6.crate", + "sha256": "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d", + "dest": "cargo/vendor/async-stream-impl-0.3.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d\", \"files\": {}}", + "dest": "cargo/vendor/async-stream-impl-0.3.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-task/async-task-4.7.1.crate", + "sha256": "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de", + "dest": "cargo/vendor/async-task-4.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de\", \"files\": {}}", + "dest": "cargo/vendor/async-task-4.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-trait/async-trait-0.1.92.crate", + "sha256": "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667", + "dest": "cargo/vendor/async-trait-0.1.92" + }, + { + "type": "inline", + "contents": "{\"package\": \"82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667\", \"files\": {}}", + "dest": "cargo/vendor/async-trait-0.1.92", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/atomic-waker/atomic-waker-1.1.2.crate", + "sha256": "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0", + "dest": "cargo/vendor/atomic-waker-1.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0\", \"files\": {}}", + "dest": "cargo/vendor/atomic-waker-1.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/atomic_refcell/atomic_refcell-0.1.14.crate", + "sha256": "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4", + "dest": "cargo/vendor/atomic_refcell-0.1.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4\", \"files\": {}}", + "dest": "cargo/vendor/atomic_refcell-0.1.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/auto_enums/auto_enums-0.8.10.crate", + "sha256": "3091d68264354f211516b91dce6f71046e444fab1867716035f736667243affb", + "dest": "cargo/vendor/auto_enums-0.8.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"3091d68264354f211516b91dce6f71046e444fab1867716035f736667243affb\", \"files\": {}}", + "dest": "cargo/vendor/auto_enums-0.8.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/autocfg/autocfg-1.5.1.crate", + "sha256": "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53", + "dest": "cargo/vendor/autocfg-1.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\", \"files\": {}}", + "dest": "cargo/vendor/autocfg-1.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/av1-grain/av1-grain-0.2.5.crate", + "sha256": "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8", + "dest": "cargo/vendor/av1-grain-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8\", \"files\": {}}", + "dest": "cargo/vendor/av1-grain-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/avif-serialize/avif-serialize-0.8.9.crate", + "sha256": "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38", + "dest": "cargo/vendor/avif-serialize-0.8.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38\", \"files\": {}}", + "dest": "cargo/vendor/avif-serialize-0.8.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aws-lc-rs/aws-lc-rs-1.18.0.crate", + "sha256": "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e", + "dest": "cargo/vendor/aws-lc-rs-1.18.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e\", \"files\": {}}", + "dest": "cargo/vendor/aws-lc-rs-1.18.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aws-lc-sys/aws-lc-sys-0.44.0.crate", + "sha256": "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483", + "dest": "cargo/vendor/aws-lc-sys-0.44.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483\", \"files\": {}}", + "dest": "cargo/vendor/aws-lc-sys-0.44.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/base64/base64-0.13.1.crate", + "sha256": "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8", + "dest": "cargo/vendor/base64-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8\", \"files\": {}}", + "dest": "cargo/vendor/base64-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/base64/base64-0.22.1.crate", + "sha256": "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6", + "dest": "cargo/vendor/base64-0.22.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6\", \"files\": {}}", + "dest": "cargo/vendor/base64-0.22.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/base64/base64-0.23.1.crate", + "sha256": "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5", + "dest": "cargo/vendor/base64-0.23.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5\", \"files\": {}}", + "dest": "cargo/vendor/base64-0.23.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bincode/bincode-2.0.1.crate", + "sha256": "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740", + "dest": "cargo/vendor/bincode-2.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740\", \"files\": {}}", + "dest": "cargo/vendor/bincode-2.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bindgen/bindgen-0.72.1.crate", + "sha256": "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895", + "dest": "cargo/vendor/bindgen-0.72.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895\", \"files\": {}}", + "dest": "cargo/vendor/bindgen-0.72.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bit-set/bit-set-0.8.0.crate", + "sha256": "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3", + "dest": "cargo/vendor/bit-set-0.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3\", \"files\": {}}", + "dest": "cargo/vendor/bit-set-0.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bit-set/bit-set-0.9.1.crate", + "sha256": "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd", + "dest": "cargo/vendor/bit-set-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd\", \"files\": {}}", + "dest": "cargo/vendor/bit-set-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bit-vec/bit-vec-0.8.0.crate", + "sha256": "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7", + "dest": "cargo/vendor/bit-vec-0.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7\", \"files\": {}}", + "dest": "cargo/vendor/bit-vec-0.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bit-vec/bit-vec-0.9.1.crate", + "sha256": "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51", + "dest": "cargo/vendor/bit-vec-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51\", \"files\": {}}", + "dest": "cargo/vendor/bit-vec-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bit_field/bit_field-0.10.3.crate", + "sha256": "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6", + "dest": "cargo/vendor/bit_field-0.10.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6\", \"files\": {}}", + "dest": "cargo/vendor/bit_field-0.10.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bitflags/bitflags-1.3.2.crate", + "sha256": "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a", + "dest": "cargo/vendor/bitflags-1.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a\", \"files\": {}}", + "dest": "cargo/vendor/bitflags-1.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bitflags/bitflags-2.13.1.crate", + "sha256": "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da", + "dest": "cargo/vendor/bitflags-2.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da\", \"files\": {}}", + "dest": "cargo/vendor/bitflags-2.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bitstream-io/bitstream-io-2.6.0.crate", + "sha256": "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2", + "dest": "cargo/vendor/bitstream-io-2.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2\", \"files\": {}}", + "dest": "cargo/vendor/bitstream-io-2.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/blake3/blake3-1.8.7.crate", + "sha256": "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae", + "dest": "cargo/vendor/blake3-1.8.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae\", \"files\": {}}", + "dest": "cargo/vendor/blake3-1.8.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/blitz-html/blitz-html-0.3.0-beta.2.crate", + "sha256": "cc7bf240a4022c6d5a167c22ec19c9d1b52cd2d74980feec493b2f08e670f0cd", + "dest": "cargo/vendor/blitz-html-0.3.0-beta.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"cc7bf240a4022c6d5a167c22ec19c9d1b52cd2d74980feec493b2f08e670f0cd\", \"files\": {}}", + "dest": "cargo/vendor/blitz-html-0.3.0-beta.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/blitz-traits/blitz-traits-0.3.0-beta.2.crate", + "sha256": "2ec23a2f409b9e92ba7c9c0d1df5992011e82bb1ba9d260c93bd28bc104ca4b5", + "dest": "cargo/vendor/blitz-traits-0.3.0-beta.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"2ec23a2f409b9e92ba7c9c0d1df5992011e82bb1ba9d260c93bd28bc104ca4b5\", \"files\": {}}", + "dest": "cargo/vendor/blitz-traits-0.3.0-beta.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block-buffer/block-buffer-0.10.4.crate", + "sha256": "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71", + "dest": "cargo/vendor/block-buffer-0.10.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71\", \"files\": {}}", + "dest": "cargo/vendor/block-buffer-0.10.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block-buffer/block-buffer-0.12.1.crate", + "sha256": "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa", + "dest": "cargo/vendor/block-buffer-0.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa\", \"files\": {}}", + "dest": "cargo/vendor/block-buffer-0.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block2/block2-0.5.1.crate", + "sha256": "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f", + "dest": "cargo/vendor/block2-0.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f\", \"files\": {}}", + "dest": "cargo/vendor/block2-0.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block2/block2-0.6.2.crate", + "sha256": "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5", + "dest": "cargo/vendor/block2-0.6.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5\", \"files\": {}}", + "dest": "cargo/vendor/block2-0.6.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/blocking/blocking-1.7.0.crate", + "sha256": "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa", + "dest": "cargo/vendor/blocking-1.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa\", \"files\": {}}", + "dest": "cargo/vendor/blocking-1.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bon/bon-3.10.0.crate", + "sha256": "9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6", + "dest": "cargo/vendor/bon-3.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6\", \"files\": {}}", + "dest": "cargo/vendor/bon-3.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bon-macros/bon-macros-3.10.0.crate", + "sha256": "d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5", + "dest": "cargo/vendor/bon-macros-3.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5\", \"files\": {}}", + "dest": "cargo/vendor/bon-macros-3.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/borsh/borsh-1.8.1.crate", + "sha256": "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a", + "dest": "cargo/vendor/borsh-1.8.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a\", \"files\": {}}", + "dest": "cargo/vendor/borsh-1.8.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/brotli-decompressor/brotli-decompressor-5.0.3.crate", + "sha256": "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583", + "dest": "cargo/vendor/brotli-decompressor-5.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583\", \"files\": {}}", + "dest": "cargo/vendor/brotli-decompressor-5.0.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bstr/bstr-1.13.1.crate", + "sha256": "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f", + "dest": "cargo/vendor/bstr-1.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f\", \"files\": {}}", + "dest": "cargo/vendor/bstr-1.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/built/built-0.7.7.crate", + "sha256": "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b", + "dest": "cargo/vendor/built-0.7.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b\", \"files\": {}}", + "dest": "cargo/vendor/built-0.7.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bumpalo/bumpalo-3.20.3.crate", + "sha256": "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649", + "dest": "cargo/vendor/bumpalo-3.20.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649\", \"files\": {}}", + "dest": "cargo/vendor/bumpalo-3.20.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/by_address/by_address-1.2.1.crate", + "sha256": "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06", + "dest": "cargo/vendor/by_address-1.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06\", \"files\": {}}", + "dest": "cargo/vendor/by_address-1.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bytemuck/bytemuck-1.25.2.crate", + "sha256": "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797", + "dest": "cargo/vendor/bytemuck-1.25.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797\", \"files\": {}}", + "dest": "cargo/vendor/bytemuck-1.25.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bytemuck_derive/bytemuck_derive-1.12.0.crate", + "sha256": "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f", + "dest": "cargo/vendor/bytemuck_derive-1.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f\", \"files\": {}}", + "dest": "cargo/vendor/bytemuck_derive-1.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/byteorder/byteorder-1.5.0.crate", + "sha256": "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b", + "dest": "cargo/vendor/byteorder-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b\", \"files\": {}}", + "dest": "cargo/vendor/byteorder-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/byteorder-lite/byteorder-lite-0.1.0.crate", + "sha256": "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495", + "dest": "cargo/vendor/byteorder-lite-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495\", \"files\": {}}", + "dest": "cargo/vendor/byteorder-lite-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bytes/bytes-1.12.1.crate", + "sha256": "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04", + "dest": "cargo/vendor/bytes-1.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04\", \"files\": {}}", + "dest": "cargo/vendor/bytes-1.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/calloop/calloop-0.13.0.crate", + "sha256": "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec", + "dest": "cargo/vendor/calloop-0.13.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec\", \"files\": {}}", + "dest": "cargo/vendor/calloop-0.13.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/calloop/calloop-0.14.4.crate", + "sha256": "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7", + "dest": "cargo/vendor/calloop-0.14.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7\", \"files\": {}}", + "dest": "cargo/vendor/calloop-0.14.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/calloop-wayland-source/calloop-wayland-source-0.3.0.crate", + "sha256": "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20", + "dest": "cargo/vendor/calloop-wayland-source-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20\", \"files\": {}}", + "dest": "cargo/vendor/calloop-wayland-source-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/calloop-wayland-source/calloop-wayland-source-0.4.1.crate", + "sha256": "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa", + "dest": "cargo/vendor/calloop-wayland-source-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa\", \"files\": {}}", + "dest": "cargo/vendor/calloop-wayland-source-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/candle-core/candle-core-0.11.0.crate", + "sha256": "5ecb245093b0f791b89d3420c3df9c6d49c60ab63ba54db896bf8a3baf486706", + "dest": "cargo/vendor/candle-core-0.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5ecb245093b0f791b89d3420c3df9c6d49c60ab63ba54db896bf8a3baf486706\", \"files\": {}}", + "dest": "cargo/vendor/candle-core-0.11.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/candle-nn/candle-nn-0.11.0.crate", + "sha256": "eaa10b6ccc365b33210ce404fbf45e60d3e0bdac1004463cf1052e6ee1c1739a", + "dest": "cargo/vendor/candle-nn-0.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"eaa10b6ccc365b33210ce404fbf45e60d3e0bdac1004463cf1052e6ee1c1739a\", \"files\": {}}", + "dest": "cargo/vendor/candle-nn-0.11.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/candle-transformers/candle-transformers-0.11.0.crate", + "sha256": "3bcbbf7ff00ff6fe2af22b93600195917fe90e90ff48424a140d1a926c44b1c1", + "dest": "cargo/vendor/candle-transformers-0.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"3bcbbf7ff00ff6fe2af22b93600195917fe90e90ff48424a140d1a926c44b1c1\", \"files\": {}}", + "dest": "cargo/vendor/candle-transformers-0.11.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/castaway/castaway-0.2.4.crate", + "sha256": "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a", + "dest": "cargo/vendor/castaway-0.2.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a\", \"files\": {}}", + "dest": "cargo/vendor/castaway-0.2.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cc/cc-1.4.4.crate", + "sha256": "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273", + "dest": "cargo/vendor/cc-1.4.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273\", \"files\": {}}", + "dest": "cargo/vendor/cc-1.4.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cexpr/cexpr-0.6.0.crate", + "sha256": "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766", + "dest": "cargo/vendor/cexpr-0.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766\", \"files\": {}}", + "dest": "cargo/vendor/cexpr-0.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cfg-expr/cfg-expr-0.15.8.crate", + "sha256": "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02", + "dest": "cargo/vendor/cfg-expr-0.15.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02\", \"files\": {}}", + "dest": "cargo/vendor/cfg-expr-0.15.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cfg-if/cfg-if-0.1.10.crate", + "sha256": "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822", + "dest": "cargo/vendor/cfg-if-0.1.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822\", \"files\": {}}", + "dest": "cargo/vendor/cfg-if-0.1.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cfg-if/cfg-if-1.0.4.crate", + "sha256": "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801", + "dest": "cargo/vendor/cfg-if-1.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801\", \"files\": {}}", + "dest": "cargo/vendor/cfg-if-1.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cfg_aliases/cfg_aliases-0.2.2.crate", + "sha256": "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527", + "dest": "cargo/vendor/cfg_aliases-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527\", \"files\": {}}", + "dest": "cargo/vendor/cfg_aliases-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cgl/cgl-0.3.2.crate", + "sha256": "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff", + "dest": "cargo/vendor/cgl-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff\", \"files\": {}}", + "dest": "cargo/vendor/cgl-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/chacha20/chacha20-0.10.2.crate", + "sha256": "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06", + "dest": "cargo/vendor/chacha20-0.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06\", \"files\": {}}", + "dest": "cargo/vendor/chacha20-0.10.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/chrono/chrono-0.4.45.crate", + "sha256": "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327", + "dest": "cargo/vendor/chrono-0.4.45" + }, + { + "type": "inline", + "contents": "{\"package\": \"1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327\", \"files\": {}}", + "dest": "cargo/vendor/chrono-0.4.45", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/clang-sys/clang-sys-1.9.1.crate", + "sha256": "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a", + "dest": "cargo/vendor/clang-sys-1.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a\", \"files\": {}}", + "dest": "cargo/vendor/clang-sys-1.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/clipboard-win/clipboard-win-5.4.1.crate", + "sha256": "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4", + "dest": "cargo/vendor/clipboard-win-5.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4\", \"files\": {}}", + "dest": "cargo/vendor/clipboard-win-5.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/clru/clru-0.6.3.crate", + "sha256": "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5", + "dest": "cargo/vendor/clru-0.6.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5\", \"files\": {}}", + "dest": "cargo/vendor/clru-0.6.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cmake/cmake-0.1.58.crate", + "sha256": "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678", + "dest": "cargo/vendor/cmake-0.1.58" + }, + { + "type": "inline", + "contents": "{\"package\": \"c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678\", \"files\": {}}", + "dest": "cargo/vendor/cmake-0.1.58", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/codespan-reporting/codespan-reporting-0.13.1.crate", + "sha256": "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681", + "dest": "cargo/vendor/codespan-reporting-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681\", \"files\": {}}", + "dest": "cargo/vendor/codespan-reporting-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/color/color-0.3.3.crate", + "sha256": "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf", + "dest": "cargo/vendor/color-0.3.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf\", \"files\": {}}", + "dest": "cargo/vendor/color-0.3.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/color_quant/color_quant-1.1.0.crate", + "sha256": "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b", + "dest": "cargo/vendor/color_quant-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b\", \"files\": {}}", + "dest": "cargo/vendor/color_quant-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/colored/colored-3.1.1.crate", + "sha256": "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34", + "dest": "cargo/vendor/colored-3.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34\", \"files\": {}}", + "dest": "cargo/vendor/colored-3.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/combine/combine-4.6.8.crate", + "sha256": "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e", + "dest": "cargo/vendor/combine-4.6.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e\", \"files\": {}}", + "dest": "cargo/vendor/combine-4.6.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/compact_str/compact_str-0.9.1.crate", + "sha256": "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab", + "dest": "cargo/vendor/compact_str-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab\", \"files\": {}}", + "dest": "cargo/vendor/compact_str-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/compression-codecs/compression-codecs-0.4.38.crate", + "sha256": "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf", + "dest": "cargo/vendor/compression-codecs-0.4.38" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf\", \"files\": {}}", + "dest": "cargo/vendor/compression-codecs-0.4.38", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/compression-core/compression-core-0.4.32.crate", + "sha256": "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789", + "dest": "cargo/vendor/compression-core-0.4.32" + }, + { + "type": "inline", + "contents": "{\"package\": \"cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789\", \"files\": {}}", + "dest": "cargo/vendor/compression-core-0.4.32", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/concurrent-queue/concurrent-queue-2.5.0.crate", + "sha256": "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973", + "dest": "cargo/vendor/concurrent-queue-2.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973\", \"files\": {}}", + "dest": "cargo/vendor/concurrent-queue-2.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/console_error_panic_hook/console_error_panic_hook-0.1.7.crate", + "sha256": "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc", + "dest": "cargo/vendor/console_error_panic_hook-0.1.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc\", \"files\": {}}", + "dest": "cargo/vendor/console_error_panic_hook-0.1.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/console_log/console_log-1.1.0.crate", + "sha256": "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1", + "dest": "cargo/vendor/console_log-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1\", \"files\": {}}", + "dest": "cargo/vendor/console_log-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/const-field-offset/const-field-offset-0.2.1.crate", + "sha256": "d535c69e75b4e3c69e17205b5b37a91936c54086cecd339b70071500786acb9c", + "dest": "cargo/vendor/const-field-offset-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d535c69e75b4e3c69e17205b5b37a91936c54086cecd339b70071500786acb9c\", \"files\": {}}", + "dest": "cargo/vendor/const-field-offset-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/const-field-offset-macro/const-field-offset-macro-0.2.1.crate", + "sha256": "93d5848843a221ac268cfdd6bfd14e68d5d3dfa79d52c880aafab93f9517fb74", + "dest": "cargo/vendor/const-field-offset-macro-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"93d5848843a221ac268cfdd6bfd14e68d5d3dfa79d52c880aafab93f9517fb74\", \"files\": {}}", + "dest": "cargo/vendor/const-field-offset-macro-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/const-oid/const-oid-0.10.2.crate", + "sha256": "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c", + "dest": "cargo/vendor/const-oid-0.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c\", \"files\": {}}", + "dest": "cargo/vendor/const-oid-0.10.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/const-str/const-str-1.1.0.crate", + "sha256": "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f", + "dest": "cargo/vendor/const-str-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f\", \"files\": {}}", + "dest": "cargo/vendor/const-str-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/const_panic/const_panic-0.2.17.crate", + "sha256": "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3", + "dest": "cargo/vendor/const_panic-0.2.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3\", \"files\": {}}", + "dest": "cargo/vendor/const_panic-0.2.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/constant_time_eq/constant_time_eq-0.4.2.crate", + "sha256": "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b", + "dest": "cargo/vendor/constant_time_eq-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b\", \"files\": {}}", + "dest": "cargo/vendor/constant_time_eq-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/convert_case/convert_case-0.10.0.crate", + "sha256": "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9", + "dest": "cargo/vendor/convert_case-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9\", \"files\": {}}", + "dest": "cargo/vendor/convert_case-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/copypasta/copypasta-0.10.2.crate", + "sha256": "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a", + "dest": "cargo/vendor/copypasta-0.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a\", \"files\": {}}", + "dest": "cargo/vendor/copypasta-0.10.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/core-foundation/core-foundation-0.9.4.crate", + "sha256": "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f", + "dest": "cargo/vendor/core-foundation-0.9.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f\", \"files\": {}}", + "dest": "cargo/vendor/core-foundation-0.9.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/core-foundation/core-foundation-0.10.1.crate", + "sha256": "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6", + "dest": "cargo/vendor/core-foundation-0.10.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6\", \"files\": {}}", + "dest": "cargo/vendor/core-foundation-0.10.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/core-foundation-sys/core-foundation-sys-0.8.7.crate", + "sha256": "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b", + "dest": "cargo/vendor/core-foundation-sys-0.8.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b\", \"files\": {}}", + "dest": "cargo/vendor/core-foundation-sys-0.8.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/core-graphics/core-graphics-0.23.2.crate", + "sha256": "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081", + "dest": "cargo/vendor/core-graphics-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081\", \"files\": {}}", + "dest": "cargo/vendor/core-graphics-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/core-graphics-types/core-graphics-types-0.1.3.crate", + "sha256": "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf", + "dest": "cargo/vendor/core-graphics-types-0.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf\", \"files\": {}}", + "dest": "cargo/vendor/core-graphics-types-0.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/core_maths/core_maths-0.1.1.crate", + "sha256": "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30", + "dest": "cargo/vendor/core_maths-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30\", \"files\": {}}", + "dest": "cargo/vendor/core_maths-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cosmic-text/cosmic-text-0.19.0.crate", + "sha256": "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73", + "dest": "cargo/vendor/cosmic-text-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73\", \"files\": {}}", + "dest": "cargo/vendor/cosmic-text-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/countio/countio-0.3.0.crate", + "sha256": "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb", + "dest": "cargo/vendor/countio-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb\", \"files\": {}}", + "dest": "cargo/vendor/countio-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/countme/countme-3.0.1.crate", + "sha256": "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636", + "dest": "cargo/vendor/countme-3.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636\", \"files\": {}}", + "dest": "cargo/vendor/countme-3.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cpufeatures/cpufeatures-0.2.17.crate", + "sha256": "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280", + "dest": "cargo/vendor/cpufeatures-0.2.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280\", \"files\": {}}", + "dest": "cargo/vendor/cpufeatures-0.2.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cpufeatures/cpufeatures-0.3.1.crate", + "sha256": "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566", + "dest": "cargo/vendor/cpufeatures-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566\", \"files\": {}}", + "dest": "cargo/vendor/cpufeatures-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crc32fast/crc32fast-1.5.1.crate", + "sha256": "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550", + "dest": "cargo/vendor/crc32fast-1.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550\", \"files\": {}}", + "dest": "cargo/vendor/crc32fast-1.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/critical-section/critical-section-1.2.0.crate", + "sha256": "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b", + "dest": "cargo/vendor/critical-section-1.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b\", \"files\": {}}", + "dest": "cargo/vendor/critical-section-1.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crossbeam-channel/crossbeam-channel-0.5.16.crate", + "sha256": "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e", + "dest": "cargo/vendor/crossbeam-channel-0.5.16" + }, + { + "type": "inline", + "contents": "{\"package\": \"d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e\", \"files\": {}}", + "dest": "cargo/vendor/crossbeam-channel-0.5.16", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crossbeam-deque/crossbeam-deque-0.8.7.crate", + "sha256": "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb", + "dest": "cargo/vendor/crossbeam-deque-0.8.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb\", \"files\": {}}", + "dest": "cargo/vendor/crossbeam-deque-0.8.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crossbeam-epoch/crossbeam-epoch-0.9.20.crate", + "sha256": "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f", + "dest": "cargo/vendor/crossbeam-epoch-0.9.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f\", \"files\": {}}", + "dest": "cargo/vendor/crossbeam-epoch-0.9.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crossbeam-utils/crossbeam-utils-0.8.22.crate", + "sha256": "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17", + "dest": "cargo/vendor/crossbeam-utils-0.8.22" + }, + { + "type": "inline", + "contents": "{\"package\": \"61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17\", \"files\": {}}", + "dest": "cargo/vendor/crossbeam-utils-0.8.22", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crunchy/crunchy-0.2.4.crate", + "sha256": "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5", + "dest": "cargo/vendor/crunchy-0.2.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5\", \"files\": {}}", + "dest": "cargo/vendor/crunchy-0.2.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crypto-common/crypto-common-0.1.7.crate", + "sha256": "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a", + "dest": "cargo/vendor/crypto-common-0.1.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a\", \"files\": {}}", + "dest": "cargo/vendor/crypto-common-0.1.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crypto-common/crypto-common-0.2.2.crate", + "sha256": "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453", + "dest": "cargo/vendor/crypto-common-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453\", \"files\": {}}", + "dest": "cargo/vendor/crypto-common-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cssparser/cssparser-0.37.0.crate", + "sha256": "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98", + "dest": "cargo/vendor/cssparser-0.37.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98\", \"files\": {}}", + "dest": "cargo/vendor/cssparser-0.37.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cssparser-macros/cssparser-macros-0.7.0.crate", + "sha256": "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf", + "dest": "cargo/vendor/cssparser-macros-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf\", \"files\": {}}", + "dest": "cargo/vendor/cssparser-macros-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ctor/ctor-0.10.1.crate", + "sha256": "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf", + "dest": "cargo/vendor/ctor-0.10.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf\", \"files\": {}}", + "dest": "cargo/vendor/ctor-0.10.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ctor/ctor-1.0.13.crate", + "sha256": "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d", + "dest": "cargo/vendor/ctor-1.0.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d\", \"files\": {}}", + "dest": "cargo/vendor/ctor-1.0.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cursor-icon/cursor-icon-1.2.0.crate", + "sha256": "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f", + "dest": "cargo/vendor/cursor-icon-1.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f\", \"files\": {}}", + "dest": "cargo/vendor/cursor-icon-1.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling/darling-0.20.11.crate", + "sha256": "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee", + "dest": "cargo/vendor/darling-0.20.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee\", \"files\": {}}", + "dest": "cargo/vendor/darling-0.20.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling/darling-0.24.1.crate", + "sha256": "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec", + "dest": "cargo/vendor/darling-0.24.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec\", \"files\": {}}", + "dest": "cargo/vendor/darling-0.24.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling_core/darling_core-0.20.11.crate", + "sha256": "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e", + "dest": "cargo/vendor/darling_core-0.20.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e\", \"files\": {}}", + "dest": "cargo/vendor/darling_core-0.20.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling_core/darling_core-0.24.1.crate", + "sha256": "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff", + "dest": "cargo/vendor/darling_core-0.24.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff\", \"files\": {}}", + "dest": "cargo/vendor/darling_core-0.24.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling_macro/darling_macro-0.20.11.crate", + "sha256": "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead", + "dest": "cargo/vendor/darling_macro-0.20.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead\", \"files\": {}}", + "dest": "cargo/vendor/darling_macro-0.20.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling_macro/darling_macro-0.24.1.crate", + "sha256": "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785", + "dest": "cargo/vendor/darling_macro-0.24.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785\", \"files\": {}}", + "dest": "cargo/vendor/darling_macro-0.24.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dary_heap/dary_heap-0.3.9.crate", + "sha256": "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe", + "dest": "cargo/vendor/dary_heap-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe\", \"files\": {}}", + "dest": "cargo/vendor/dary_heap-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/data-url/data-url-0.3.2.crate", + "sha256": "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376", + "dest": "cargo/vendor/data-url-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376\", \"files\": {}}", + "dest": "cargo/vendor/data-url-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dbus/dbus-0.9.12.crate", + "sha256": "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e", + "dest": "cargo/vendor/dbus-0.9.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e\", \"files\": {}}", + "dest": "cargo/vendor/dbus-0.9.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dbus-secret-service/dbus-secret-service-4.1.0.crate", + "sha256": "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6", + "dest": "cargo/vendor/dbus-secret-service-4.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6\", \"files\": {}}", + "dest": "cargo/vendor/dbus-secret-service-4.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/debug_timer/debug_timer-0.1.3.crate", + "sha256": "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c", + "dest": "cargo/vendor/debug_timer-0.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c\", \"files\": {}}", + "dest": "cargo/vendor/debug_timer-0.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/deranged/deranged-0.5.8.crate", + "sha256": "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c", + "dest": "cargo/vendor/deranged-0.5.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c\", \"files\": {}}", + "dest": "cargo/vendor/deranged-0.5.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_builder/derive_builder-0.20.2.crate", + "sha256": "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947", + "dest": "cargo/vendor/derive_builder-0.20.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947\", \"files\": {}}", + "dest": "cargo/vendor/derive_builder-0.20.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_builder_core/derive_builder_core-0.20.2.crate", + "sha256": "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8", + "dest": "cargo/vendor/derive_builder_core-0.20.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8\", \"files\": {}}", + "dest": "cargo/vendor/derive_builder_core-0.20.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_builder_macro/derive_builder_macro-0.20.2.crate", + "sha256": "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c", + "dest": "cargo/vendor/derive_builder_macro-0.20.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c\", \"files\": {}}", + "dest": "cargo/vendor/derive_builder_macro-0.20.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_more/derive_more-2.1.1.crate", + "sha256": "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134", + "dest": "cargo/vendor/derive_more-2.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134\", \"files\": {}}", + "dest": "cargo/vendor/derive_more-2.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_more-impl/derive_more-impl-2.1.1.crate", + "sha256": "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb", + "dest": "cargo/vendor/derive_more-impl-2.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb\", \"files\": {}}", + "dest": "cargo/vendor/derive_more-impl-2.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_utils/derive_utils-0.16.0.crate", + "sha256": "dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23", + "dest": "cargo/vendor/derive_utils-0.16.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23\", \"files\": {}}", + "dest": "cargo/vendor/derive_utils-0.16.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/deunicode/deunicode-1.6.2.crate", + "sha256": "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04", + "dest": "cargo/vendor/deunicode-1.6.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04\", \"files\": {}}", + "dest": "cargo/vendor/deunicode-1.6.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/digest/digest-0.10.7.crate", + "sha256": "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292", + "dest": "cargo/vendor/digest-0.10.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292\", \"files\": {}}", + "dest": "cargo/vendor/digest-0.10.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/digest/digest-0.11.3.crate", + "sha256": "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2", + "dest": "cargo/vendor/digest-0.11.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2\", \"files\": {}}", + "dest": "cargo/vendor/digest-0.11.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dirs/dirs-6.0.0.crate", + "sha256": "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e", + "dest": "cargo/vendor/dirs-6.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e\", \"files\": {}}", + "dest": "cargo/vendor/dirs-6.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dirs-sys/dirs-sys-0.5.0.crate", + "sha256": "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab", + "dest": "cargo/vendor/dirs-sys-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab\", \"files\": {}}", + "dest": "cargo/vendor/dirs-sys-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dispatch/dispatch-0.2.0.crate", + "sha256": "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b", + "dest": "cargo/vendor/dispatch-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b\", \"files\": {}}", + "dest": "cargo/vendor/dispatch-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dispatch2/dispatch2-0.3.1.crate", + "sha256": "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38", + "dest": "cargo/vendor/dispatch2-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38\", \"files\": {}}", + "dest": "cargo/vendor/dispatch2-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/displaydoc/displaydoc-0.2.7.crate", + "sha256": "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8", + "dest": "cargo/vendor/displaydoc-0.2.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8\", \"files\": {}}", + "dest": "cargo/vendor/displaydoc-0.2.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dlib/dlib-0.5.3.crate", + "sha256": "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a", + "dest": "cargo/vendor/dlib-0.5.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a\", \"files\": {}}", + "dest": "cargo/vendor/dlib-0.5.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/document-features/document-features-0.2.12.crate", + "sha256": "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61", + "dest": "cargo/vendor/document-features-0.2.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61\", \"files\": {}}", + "dest": "cargo/vendor/document-features-0.2.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/downcast-rs/downcast-rs-1.2.1.crate", + "sha256": "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2", + "dest": "cargo/vendor/downcast-rs-1.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2\", \"files\": {}}", + "dest": "cargo/vendor/downcast-rs-1.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dpi/dpi-0.1.2.crate", + "sha256": "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76", + "dest": "cargo/vendor/dpi-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76\", \"files\": {}}", + "dest": "cargo/vendor/dpi-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/drm/drm-0.14.1.crate", + "sha256": "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f", + "dest": "cargo/vendor/drm-0.14.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f\", \"files\": {}}", + "dest": "cargo/vendor/drm-0.14.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/drm-ffi/drm-ffi-0.9.1.crate", + "sha256": "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690", + "dest": "cargo/vendor/drm-ffi-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690\", \"files\": {}}", + "dest": "cargo/vendor/drm-ffi-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/drm-fourcc/drm-fourcc-2.2.0.crate", + "sha256": "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4", + "dest": "cargo/vendor/drm-fourcc-2.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4\", \"files\": {}}", + "dest": "cargo/vendor/drm-fourcc-2.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/drm-sys/drm-sys-0.8.1.crate", + "sha256": "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8", + "dest": "cargo/vendor/drm-sys-0.8.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8\", \"files\": {}}", + "dest": "cargo/vendor/drm-sys-0.8.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dtoa/dtoa-1.0.11.crate", + "sha256": "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590", + "dest": "cargo/vendor/dtoa-1.0.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590\", \"files\": {}}", + "dest": "cargo/vendor/dtoa-1.0.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dtoa-short/dtoa-short-0.3.5.crate", + "sha256": "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87", + "dest": "cargo/vendor/dtoa-short-0.3.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87\", \"files\": {}}", + "dest": "cargo/vendor/dtoa-short-0.3.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dtor/dtor-0.8.1.crate", + "sha256": "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b", + "dest": "cargo/vendor/dtor-0.8.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b\", \"files\": {}}", + "dest": "cargo/vendor/dtor-0.8.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dunce/dunce-1.0.5.crate", + "sha256": "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813", + "dest": "cargo/vendor/dunce-1.0.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813\", \"files\": {}}", + "dest": "cargo/vendor/dunce-1.0.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dyn-stack/dyn-stack-0.13.2.crate", + "sha256": "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8", + "dest": "cargo/vendor/dyn-stack-0.13.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8\", \"files\": {}}", + "dest": "cargo/vendor/dyn-stack-0.13.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dyn-stack-macros/dyn-stack-macros-0.1.3.crate", + "sha256": "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9", + "dest": "cargo/vendor/dyn-stack-macros-0.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9\", \"files\": {}}", + "dest": "cargo/vendor/dyn-stack-macros-0.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/either/either-1.18.0.crate", + "sha256": "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34", + "dest": "cargo/vendor/either-1.18.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34\", \"files\": {}}", + "dest": "cargo/vendor/either-1.18.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/email-encoding/email-encoding-0.4.2.crate", + "sha256": "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd", + "dest": "cargo/vendor/email-encoding-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd\", \"files\": {}}", + "dest": "cargo/vendor/email-encoding-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/email_address/email_address-0.2.9.crate", + "sha256": "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449", + "dest": "cargo/vendor/email_address-0.2.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449\", \"files\": {}}", + "dest": "cargo/vendor/email_address-0.2.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/encoding_rs/encoding_rs-0.8.35.crate", + "sha256": "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3", + "dest": "cargo/vendor/encoding_rs-0.8.35" + }, + { + "type": "inline", + "contents": "{\"package\": \"75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3\", \"files\": {}}", + "dest": "cargo/vendor/encoding_rs-0.8.35", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/endi/endi-1.1.1.crate", + "sha256": "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099", + "dest": "cargo/vendor/endi-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099\", \"files\": {}}", + "dest": "cargo/vendor/endi-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/enum-as-inner/enum-as-inner-0.6.1.crate", + "sha256": "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc", + "dest": "cargo/vendor/enum-as-inner-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc\", \"files\": {}}", + "dest": "cargo/vendor/enum-as-inner-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/enumflags2/enumflags2-0.7.12.crate", + "sha256": "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef", + "dest": "cargo/vendor/enumflags2-0.7.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef\", \"files\": {}}", + "dest": "cargo/vendor/enumflags2-0.7.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/enumflags2_derive/enumflags2_derive-0.7.12.crate", + "sha256": "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827", + "dest": "cargo/vendor/enumflags2_derive-0.7.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827\", \"files\": {}}", + "dest": "cargo/vendor/enumflags2_derive-0.7.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/equator/equator-0.4.2.crate", + "sha256": "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc", + "dest": "cargo/vendor/equator-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc\", \"files\": {}}", + "dest": "cargo/vendor/equator-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/equator-macro/equator-macro-0.4.2.crate", + "sha256": "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3", + "dest": "cargo/vendor/equator-macro-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3\", \"files\": {}}", + "dest": "cargo/vendor/equator-macro-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/equivalent/equivalent-1.0.2.crate", + "sha256": "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f", + "dest": "cargo/vendor/equivalent-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f\", \"files\": {}}", + "dest": "cargo/vendor/equivalent-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/errno/errno-0.3.14.crate", + "sha256": "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb", + "dest": "cargo/vendor/errno-0.3.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb\", \"files\": {}}", + "dest": "cargo/vendor/errno-0.3.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/error-code/error-code-3.4.0.crate", + "sha256": "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7", + "dest": "cargo/vendor/error-code-3.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7\", \"files\": {}}", + "dest": "cargo/vendor/error-code-3.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/esaxx-rs/esaxx-rs-0.1.10.crate", + "sha256": "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6", + "dest": "cargo/vendor/esaxx-rs-0.1.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6\", \"files\": {}}", + "dest": "cargo/vendor/esaxx-rs-0.1.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/euclid/euclid-0.22.14.crate", + "sha256": "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06", + "dest": "cargo/vendor/euclid-0.22.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06\", \"files\": {}}", + "dest": "cargo/vendor/euclid-0.22.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/event-listener/event-listener-2.5.3.crate", + "sha256": "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0", + "dest": "cargo/vendor/event-listener-2.5.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0\", \"files\": {}}", + "dest": "cargo/vendor/event-listener-2.5.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/event-listener/event-listener-5.4.2.crate", + "sha256": "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2", + "dest": "cargo/vendor/event-listener-5.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2\", \"files\": {}}", + "dest": "cargo/vendor/event-listener-5.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/event-listener-strategy/event-listener-strategy-0.5.4.crate", + "sha256": "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93", + "dest": "cargo/vendor/event-listener-strategy-0.5.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93\", \"files\": {}}", + "dest": "cargo/vendor/event-listener-strategy-0.5.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/exr/exr-1.74.2.crate", + "sha256": "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3", + "dest": "cargo/vendor/exr-1.74.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3\", \"files\": {}}", + "dest": "cargo/vendor/exr-1.74.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fallible-iterator/fallible-iterator-0.3.0.crate", + "sha256": "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649", + "dest": "cargo/vendor/fallible-iterator-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649\", \"files\": {}}", + "dest": "cargo/vendor/fallible-iterator-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fallible-streaming-iterator/fallible-streaming-iterator-0.1.9.crate", + "sha256": "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a", + "dest": "cargo/vendor/fallible-streaming-iterator-0.1.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a\", \"files\": {}}", + "dest": "cargo/vendor/fallible-streaming-iterator-0.1.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fancy-regex/fancy-regex-0.14.0.crate", + "sha256": "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298", + "dest": "cargo/vendor/fancy-regex-0.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298\", \"files\": {}}", + "dest": "cargo/vendor/fancy-regex-0.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fancy-regex/fancy-regex-0.18.0.crate", + "sha256": "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277", + "dest": "cargo/vendor/fancy-regex-0.18.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277\", \"files\": {}}", + "dest": "cargo/vendor/fancy-regex-0.18.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fastrand/fastrand-2.5.0.crate", + "sha256": "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223", + "dest": "cargo/vendor/fastrand-2.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223\", \"files\": {}}", + "dest": "cargo/vendor/fastrand-2.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fdeflate/fdeflate-0.3.7.crate", + "sha256": "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c", + "dest": "cargo/vendor/fdeflate-0.3.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c\", \"files\": {}}", + "dest": "cargo/vendor/fdeflate-0.3.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fearless_simd/fearless_simd-0.4.1.crate", + "sha256": "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693", + "dest": "cargo/vendor/fearless_simd-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693\", \"files\": {}}", + "dest": "cargo/vendor/fearless_simd-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/femtovg/femtovg-0.25.1.crate", + "sha256": "f43d05da42e81724c16d34a150fb7fda53d3f786e5a94673ee526ff8602f0f6a", + "dest": "cargo/vendor/femtovg-0.25.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f43d05da42e81724c16d34a150fb7fda53d3f786e5a94673ee526ff8602f0f6a\", \"files\": {}}", + "dest": "cargo/vendor/femtovg-0.25.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/field-offset/field-offset-0.3.6.crate", + "sha256": "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f", + "dest": "cargo/vendor/field-offset-0.3.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f\", \"files\": {}}", + "dest": "cargo/vendor/field-offset-0.3.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/filetime/filetime-0.2.29.crate", + "sha256": "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759", + "dest": "cargo/vendor/filetime-0.2.29" + }, + { + "type": "inline", + "contents": "{\"package\": \"5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759\", \"files\": {}}", + "dest": "cargo/vendor/filetime-0.2.29", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/find-msvc-tools/find-msvc-tools-0.1.11.crate", + "sha256": "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890", + "dest": "cargo/vendor/find-msvc-tools-0.1.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890\", \"files\": {}}", + "dest": "cargo/vendor/find-msvc-tools-0.1.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fixed_decimal/fixed_decimal-0.7.2.crate", + "sha256": "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd", + "dest": "cargo/vendor/fixed_decimal-0.7.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd\", \"files\": {}}", + "dest": "cargo/vendor/fixed_decimal-0.7.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fixedbitset/fixedbitset-0.5.7.crate", + "sha256": "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99", + "dest": "cargo/vendor/fixedbitset-0.5.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99\", \"files\": {}}", + "dest": "cargo/vendor/fixedbitset-0.5.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/flate2/flate2-1.1.10.crate", + "sha256": "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb", + "dest": "cargo/vendor/flate2-1.1.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb\", \"files\": {}}", + "dest": "cargo/vendor/flate2-1.1.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/float-cmp/float-cmp-0.9.0.crate", + "sha256": "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4", + "dest": "cargo/vendor/float-cmp-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4\", \"files\": {}}", + "dest": "cargo/vendor/float-cmp-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/float8/float8-0.7.0.crate", + "sha256": "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc", + "dest": "cargo/vendor/float8-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc\", \"files\": {}}", + "dest": "cargo/vendor/float8-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fnv/fnv-1.0.7.crate", + "sha256": "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1", + "dest": "cargo/vendor/fnv-1.0.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1\", \"files\": {}}", + "dest": "cargo/vendor/fnv-1.0.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/foldhash/foldhash-0.1.5.crate", + "sha256": "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2", + "dest": "cargo/vendor/foldhash-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2\", \"files\": {}}", + "dest": "cargo/vendor/foldhash-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/foldhash/foldhash-0.2.0.crate", + "sha256": "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb", + "dest": "cargo/vendor/foldhash-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb\", \"files\": {}}", + "dest": "cargo/vendor/foldhash-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/font-types/font-types-0.11.3.crate", + "sha256": "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7", + "dest": "cargo/vendor/font-types-0.11.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7\", \"files\": {}}", + "dest": "cargo/vendor/font-types-0.11.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/font-types/font-types-0.12.4.crate", + "sha256": "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23", + "dest": "cargo/vendor/font-types-0.12.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23\", \"files\": {}}", + "dest": "cargo/vendor/font-types-0.12.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fontconfig-parser/fontconfig-parser-0.5.8.crate", + "sha256": "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646", + "dest": "cargo/vendor/fontconfig-parser-0.5.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646\", \"files\": {}}", + "dest": "cargo/vendor/fontconfig-parser-0.5.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fontdb/fontdb-0.23.0.crate", + "sha256": "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905", + "dest": "cargo/vendor/fontdb-0.23.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905\", \"files\": {}}", + "dest": "cargo/vendor/fontdb-0.23.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fontdb/fontdb-0.24.0.crate", + "sha256": "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f", + "dest": "cargo/vendor/fontdb-0.24.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f\", \"files\": {}}", + "dest": "cargo/vendor/fontdb-0.24.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fontique/fontique-0.10.0.crate", + "sha256": "274fa4f0f0a926ae182c7c076c078cce8a38471d15e61a102a02cac984be9813", + "dest": "cargo/vendor/fontique-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"274fa4f0f0a926ae182c7c076c078cce8a38471d15e61a102a02cac984be9813\", \"files\": {}}", + "dest": "cargo/vendor/fontique-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fontique/fontique-0.11.1.crate", + "sha256": "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a", + "dest": "cargo/vendor/fontique-0.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a\", \"files\": {}}", + "dest": "cargo/vendor/fontique-0.11.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/foreign-types/foreign-types-0.5.0.crate", + "sha256": "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965", + "dest": "cargo/vendor/foreign-types-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965\", \"files\": {}}", + "dest": "cargo/vendor/foreign-types-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/foreign-types-macros/foreign-types-macros-0.2.4.crate", + "sha256": "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225", + "dest": "cargo/vendor/foreign-types-macros-0.2.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225\", \"files\": {}}", + "dest": "cargo/vendor/foreign-types-macros-0.2.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/foreign-types-shared/foreign-types-shared-0.3.1.crate", + "sha256": "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b", + "dest": "cargo/vendor/foreign-types-shared-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b\", \"files\": {}}", + "dest": "cargo/vendor/foreign-types-shared-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/form_urlencoded/form_urlencoded-1.2.2.crate", + "sha256": "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf", + "dest": "cargo/vendor/form_urlencoded-1.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf\", \"files\": {}}", + "dest": "cargo/vendor/form_urlencoded-1.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fs_extra/fs_extra-1.3.0.crate", + "sha256": "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c", + "dest": "cargo/vendor/fs_extra-1.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c\", \"files\": {}}", + "dest": "cargo/vendor/fs_extra-1.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures/futures-0.3.34.crate", + "sha256": "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3", + "dest": "cargo/vendor/futures-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3\", \"files\": {}}", + "dest": "cargo/vendor/futures-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-channel/futures-channel-0.3.34.crate", + "sha256": "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4", + "dest": "cargo/vendor/futures-channel-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4\", \"files\": {}}", + "dest": "cargo/vendor/futures-channel-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-core/futures-core-0.3.34.crate", + "sha256": "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e", + "dest": "cargo/vendor/futures-core-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e\", \"files\": {}}", + "dest": "cargo/vendor/futures-core-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-executor/futures-executor-0.3.34.crate", + "sha256": "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432", + "dest": "cargo/vendor/futures-executor-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432\", \"files\": {}}", + "dest": "cargo/vendor/futures-executor-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-intrusive/futures-intrusive-0.5.0.crate", + "sha256": "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f", + "dest": "cargo/vendor/futures-intrusive-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f\", \"files\": {}}", + "dest": "cargo/vendor/futures-intrusive-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-io/futures-io-0.3.34.crate", + "sha256": "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed", + "dest": "cargo/vendor/futures-io-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed\", \"files\": {}}", + "dest": "cargo/vendor/futures-io-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-lite/futures-lite-2.6.1.crate", + "sha256": "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad", + "dest": "cargo/vendor/futures-lite-2.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad\", \"files\": {}}", + "dest": "cargo/vendor/futures-lite-2.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-macro/futures-macro-0.3.34.crate", + "sha256": "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44", + "dest": "cargo/vendor/futures-macro-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44\", \"files\": {}}", + "dest": "cargo/vendor/futures-macro-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-sink/futures-sink-0.3.34.crate", + "sha256": "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d", + "dest": "cargo/vendor/futures-sink-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d\", \"files\": {}}", + "dest": "cargo/vendor/futures-sink-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-task/futures-task-0.3.34.crate", + "sha256": "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd", + "dest": "cargo/vendor/futures-task-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd\", \"files\": {}}", + "dest": "cargo/vendor/futures-task-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-util/futures-util-0.3.34.crate", + "sha256": "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc", + "dest": "cargo/vendor/futures-util-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc\", \"files\": {}}", + "dest": "cargo/vendor/futures-util-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gbm/gbm-0.18.0.crate", + "sha256": "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a", + "dest": "cargo/vendor/gbm-0.18.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a\", \"files\": {}}", + "dest": "cargo/vendor/gbm-0.18.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gbm-sys/gbm-sys-0.4.0.crate", + "sha256": "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f", + "dest": "cargo/vendor/gbm-sys-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f\", \"files\": {}}", + "dest": "cargo/vendor/gbm-sys-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gearhash/gearhash-0.1.3.crate", + "sha256": "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213", + "dest": "cargo/vendor/gearhash-0.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213\", \"files\": {}}", + "dest": "cargo/vendor/gearhash-0.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gemm/gemm-0.19.0.crate", + "sha256": "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb", + "dest": "cargo/vendor/gemm-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb\", \"files\": {}}", + "dest": "cargo/vendor/gemm-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gemm-c32/gemm-c32-0.19.0.crate", + "sha256": "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c", + "dest": "cargo/vendor/gemm-c32-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c\", \"files\": {}}", + "dest": "cargo/vendor/gemm-c32-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gemm-c64/gemm-c64-0.19.0.crate", + "sha256": "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f", + "dest": "cargo/vendor/gemm-c64-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f\", \"files\": {}}", + "dest": "cargo/vendor/gemm-c64-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gemm-common/gemm-common-0.19.0.crate", + "sha256": "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e", + "dest": "cargo/vendor/gemm-common-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e\", \"files\": {}}", + "dest": "cargo/vendor/gemm-common-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gemm-f16/gemm-f16-0.19.0.crate", + "sha256": "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e", + "dest": "cargo/vendor/gemm-f16-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e\", \"files\": {}}", + "dest": "cargo/vendor/gemm-f16-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gemm-f32/gemm-f32-0.19.0.crate", + "sha256": "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c", + "dest": "cargo/vendor/gemm-f32-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c\", \"files\": {}}", + "dest": "cargo/vendor/gemm-f32-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gemm-f64/gemm-f64-0.19.0.crate", + "sha256": "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a", + "dest": "cargo/vendor/gemm-f64-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a\", \"files\": {}}", + "dest": "cargo/vendor/gemm-f64-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/generic-array/generic-array-0.14.7.crate", + "sha256": "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a", + "dest": "cargo/vendor/generic-array-0.14.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a\", \"files\": {}}", + "dest": "cargo/vendor/generic-array-0.14.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gethostname/gethostname-1.1.0.crate", + "sha256": "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8", + "dest": "cargo/vendor/gethostname-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8\", \"files\": {}}", + "dest": "cargo/vendor/gethostname-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/getopts/getopts-0.2.24.crate", + "sha256": "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df", + "dest": "cargo/vendor/getopts-0.2.24" + }, + { + "type": "inline", + "contents": "{\"package\": \"cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df\", \"files\": {}}", + "dest": "cargo/vendor/getopts-0.2.24", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/getrandom/getrandom-0.2.17.crate", + "sha256": "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0", + "dest": "cargo/vendor/getrandom-0.2.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0\", \"files\": {}}", + "dest": "cargo/vendor/getrandom-0.2.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/getrandom/getrandom-0.3.4.crate", + "sha256": "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd", + "dest": "cargo/vendor/getrandom-0.3.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd\", \"files\": {}}", + "dest": "cargo/vendor/getrandom-0.3.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/getrandom/getrandom-0.4.3.crate", + "sha256": "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099", + "dest": "cargo/vendor/getrandom-0.4.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099\", \"files\": {}}", + "dest": "cargo/vendor/getrandom-0.4.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gif/gif-0.13.3.crate", + "sha256": "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b", + "dest": "cargo/vendor/gif-0.13.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b\", \"files\": {}}", + "dest": "cargo/vendor/gif-0.13.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gif/gif-0.14.2.crate", + "sha256": "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159", + "dest": "cargo/vendor/gif-0.14.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159\", \"files\": {}}", + "dest": "cargo/vendor/gif-0.14.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/git-version/git-version-0.3.9.crate", + "sha256": "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19", + "dest": "cargo/vendor/git-version-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19\", \"files\": {}}", + "dest": "cargo/vendor/git-version-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/git-version-macro/git-version-macro-0.3.9.crate", + "sha256": "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0", + "dest": "cargo/vendor/git-version-macro-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0\", \"files\": {}}", + "dest": "cargo/vendor/git-version-macro-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gl_generator/gl_generator-0.14.0.crate", + "sha256": "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d", + "dest": "cargo/vendor/gl_generator-0.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d\", \"files\": {}}", + "dest": "cargo/vendor/gl_generator-0.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glifo/glifo-0.2.0.crate", + "sha256": "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a", + "dest": "cargo/vendor/glifo-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a\", \"files\": {}}", + "dest": "cargo/vendor/glifo-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glob/glob-0.3.4.crate", + "sha256": "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b", + "dest": "cargo/vendor/glob-0.3.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b\", \"files\": {}}", + "dest": "cargo/vendor/glob-0.3.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/globset/globset-0.4.20.crate", + "sha256": "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988", + "dest": "cargo/vendor/globset-0.4.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988\", \"files\": {}}", + "dest": "cargo/vendor/globset-0.4.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glow/glow-0.17.0.crate", + "sha256": "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5", + "dest": "cargo/vendor/glow-0.17.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5\", \"files\": {}}", + "dest": "cargo/vendor/glow-0.17.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glutin/glutin-0.32.3.crate", + "sha256": "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325", + "dest": "cargo/vendor/glutin-0.32.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325\", \"files\": {}}", + "dest": "cargo/vendor/glutin-0.32.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glutin-winit/glutin-winit-0.5.0.crate", + "sha256": "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f", + "dest": "cargo/vendor/glutin-winit-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f\", \"files\": {}}", + "dest": "cargo/vendor/glutin-winit-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glutin_egl_sys/glutin_egl_sys-0.7.1.crate", + "sha256": "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2", + "dest": "cargo/vendor/glutin_egl_sys-0.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2\", \"files\": {}}", + "dest": "cargo/vendor/glutin_egl_sys-0.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glutin_glx_sys/glutin_glx_sys-0.6.1.crate", + "sha256": "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185", + "dest": "cargo/vendor/glutin_glx_sys-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185\", \"files\": {}}", + "dest": "cargo/vendor/glutin_glx_sys-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glutin_wgl_sys/glutin_wgl_sys-0.6.1.crate", + "sha256": "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e", + "dest": "cargo/vendor/glutin_wgl_sys-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e\", \"files\": {}}", + "dest": "cargo/vendor/glutin_wgl_sys-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gpu-allocator/gpu-allocator-0.28.0.crate", + "sha256": "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795", + "dest": "cargo/vendor/gpu-allocator-0.28.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795\", \"files\": {}}", + "dest": "cargo/vendor/gpu-allocator-0.28.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gpu-descriptor/gpu-descriptor-0.3.2.crate", + "sha256": "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca", + "dest": "cargo/vendor/gpu-descriptor-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca\", \"files\": {}}", + "dest": "cargo/vendor/gpu-descriptor-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gpu-descriptor-types/gpu-descriptor-types-0.2.0.crate", + "sha256": "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91", + "dest": "cargo/vendor/gpu-descriptor-types-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91\", \"files\": {}}", + "dest": "cargo/vendor/gpu-descriptor-types-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/grid/grid-1.0.1.crate", + "sha256": "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5", + "dest": "cargo/vendor/grid-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5\", \"files\": {}}", + "dest": "cargo/vendor/grid-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/guillotiere/guillotiere-0.7.0.crate", + "sha256": "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab", + "dest": "cargo/vendor/guillotiere-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab\", \"files\": {}}", + "dest": "cargo/vendor/guillotiere-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/h2/h2-0.4.19.crate", + "sha256": "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16", + "dest": "cargo/vendor/h2-0.4.19" + }, + { + "type": "inline", + "contents": "{\"package\": \"ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16\", \"files\": {}}", + "dest": "cargo/vendor/h2-0.4.19", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/half/half-2.7.1.crate", + "sha256": "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b", + "dest": "cargo/vendor/half-2.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b\", \"files\": {}}", + "dest": "cargo/vendor/half-2.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/harfrust/harfrust-0.5.2.crate", + "sha256": "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9", + "dest": "cargo/vendor/harfrust-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9\", \"files\": {}}", + "dest": "cargo/vendor/harfrust-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/harfrust/harfrust-0.8.4.crate", + "sha256": "d12c7c642d4ce8c2e784b4751a6634bd89583912265add4a679a8882d123fbcd", + "dest": "cargo/vendor/harfrust-0.8.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"d12c7c642d4ce8c2e784b4751a6634bd89583912265add4a679a8882d123fbcd\", \"files\": {}}", + "dest": "cargo/vendor/harfrust-0.8.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/harfrust/harfrust-0.12.0.crate", + "sha256": "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9", + "dest": "cargo/vendor/harfrust-0.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9\", \"files\": {}}", + "dest": "cargo/vendor/harfrust-0.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hashbrown/hashbrown-0.14.5.crate", + "sha256": "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1", + "dest": "cargo/vendor/hashbrown-0.14.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1\", \"files\": {}}", + "dest": "cargo/vendor/hashbrown-0.14.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hashbrown/hashbrown-0.15.5.crate", + "sha256": "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1", + "dest": "cargo/vendor/hashbrown-0.15.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1\", \"files\": {}}", + "dest": "cargo/vendor/hashbrown-0.15.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hashbrown/hashbrown-0.16.1.crate", + "sha256": "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100", + "dest": "cargo/vendor/hashbrown-0.16.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100\", \"files\": {}}", + "dest": "cargo/vendor/hashbrown-0.16.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.1.crate", + "sha256": "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a", + "dest": "cargo/vendor/hashbrown-0.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a\", \"files\": {}}", + "dest": "cargo/vendor/hashbrown-0.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hashify/hashify-0.2.9.crate", + "sha256": "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26", + "dest": "cargo/vendor/hashify-0.2.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26\", \"files\": {}}", + "dest": "cargo/vendor/hashify-0.2.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hashlink/hashlink-0.12.1.crate", + "sha256": "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248", + "dest": "cargo/vendor/hashlink-0.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248\", \"files\": {}}", + "dest": "cargo/vendor/hashlink-0.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/heapify/heapify-0.2.0.crate", + "sha256": "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e", + "dest": "cargo/vendor/heapify-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e\", \"files\": {}}", + "dest": "cargo/vendor/heapify-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/heck/heck-0.5.0.crate", + "sha256": "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea", + "dest": "cargo/vendor/heck-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\", \"files\": {}}", + "dest": "cargo/vendor/heck-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hermit-abi/hermit-abi-0.3.9.crate", + "sha256": "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024", + "dest": "cargo/vendor/hermit-abi-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024\", \"files\": {}}", + "dest": "cargo/vendor/hermit-abi-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hermit-abi/hermit-abi-0.5.2.crate", + "sha256": "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c", + "dest": "cargo/vendor/hermit-abi-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c\", \"files\": {}}", + "dest": "cargo/vendor/hermit-abi-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hex/hex-0.4.3.crate", + "sha256": "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70", + "dest": "cargo/vendor/hex-0.4.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70\", \"files\": {}}", + "dest": "cargo/vendor/hex-0.4.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hexf-parse/hexf-parse-0.2.1.crate", + "sha256": "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df", + "dest": "cargo/vendor/hexf-parse-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df\", \"files\": {}}", + "dest": "cargo/vendor/hexf-parse-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hf-hub/hf-hub-1.0.0.crate", + "sha256": "e7ccb6bcc85dec15413ef5949879f9a5497ca4568ed702547eb83fac23376e5c", + "dest": "cargo/vendor/hf-hub-1.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e7ccb6bcc85dec15413ef5949879f9a5497ca4568ed702547eb83fac23376e5c\", \"files\": {}}", + "dest": "cargo/vendor/hf-hub-1.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hf-xet/hf-xet-1.6.0.crate", + "sha256": "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754", + "dest": "cargo/vendor/hf-xet-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754\", \"files\": {}}", + "dest": "cargo/vendor/hf-xet-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hostname/hostname-0.4.2.crate", + "sha256": "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd", + "dest": "cargo/vendor/hostname-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd\", \"files\": {}}", + "dest": "cargo/vendor/hostname-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/html-escape/html-escape-0.2.15.crate", + "sha256": "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5", + "dest": "cargo/vendor/html-escape-0.2.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5\", \"files\": {}}", + "dest": "cargo/vendor/html-escape-0.2.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/html5ever/html5ever-0.39.0.crate", + "sha256": "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8", + "dest": "cargo/vendor/html5ever-0.39.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8\", \"files\": {}}", + "dest": "cargo/vendor/html5ever-0.39.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/htmlparser/htmlparser-0.2.1.crate", + "sha256": "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144", + "dest": "cargo/vendor/htmlparser-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144\", \"files\": {}}", + "dest": "cargo/vendor/htmlparser-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/http/http-1.5.0.crate", + "sha256": "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0", + "dest": "cargo/vendor/http-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0\", \"files\": {}}", + "dest": "cargo/vendor/http-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/http-body/http-body-1.1.0.crate", + "sha256": "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c", + "dest": "cargo/vendor/http-body-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c\", \"files\": {}}", + "dest": "cargo/vendor/http-body-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/http-body-util/http-body-util-0.1.5.crate", + "sha256": "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c", + "dest": "cargo/vendor/http-body-util-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c\", \"files\": {}}", + "dest": "cargo/vendor/http-body-util-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/httparse/httparse-1.10.1.crate", + "sha256": "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87", + "dest": "cargo/vendor/httparse-1.10.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87\", \"files\": {}}", + "dest": "cargo/vendor/httparse-1.10.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/httpdate/httpdate-1.0.3.crate", + "sha256": "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9", + "dest": "cargo/vendor/httpdate-1.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9\", \"files\": {}}", + "dest": "cargo/vendor/httpdate-1.0.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/humantime/humantime-2.4.0.crate", + "sha256": "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15", + "dest": "cargo/vendor/humantime-2.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15\", \"files\": {}}", + "dest": "cargo/vendor/humantime-2.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hybrid-array/hybrid-array-0.4.14.crate", + "sha256": "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b", + "dest": "cargo/vendor/hybrid-array-0.4.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b\", \"files\": {}}", + "dest": "cargo/vendor/hybrid-array-0.4.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper/hyper-1.11.1.crate", + "sha256": "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43", + "dest": "cargo/vendor/hyper-1.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43\", \"files\": {}}", + "dest": "cargo/vendor/hyper-1.11.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.27.9.crate", + "sha256": "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f", + "dest": "cargo/vendor/hyper-rustls-0.27.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f\", \"files\": {}}", + "dest": "cargo/vendor/hyper-rustls-0.27.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper-util/hyper-util-0.1.20.crate", + "sha256": "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0", + "dest": "cargo/vendor/hyper-util-0.1.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0\", \"files\": {}}", + "dest": "cargo/vendor/hyper-util-0.1.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-backend-android-activity/i-slint-backend-android-activity-1.17.1.crate", + "sha256": "9805bcdcbeea6626a0054347ee55d2424e0d5574b563472719d3fc7a11852242", + "dest": "cargo/vendor/i-slint-backend-android-activity-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9805bcdcbeea6626a0054347ee55d2424e0d5574b563472719d3fc7a11852242\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-backend-android-activity-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-backend-linuxkms/i-slint-backend-linuxkms-1.17.1.crate", + "sha256": "68e76f2b13846ca54bba6b8eb3b5454b26879ad987396d0a2d8abedc31a6c469", + "dest": "cargo/vendor/i-slint-backend-linuxkms-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"68e76f2b13846ca54bba6b8eb3b5454b26879ad987396d0a2d8abedc31a6c469\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-backend-linuxkms-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-backend-selector/i-slint-backend-selector-1.17.1.crate", + "sha256": "2247a4590a02a466b2f4916cc789dfe66247f6af826f00eb84a2685259a45c1c", + "dest": "cargo/vendor/i-slint-backend-selector-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2247a4590a02a466b2f4916cc789dfe66247f6af826f00eb84a2685259a45c1c\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-backend-selector-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-backend-testing/i-slint-backend-testing-1.17.1.crate", + "sha256": "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786", + "dest": "cargo/vendor/i-slint-backend-testing-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-backend-testing-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-backend-winit/i-slint-backend-winit-1.17.1.crate", + "sha256": "a27c7e8f53418d3017362465367b6612b6e439a68097d627d1b91c94b885f0a6", + "dest": "cargo/vendor/i-slint-backend-winit-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"a27c7e8f53418d3017362465367b6612b6e439a68097d627d1b91c94b885f0a6\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-backend-winit-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-common/i-slint-common-1.17.1.crate", + "sha256": "52e63f8ea55ba21e44567ae67d2b01a4f5d09bb1fab693b1e6c79d96bef9e456", + "dest": "cargo/vendor/i-slint-common-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"52e63f8ea55ba21e44567ae67d2b01a4f5d09bb1fab693b1e6c79d96bef9e456\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-common-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-compiler/i-slint-compiler-1.17.1.crate", + "sha256": "45ea275b15a425c7f2f77481151e1f5f8f1ea83feae580273090ef6b9e192218", + "dest": "cargo/vendor/i-slint-compiler-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"45ea275b15a425c7f2f77481151e1f5f8f1ea83feae580273090ef6b9e192218\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-compiler-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-core/i-slint-core-1.17.1.crate", + "sha256": "4e407dc2bd5385dcbdd9c1cf927213d2e4bacda667545abf8839c60f8290f754", + "dest": "cargo/vendor/i-slint-core-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"4e407dc2bd5385dcbdd9c1cf927213d2e4bacda667545abf8839c60f8290f754\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-core-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-core-macros/i-slint-core-macros-1.17.1.crate", + "sha256": "3a842640da4c4d0909d25cc5a51e1f9cde02bd48f7fdfa94df10d595d4e3c80a", + "dest": "cargo/vendor/i-slint-core-macros-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"3a842640da4c4d0909d25cc5a51e1f9cde02bd48f7fdfa94df10d595d4e3c80a\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-core-macros-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-renderer-femtovg/i-slint-renderer-femtovg-1.17.1.crate", + "sha256": "78d0d99a18d66738b684758fe8460743a0aa0f64a96e05940942231a340085b1", + "dest": "cargo/vendor/i-slint-renderer-femtovg-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"78d0d99a18d66738b684758fe8460743a0aa0f64a96e05940942231a340085b1\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-renderer-femtovg-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-renderer-skia/i-slint-renderer-skia-1.17.1.crate", + "sha256": "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57", + "dest": "cargo/vendor/i-slint-renderer-skia-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-renderer-skia-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/i-slint-renderer-software/i-slint-renderer-software-1.17.1.crate", + "sha256": "73496f164f8e471877c35e0fadb996dcc674922712abd9c2be2cbe80c58859bd", + "dest": "cargo/vendor/i-slint-renderer-software-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"73496f164f8e471877c35e0fadb996dcc674922712abd9c2be2cbe80c58859bd\", \"files\": {}}", + "dest": "cargo/vendor/i-slint-renderer-software-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/iana-time-zone/iana-time-zone-0.1.65.crate", + "sha256": "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470", + "dest": "cargo/vendor/iana-time-zone-0.1.65" + }, + { + "type": "inline", + "contents": "{\"package\": \"e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470\", \"files\": {}}", + "dest": "cargo/vendor/iana-time-zone-0.1.65", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/iana-time-zone-haiku/iana-time-zone-haiku-0.1.2.crate", + "sha256": "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f", + "dest": "cargo/vendor/iana-time-zone-haiku-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f\", \"files\": {}}", + "dest": "cargo/vendor/iana-time-zone-haiku-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_collections/icu_collections-2.3.0.crate", + "sha256": "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513", + "dest": "cargo/vendor/icu_collections-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513\", \"files\": {}}", + "dest": "cargo/vendor/icu_collections-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_decimal/icu_decimal-2.3.0.crate", + "sha256": "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08", + "dest": "cargo/vendor/icu_decimal-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08\", \"files\": {}}", + "dest": "cargo/vendor/icu_decimal-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_decimal_data/icu_decimal_data-2.3.0.crate", + "sha256": "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c", + "dest": "cargo/vendor/icu_decimal_data-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c\", \"files\": {}}", + "dest": "cargo/vendor/icu_decimal_data-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_locale_core/icu_locale_core-2.3.0.crate", + "sha256": "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb", + "dest": "cargo/vendor/icu_locale_core-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb\", \"files\": {}}", + "dest": "cargo/vendor/icu_locale_core-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_locale_fallback/icu_locale_fallback-2.3.0.crate", + "sha256": "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9", + "dest": "cargo/vendor/icu_locale_fallback-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9\", \"files\": {}}", + "dest": "cargo/vendor/icu_locale_fallback-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_locale_fallback_data/icu_locale_fallback_data-2.3.0.crate", + "sha256": "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8", + "dest": "cargo/vendor/icu_locale_fallback_data-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8\", \"files\": {}}", + "dest": "cargo/vendor/icu_locale_fallback_data-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_normalizer/icu_normalizer-2.3.0.crate", + "sha256": "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f", + "dest": "cargo/vendor/icu_normalizer-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f\", \"files\": {}}", + "dest": "cargo/vendor/icu_normalizer-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_normalizer_data/icu_normalizer_data-2.3.0.crate", + "sha256": "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0", + "dest": "cargo/vendor/icu_normalizer_data-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0\", \"files\": {}}", + "dest": "cargo/vendor/icu_normalizer_data-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_plurals/icu_plurals-2.3.0.crate", + "sha256": "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593", + "dest": "cargo/vendor/icu_plurals-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593\", \"files\": {}}", + "dest": "cargo/vendor/icu_plurals-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_plurals_data/icu_plurals_data-2.3.0.crate", + "sha256": "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf", + "dest": "cargo/vendor/icu_plurals_data-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf\", \"files\": {}}", + "dest": "cargo/vendor/icu_plurals_data-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_properties/icu_properties-2.3.0.crate", + "sha256": "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148", + "dest": "cargo/vendor/icu_properties-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148\", \"files\": {}}", + "dest": "cargo/vendor/icu_properties-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_properties_data/icu_properties_data-2.3.0.crate", + "sha256": "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa", + "dest": "cargo/vendor/icu_properties_data-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa\", \"files\": {}}", + "dest": "cargo/vendor/icu_properties_data-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_provider/icu_provider-2.3.1.crate", + "sha256": "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73", + "dest": "cargo/vendor/icu_provider-2.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73\", \"files\": {}}", + "dest": "cargo/vendor/icu_provider-2.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_segmenter/icu_segmenter-2.3.0.crate", + "sha256": "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db", + "dest": "cargo/vendor/icu_segmenter-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db\", \"files\": {}}", + "dest": "cargo/vendor/icu_segmenter-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/icu_segmenter_data/icu_segmenter_data-2.3.0.crate", + "sha256": "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad", + "dest": "cargo/vendor/icu_segmenter_data-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad\", \"files\": {}}", + "dest": "cargo/vendor/icu_segmenter_data-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ident_case/ident_case-1.0.1.crate", + "sha256": "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39", + "dest": "cargo/vendor/ident_case-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39\", \"files\": {}}", + "dest": "cargo/vendor/ident_case-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/idna/idna-1.1.0.crate", + "sha256": "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de", + "dest": "cargo/vendor/idna-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de\", \"files\": {}}", + "dest": "cargo/vendor/idna-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/idna_adapter/idna_adapter-1.2.2.crate", + "sha256": "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714", + "dest": "cargo/vendor/idna_adapter-1.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714\", \"files\": {}}", + "dest": "cargo/vendor/idna_adapter-1.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/image/image-0.25.6.crate", + "sha256": "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a", + "dest": "cargo/vendor/image-0.25.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a\", \"files\": {}}", + "dest": "cargo/vendor/image-0.25.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/image-webp/image-webp-0.2.4.crate", + "sha256": "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3", + "dest": "cargo/vendor/image-webp-0.2.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3\", \"files\": {}}", + "dest": "cargo/vendor/image-webp-0.2.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/imagesize/imagesize-0.14.0.crate", + "sha256": "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c", + "dest": "cargo/vendor/imagesize-0.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c\", \"files\": {}}", + "dest": "cargo/vendor/imagesize-0.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/imagesize/imagesize-0.15.0.crate", + "sha256": "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a", + "dest": "cargo/vendor/imagesize-0.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a\", \"files\": {}}", + "dest": "cargo/vendor/imagesize-0.15.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/imap-proto/imap-proto-0.16.7.crate", + "sha256": "25f6af35c6a517aea5c72314abe90134980d2ae6a763809b50c208b3e429d71f", + "dest": "cargo/vendor/imap-proto-0.16.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"25f6af35c6a517aea5c72314abe90134980d2ae6a763809b50c208b3e429d71f\", \"files\": {}}", + "dest": "cargo/vendor/imap-proto-0.16.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/imgref/imgref-1.12.3.crate", + "sha256": "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f", + "dest": "cargo/vendor/imgref-1.12.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f\", \"files\": {}}", + "dest": "cargo/vendor/imgref-1.12.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/indexmap/indexmap-2.14.1.crate", + "sha256": "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb", + "dest": "cargo/vendor/indexmap-2.14.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb\", \"files\": {}}", + "dest": "cargo/vendor/indexmap-2.14.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/input/input-0.10.0.crate", + "sha256": "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d", + "dest": "cargo/vendor/input-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d\", \"files\": {}}", + "dest": "cargo/vendor/input-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/input-sys/input-sys-1.19.0.crate", + "sha256": "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7", + "dest": "cargo/vendor/input-sys-1.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7\", \"files\": {}}", + "dest": "cargo/vendor/input-sys-1.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/integer-sqrt/integer-sqrt-0.1.5.crate", + "sha256": "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770", + "dest": "cargo/vendor/integer-sqrt-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770\", \"files\": {}}", + "dest": "cargo/vendor/integer-sqrt-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/interpolate_name/interpolate_name-0.2.4.crate", + "sha256": "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60", + "dest": "cargo/vendor/interpolate_name-0.2.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60\", \"files\": {}}", + "dest": "cargo/vendor/interpolate_name-0.2.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/io-lifetimes/io-lifetimes-1.0.11.crate", + "sha256": "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2", + "dest": "cargo/vendor/io-lifetimes-1.0.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2\", \"files\": {}}", + "dest": "cargo/vendor/io-lifetimes-1.0.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ipnet/ipnet-2.12.1.crate", + "sha256": "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78", + "dest": "cargo/vendor/ipnet-2.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78\", \"files\": {}}", + "dest": "cargo/vendor/ipnet-2.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/itertools/itertools-0.12.1.crate", + "sha256": "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569", + "dest": "cargo/vendor/itertools-0.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569\", \"files\": {}}", + "dest": "cargo/vendor/itertools-0.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/itertools/itertools-0.13.0.crate", + "sha256": "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186", + "dest": "cargo/vendor/itertools-0.13.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\", \"files\": {}}", + "dest": "cargo/vendor/itertools-0.13.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/itertools/itertools-0.14.0.crate", + "sha256": "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285", + "dest": "cargo/vendor/itertools-0.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285\", \"files\": {}}", + "dest": "cargo/vendor/itertools-0.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/itoa/itoa-1.0.18.crate", + "sha256": "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682", + "dest": "cargo/vendor/itoa-1.0.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682\", \"files\": {}}", + "dest": "cargo/vendor/itoa-1.0.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jmap-client/jmap-client-0.4.2.crate", + "sha256": "4deab22e057d24e32122f0fc6e2d667a124fdd6a0d8ef3ed4f8a89923c11084f", + "dest": "cargo/vendor/jmap-client-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"4deab22e057d24e32122f0fc6e2d667a124fdd6a0d8ef3ed4f8a89923c11084f\", \"files\": {}}", + "dest": "cargo/vendor/jmap-client-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jni/jni-0.22.4.crate", + "sha256": "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498", + "dest": "cargo/vendor/jni-0.22.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498\", \"files\": {}}", + "dest": "cargo/vendor/jni-0.22.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jni-macros/jni-macros-0.22.4.crate", + "sha256": "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3", + "dest": "cargo/vendor/jni-macros-0.22.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3\", \"files\": {}}", + "dest": "cargo/vendor/jni-macros-0.22.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jni-sys/jni-sys-0.3.1.crate", + "sha256": "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258", + "dest": "cargo/vendor/jni-sys-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258\", \"files\": {}}", + "dest": "cargo/vendor/jni-sys-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jni-sys/jni-sys-0.4.1.crate", + "sha256": "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2", + "dest": "cargo/vendor/jni-sys-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2\", \"files\": {}}", + "dest": "cargo/vendor/jni-sys-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jni-sys-macros/jni-sys-macros-0.4.1.crate", + "sha256": "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264", + "dest": "cargo/vendor/jni-sys-macros-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264\", \"files\": {}}", + "dest": "cargo/vendor/jni-sys-macros-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jobserver/jobserver-0.1.35.crate", + "sha256": "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3", + "dest": "cargo/vendor/jobserver-0.1.35" + }, + { + "type": "inline", + "contents": "{\"package\": \"1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3\", \"files\": {}}", + "dest": "cargo/vendor/jobserver-0.1.35", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jpeg-decoder/jpeg-decoder-0.3.2.crate", + "sha256": "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07", + "dest": "cargo/vendor/jpeg-decoder-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07\", \"files\": {}}", + "dest": "cargo/vendor/jpeg-decoder-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/js-sys/js-sys-0.3.104.crate", + "sha256": "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a", + "dest": "cargo/vendor/js-sys-0.3.104" + }, + { + "type": "inline", + "contents": "{\"package\": \"0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a\", \"files\": {}}", + "dest": "cargo/vendor/js-sys-0.3.104", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/keyboard-types/keyboard-types-0.7.0.crate", + "sha256": "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a", + "dest": "cargo/vendor/keyboard-types-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a\", \"files\": {}}", + "dest": "cargo/vendor/keyboard-types-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/keyring/keyring-3.6.3.crate", + "sha256": "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c", + "dest": "cargo/vendor/keyring-3.6.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c\", \"files\": {}}", + "dest": "cargo/vendor/keyring-3.6.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/khronos-egl/khronos-egl-6.0.0.crate", + "sha256": "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76", + "dest": "cargo/vendor/khronos-egl-6.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76\", \"files\": {}}", + "dest": "cargo/vendor/khronos-egl-6.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/khronos_api/khronos_api-3.1.0.crate", + "sha256": "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc", + "dest": "cargo/vendor/khronos_api-3.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc\", \"files\": {}}", + "dest": "cargo/vendor/khronos_api-3.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/konst/konst-0.4.3.crate", + "sha256": "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d", + "dest": "cargo/vendor/konst-0.4.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d\", \"files\": {}}", + "dest": "cargo/vendor/konst-0.4.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/konst_proc_macros/konst_proc_macros-0.4.1.crate", + "sha256": "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a", + "dest": "cargo/vendor/konst_proc_macros-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a\", \"files\": {}}", + "dest": "cargo/vendor/konst_proc_macros-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ksni/ksni-0.3.6.crate", + "sha256": "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b", + "dest": "cargo/vendor/ksni-0.3.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b\", \"files\": {}}", + "dest": "cargo/vendor/ksni-0.3.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/kurbo/kurbo-0.13.1.crate", + "sha256": "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2", + "dest": "cargo/vendor/kurbo-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2\", \"files\": {}}", + "dest": "cargo/vendor/kurbo-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/landlock/landlock-0.4.7.crate", + "sha256": "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1", + "dest": "cargo/vendor/landlock-0.4.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1\", \"files\": {}}", + "dest": "cargo/vendor/landlock-0.4.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lazy_static/lazy_static-1.5.0.crate", + "sha256": "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe", + "dest": "cargo/vendor/lazy_static-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe\", \"files\": {}}", + "dest": "cargo/vendor/lazy_static-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lebe/lebe-0.5.3.crate", + "sha256": "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8", + "dest": "cargo/vendor/lebe-0.5.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8\", \"files\": {}}", + "dest": "cargo/vendor/lebe-0.5.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lettre/lettre-0.11.23.crate", + "sha256": "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae", + "dest": "cargo/vendor/lettre-0.11.23" + }, + { + "type": "inline", + "contents": "{\"package\": \"f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae\", \"files\": {}}", + "dest": "cargo/vendor/lettre-0.11.23", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libc/libc-0.2.189.crate", + "sha256": "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2", + "dest": "cargo/vendor/libc-0.2.189" + }, + { + "type": "inline", + "contents": "{\"package\": \"3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2\", \"files\": {}}", + "dest": "cargo/vendor/libc-0.2.189", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libdbus-sys/libdbus-sys-0.2.7.crate", + "sha256": "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043", + "dest": "cargo/vendor/libdbus-sys-0.2.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043\", \"files\": {}}", + "dest": "cargo/vendor/libdbus-sys-0.2.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libfuzzer-sys/libfuzzer-sys-0.4.13.crate", + "sha256": "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2", + "dest": "cargo/vendor/libfuzzer-sys-0.4.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2\", \"files\": {}}", + "dest": "cargo/vendor/libfuzzer-sys-0.4.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libloading/libloading-0.8.9.crate", + "sha256": "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55", + "dest": "cargo/vendor/libloading-0.8.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55\", \"files\": {}}", + "dest": "cargo/vendor/libloading-0.8.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libm/libm-0.2.16.crate", + "sha256": "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981", + "dest": "cargo/vendor/libm-0.2.16" + }, + { + "type": "inline", + "contents": "{\"package\": \"b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981\", \"files\": {}}", + "dest": "cargo/vendor/libm-0.2.16", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libredox/libredox-0.1.21.crate", + "sha256": "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96", + "dest": "cargo/vendor/libredox-0.1.21" + }, + { + "type": "inline", + "contents": "{\"package\": \"d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96\", \"files\": {}}", + "dest": "cargo/vendor/libredox-0.1.21", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libsqlite3-sys/libsqlite3-sys-0.38.2.crate", + "sha256": "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8", + "dest": "cargo/vendor/libsqlite3-sys-0.38.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8\", \"files\": {}}", + "dest": "cargo/vendor/libsqlite3-sys-0.38.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libudev-sys/libudev-sys-0.1.4.crate", + "sha256": "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324", + "dest": "cargo/vendor/libudev-sys-0.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324\", \"files\": {}}", + "dest": "cargo/vendor/libudev-sys-0.1.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linebender_resource_handle/linebender_resource_handle-0.1.1.crate", + "sha256": "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4", + "dest": "cargo/vendor/linebender_resource_handle-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4\", \"files\": {}}", + "dest": "cargo/vendor/linebender_resource_handle-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/link-section/link-section-0.19.3.crate", + "sha256": "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9", + "dest": "cargo/vendor/link-section-0.19.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9\", \"files\": {}}", + "dest": "cargo/vendor/link-section-0.19.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linked-hash-map/linked-hash-map-0.5.6.crate", + "sha256": "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f", + "dest": "cargo/vendor/linked-hash-map-0.5.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f\", \"files\": {}}", + "dest": "cargo/vendor/linked-hash-map-0.5.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linked_hash_set/linked_hash_set-0.1.6.crate", + "sha256": "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8", + "dest": "cargo/vendor/linked_hash_set-0.1.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8\", \"files\": {}}", + "dest": "cargo/vendor/linked_hash_set-0.1.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linktime-proc-macro/linktime-proc-macro-0.2.3.crate", + "sha256": "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616", + "dest": "cargo/vendor/linktime-proc-macro-0.2.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616\", \"files\": {}}", + "dest": "cargo/vendor/linktime-proc-macro-0.2.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linux-raw-sys/linux-raw-sys-0.4.15.crate", + "sha256": "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab", + "dest": "cargo/vendor/linux-raw-sys-0.4.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab\", \"files\": {}}", + "dest": "cargo/vendor/linux-raw-sys-0.4.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linux-raw-sys/linux-raw-sys-0.9.4.crate", + "sha256": "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12", + "dest": "cargo/vendor/linux-raw-sys-0.9.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12\", \"files\": {}}", + "dest": "cargo/vendor/linux-raw-sys-0.9.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linux-raw-sys/linux-raw-sys-0.12.1.crate", + "sha256": "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53", + "dest": "cargo/vendor/linux-raw-sys-0.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53\", \"files\": {}}", + "dest": "cargo/vendor/linux-raw-sys-0.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/litemap/litemap-0.8.3.crate", + "sha256": "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae", + "dest": "cargo/vendor/litemap-0.8.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae\", \"files\": {}}", + "dest": "cargo/vendor/litemap-0.8.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/litrs/litrs-1.0.0.crate", + "sha256": "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092", + "dest": "cargo/vendor/litrs-1.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092\", \"files\": {}}", + "dest": "cargo/vendor/litrs-1.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lock_api/lock_api-0.4.14.crate", + "sha256": "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965", + "dest": "cargo/vendor/lock_api-0.4.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965\", \"files\": {}}", + "dest": "cargo/vendor/lock_api-0.4.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/log/log-0.4.34.crate", + "sha256": "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6", + "dest": "cargo/vendor/log-0.4.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6\", \"files\": {}}", + "dest": "cargo/vendor/log-0.4.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/loop9/loop9-0.1.5.crate", + "sha256": "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062", + "dest": "cargo/vendor/loop9-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062\", \"files\": {}}", + "dest": "cargo/vendor/loop9-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lru-slab/lru-slab-0.1.2.crate", + "sha256": "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154", + "dest": "cargo/vendor/lru-slab-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154\", \"files\": {}}", + "dest": "cargo/vendor/lru-slab-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lyon_algorithms/lyon_algorithms-1.0.20.crate", + "sha256": "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31", + "dest": "cargo/vendor/lyon_algorithms-1.0.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31\", \"files\": {}}", + "dest": "cargo/vendor/lyon_algorithms-1.0.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lyon_extra/lyon_extra-1.1.0.crate", + "sha256": "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72", + "dest": "cargo/vendor/lyon_extra-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72\", \"files\": {}}", + "dest": "cargo/vendor/lyon_extra-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lyon_geom/lyon_geom-1.0.19.crate", + "sha256": "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92", + "dest": "cargo/vendor/lyon_geom-1.0.19" + }, + { + "type": "inline", + "contents": "{\"package\": \"4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92\", \"files\": {}}", + "dest": "cargo/vendor/lyon_geom-1.0.19", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lyon_path/lyon_path-1.0.19.crate", + "sha256": "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e", + "dest": "cargo/vendor/lyon_path-1.0.19" + }, + { + "type": "inline", + "contents": "{\"package\": \"5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e\", \"files\": {}}", + "dest": "cargo/vendor/lyon_path-1.0.19", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lz4_flex/lz4_flex-0.13.1.crate", + "sha256": "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e", + "dest": "cargo/vendor/lz4_flex-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e\", \"files\": {}}", + "dest": "cargo/vendor/lz4_flex-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/macro_rules_attribute/macro_rules_attribute-0.2.3.crate", + "sha256": "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c", + "dest": "cargo/vendor/macro_rules_attribute-0.2.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c\", \"files\": {}}", + "dest": "cargo/vendor/macro_rules_attribute-0.2.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/macro_rules_attribute-proc_macro/macro_rules_attribute-proc_macro-0.2.3.crate", + "sha256": "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c", + "dest": "cargo/vendor/macro_rules_attribute-proc_macro-0.2.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c\", \"files\": {}}", + "dest": "cargo/vendor/macro_rules_attribute-proc_macro-0.2.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mail-builder/mail-builder-0.4.4.crate", + "sha256": "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b", + "dest": "cargo/vendor/mail-builder-0.4.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b\", \"files\": {}}", + "dest": "cargo/vendor/mail-builder-0.4.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mail-parser/mail-parser-0.11.8.crate", + "sha256": "0b3a9080c1fb8190e232df37a10aa1b3d6b08be084e537069913f025e0ce86c5", + "dest": "cargo/vendor/mail-parser-0.11.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"0b3a9080c1fb8190e232df37a10aa1b3d6b08be084e537069913f025e0ce86c5\", \"files\": {}}", + "dest": "cargo/vendor/mail-parser-0.11.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/malloc_size_of_derive/malloc_size_of_derive-0.1.3.crate", + "sha256": "f44db74bde26fdf427af23f1d146c211aed857c59e3be750cf2617f6b0b05c94", + "dest": "cargo/vendor/malloc_size_of_derive-0.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"f44db74bde26fdf427af23f1d146c211aed857c59e3be750cf2617f6b0b05c94\", \"files\": {}}", + "dest": "cargo/vendor/malloc_size_of_derive-0.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/maplit/maplit-1.0.2.crate", + "sha256": "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d", + "dest": "cargo/vendor/maplit-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d\", \"files\": {}}", + "dest": "cargo/vendor/maplit-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/markup5ever/markup5ever-0.39.0.crate", + "sha256": "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de", + "dest": "cargo/vendor/markup5ever-0.39.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de\", \"files\": {}}", + "dest": "cargo/vendor/markup5ever-0.39.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/matchers/matchers-0.2.0.crate", + "sha256": "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9", + "dest": "cargo/vendor/matchers-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9\", \"files\": {}}", + "dest": "cargo/vendor/matchers-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/maybe-async/maybe-async-0.2.11.crate", + "sha256": "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c", + "dest": "cargo/vendor/maybe-async-0.2.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c\", \"files\": {}}", + "dest": "cargo/vendor/maybe-async-0.2.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/maybe-owned/maybe-owned-0.3.4.crate", + "sha256": "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4", + "dest": "cargo/vendor/maybe-owned-0.3.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4\", \"files\": {}}", + "dest": "cargo/vendor/maybe-owned-0.3.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/maybe-rayon/maybe-rayon-0.1.1.crate", + "sha256": "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519", + "dest": "cargo/vendor/maybe-rayon-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519\", \"files\": {}}", + "dest": "cargo/vendor/maybe-rayon-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/memchr/memchr-2.8.3.crate", + "sha256": "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98", + "dest": "cargo/vendor/memchr-2.8.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98\", \"files\": {}}", + "dest": "cargo/vendor/memchr-2.8.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/memmap2/memmap2-0.9.11.crate", + "sha256": "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0", + "dest": "cargo/vendor/memmap2-0.9.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0\", \"files\": {}}", + "dest": "cargo/vendor/memmap2-0.9.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/memoffset/memoffset-0.9.1.crate", + "sha256": "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a", + "dest": "cargo/vendor/memoffset-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a\", \"files\": {}}", + "dest": "cargo/vendor/memoffset-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mime/mime-0.3.17.crate", + "sha256": "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a", + "dest": "cargo/vendor/mime-0.3.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a\", \"files\": {}}", + "dest": "cargo/vendor/mime-0.3.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mime_guess/mime_guess-2.0.5.crate", + "sha256": "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e", + "dest": "cargo/vendor/mime_guess-2.0.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e\", \"files\": {}}", + "dest": "cargo/vendor/mime_guess-2.0.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/minimal-lexical/minimal-lexical-0.2.1.crate", + "sha256": "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a", + "dest": "cargo/vendor/minimal-lexical-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a\", \"files\": {}}", + "dest": "cargo/vendor/minimal-lexical-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/miniz_oxide/miniz_oxide-0.8.9.crate", + "sha256": "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316", + "dest": "cargo/vendor/miniz_oxide-0.8.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316\", \"files\": {}}", + "dest": "cargo/vendor/miniz_oxide-0.8.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/miniz_oxide/miniz_oxide-0.9.1.crate", + "sha256": "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c", + "dest": "cargo/vendor/miniz_oxide-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c\", \"files\": {}}", + "dest": "cargo/vendor/miniz_oxide-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mio/mio-1.2.2.crate", + "sha256": "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427", + "dest": "cargo/vendor/mio-1.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427\", \"files\": {}}", + "dest": "cargo/vendor/mio-1.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/monostate/monostate-0.1.18.crate", + "sha256": "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67", + "dest": "cargo/vendor/monostate-0.1.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67\", \"files\": {}}", + "dest": "cargo/vendor/monostate-0.1.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/monostate-impl/monostate-impl-0.1.18.crate", + "sha256": "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9", + "dest": "cargo/vendor/monostate-impl-0.1.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9\", \"files\": {}}", + "dest": "cargo/vendor/monostate-impl-0.1.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/more-asserts/more-asserts-0.3.1.crate", + "sha256": "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e", + "dest": "cargo/vendor/more-asserts-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e\", \"files\": {}}", + "dest": "cargo/vendor/more-asserts-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/muda/muda-0.19.3.crate", + "sha256": "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878", + "dest": "cargo/vendor/muda-0.19.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878\", \"files\": {}}", + "dest": "cargo/vendor/muda-0.19.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/naga/naga-29.0.4.crate", + "sha256": "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df", + "dest": "cargo/vendor/naga-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df\", \"files\": {}}", + "dest": "cargo/vendor/naga-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/natord/natord-1.0.9.crate", + "sha256": "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c", + "dest": "cargo/vendor/natord-1.0.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c\", \"files\": {}}", + "dest": "cargo/vendor/natord-1.0.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ndk/ndk-0.9.0.crate", + "sha256": "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4", + "dest": "cargo/vendor/ndk-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4\", \"files\": {}}", + "dest": "cargo/vendor/ndk-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ndk-context/ndk-context-0.1.1.crate", + "sha256": "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b", + "dest": "cargo/vendor/ndk-context-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b\", \"files\": {}}", + "dest": "cargo/vendor/ndk-context-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ndk-sys/ndk-sys-0.6.0+11769913.crate", + "sha256": "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873", + "dest": "cargo/vendor/ndk-sys-0.6.0+11769913" + }, + { + "type": "inline", + "contents": "{\"package\": \"ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873\", \"files\": {}}", + "dest": "cargo/vendor/ndk-sys-0.6.0+11769913", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/new_debug_unreachable/new_debug_unreachable-1.0.6.crate", + "sha256": "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086", + "dest": "cargo/vendor/new_debug_unreachable-1.0.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086\", \"files\": {}}", + "dest": "cargo/vendor/new_debug_unreachable-1.0.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/nix/nix-0.31.3.crate", + "sha256": "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d", + "dest": "cargo/vendor/nix-0.31.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d\", \"files\": {}}", + "dest": "cargo/vendor/nix-0.31.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/nom/nom-7.1.3.crate", + "sha256": "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a", + "dest": "cargo/vendor/nom-7.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a\", \"files\": {}}", + "dest": "cargo/vendor/nom-7.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/nom/nom-8.0.0.crate", + "sha256": "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405", + "dest": "cargo/vendor/nom-8.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405\", \"files\": {}}", + "dest": "cargo/vendor/nom-8.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/noop_proc_macro/noop_proc_macro-0.3.0.crate", + "sha256": "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8", + "dest": "cargo/vendor/noop_proc_macro-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8\", \"files\": {}}", + "dest": "cargo/vendor/noop_proc_macro-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ntapi/ntapi-0.4.3.crate", + "sha256": "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae", + "dest": "cargo/vendor/ntapi-0.4.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae\", \"files\": {}}", + "dest": "cargo/vendor/ntapi-0.4.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/nu-ansi-term/nu-ansi-term-0.50.3.crate", + "sha256": "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5", + "dest": "cargo/vendor/nu-ansi-term-0.50.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5\", \"files\": {}}", + "dest": "cargo/vendor/nu-ansi-term-0.50.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-bigint/num-bigint-0.4.8.crate", + "sha256": "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367", + "dest": "cargo/vendor/num-bigint-0.4.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367\", \"files\": {}}", + "dest": "cargo/vendor/num-bigint-0.4.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-complex/num-complex-0.4.6.crate", + "sha256": "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495", + "dest": "cargo/vendor/num-complex-0.4.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495\", \"files\": {}}", + "dest": "cargo/vendor/num-complex-0.4.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-conv/num-conv-0.2.2.crate", + "sha256": "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441", + "dest": "cargo/vendor/num-conv-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441\", \"files\": {}}", + "dest": "cargo/vendor/num-conv-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-derive/num-derive-0.4.2.crate", + "sha256": "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202", + "dest": "cargo/vendor/num-derive-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202\", \"files\": {}}", + "dest": "cargo/vendor/num-derive-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-integer/num-integer-0.1.47.crate", + "sha256": "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b", + "dest": "cargo/vendor/num-integer-0.1.47" + }, + { + "type": "inline", + "contents": "{\"package\": \"7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b\", \"files\": {}}", + "dest": "cargo/vendor/num-integer-0.1.47", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-rational/num-rational-0.4.2.crate", + "sha256": "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824", + "dest": "cargo/vendor/num-rational-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824\", \"files\": {}}", + "dest": "cargo/vendor/num-rational-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-traits/num-traits-0.2.19.crate", + "sha256": "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841", + "dest": "cargo/vendor/num-traits-0.2.19" + }, + { + "type": "inline", + "contents": "{\"package\": \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\", \"files\": {}}", + "dest": "cargo/vendor/num-traits-0.2.19", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num_cpus/num_cpus-1.17.0.crate", + "sha256": "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b", + "dest": "cargo/vendor/num_cpus-1.17.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b\", \"files\": {}}", + "dest": "cargo/vendor/num_cpus-1.17.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num_enum/num_enum-0.7.6.crate", + "sha256": "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26", + "dest": "cargo/vendor/num_enum-0.7.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26\", \"files\": {}}", + "dest": "cargo/vendor/num_enum-0.7.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num_enum_derive/num_enum_derive-0.7.6.crate", + "sha256": "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8", + "dest": "cargo/vendor/num_enum_derive-0.7.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8\", \"files\": {}}", + "dest": "cargo/vendor/num_enum_derive-0.7.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc-sys/objc-sys-0.3.5.crate", + "sha256": "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310", + "dest": "cargo/vendor/objc-sys-0.3.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310\", \"files\": {}}", + "dest": "cargo/vendor/objc-sys-0.3.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2/objc2-0.5.2.crate", + "sha256": "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804", + "dest": "cargo/vendor/objc2-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804\", \"files\": {}}", + "dest": "cargo/vendor/objc2-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2/objc2-0.6.4.crate", + "sha256": "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f", + "dest": "cargo/vendor/objc2-0.6.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f\", \"files\": {}}", + "dest": "cargo/vendor/objc2-0.6.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-app-kit/objc2-app-kit-0.2.2.crate", + "sha256": "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff", + "dest": "cargo/vendor/objc2-app-kit-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff\", \"files\": {}}", + "dest": "cargo/vendor/objc2-app-kit-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-app-kit/objc2-app-kit-0.3.2.crate", + "sha256": "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c", + "dest": "cargo/vendor/objc2-app-kit-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c\", \"files\": {}}", + "dest": "cargo/vendor/objc2-app-kit-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-cloud-kit/objc2-cloud-kit-0.2.2.crate", + "sha256": "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009", + "dest": "cargo/vendor/objc2-cloud-kit-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009\", \"files\": {}}", + "dest": "cargo/vendor/objc2-cloud-kit-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-cloud-kit/objc2-cloud-kit-0.3.2.crate", + "sha256": "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c", + "dest": "cargo/vendor/objc2-cloud-kit-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c\", \"files\": {}}", + "dest": "cargo/vendor/objc2-cloud-kit-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-contacts/objc2-contacts-0.2.2.crate", + "sha256": "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889", + "dest": "cargo/vendor/objc2-contacts-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889\", \"files\": {}}", + "dest": "cargo/vendor/objc2-contacts-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-data/objc2-core-data-0.2.2.crate", + "sha256": "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef", + "dest": "cargo/vendor/objc2-core-data-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-data-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-data/objc2-core-data-0.3.2.crate", + "sha256": "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa", + "dest": "cargo/vendor/objc2-core-data-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-data-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-foundation/objc2-core-foundation-0.3.2.crate", + "sha256": "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536", + "dest": "cargo/vendor/objc2-core-foundation-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-foundation-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-graphics/objc2-core-graphics-0.3.2.crate", + "sha256": "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807", + "dest": "cargo/vendor/objc2-core-graphics-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-graphics-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-image/objc2-core-image-0.2.2.crate", + "sha256": "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80", + "dest": "cargo/vendor/objc2-core-image-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-image-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-image/objc2-core-image-0.3.2.crate", + "sha256": "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006", + "dest": "cargo/vendor/objc2-core-image-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-image-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-location/objc2-core-location-0.2.2.crate", + "sha256": "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781", + "dest": "cargo/vendor/objc2-core-location-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-location-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-text/objc2-core-text-0.3.2.crate", + "sha256": "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d", + "dest": "cargo/vendor/objc2-core-text-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-text-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-core-video/objc2-core-video-0.3.2.crate", + "sha256": "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6", + "dest": "cargo/vendor/objc2-core-video-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6\", \"files\": {}}", + "dest": "cargo/vendor/objc2-core-video-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-encode/objc2-encode-4.1.0.crate", + "sha256": "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33", + "dest": "cargo/vendor/objc2-encode-4.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33\", \"files\": {}}", + "dest": "cargo/vendor/objc2-encode-4.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-foundation/objc2-foundation-0.2.2.crate", + "sha256": "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8", + "dest": "cargo/vendor/objc2-foundation-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8\", \"files\": {}}", + "dest": "cargo/vendor/objc2-foundation-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-foundation/objc2-foundation-0.3.2.crate", + "sha256": "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272", + "dest": "cargo/vendor/objc2-foundation-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272\", \"files\": {}}", + "dest": "cargo/vendor/objc2-foundation-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-io-kit/objc2-io-kit-0.3.2.crate", + "sha256": "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15", + "dest": "cargo/vendor/objc2-io-kit-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15\", \"files\": {}}", + "dest": "cargo/vendor/objc2-io-kit-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-io-surface/objc2-io-surface-0.3.2.crate", + "sha256": "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d", + "dest": "cargo/vendor/objc2-io-surface-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d\", \"files\": {}}", + "dest": "cargo/vendor/objc2-io-surface-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-link-presentation/objc2-link-presentation-0.2.2.crate", + "sha256": "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398", + "dest": "cargo/vendor/objc2-link-presentation-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398\", \"files\": {}}", + "dest": "cargo/vendor/objc2-link-presentation-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-metal/objc2-metal-0.2.2.crate", + "sha256": "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6", + "dest": "cargo/vendor/objc2-metal-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6\", \"files\": {}}", + "dest": "cargo/vendor/objc2-metal-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-metal/objc2-metal-0.3.2.crate", + "sha256": "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794", + "dest": "cargo/vendor/objc2-metal-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794\", \"files\": {}}", + "dest": "cargo/vendor/objc2-metal-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-quartz-core/objc2-quartz-core-0.2.2.crate", + "sha256": "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a", + "dest": "cargo/vendor/objc2-quartz-core-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a\", \"files\": {}}", + "dest": "cargo/vendor/objc2-quartz-core-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-quartz-core/objc2-quartz-core-0.3.2.crate", + "sha256": "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f", + "dest": "cargo/vendor/objc2-quartz-core-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f\", \"files\": {}}", + "dest": "cargo/vendor/objc2-quartz-core-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-symbols/objc2-symbols-0.2.2.crate", + "sha256": "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc", + "dest": "cargo/vendor/objc2-symbols-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc\", \"files\": {}}", + "dest": "cargo/vendor/objc2-symbols-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-system-configuration/objc2-system-configuration-0.3.2.crate", + "sha256": "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396", + "dest": "cargo/vendor/objc2-system-configuration-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396\", \"files\": {}}", + "dest": "cargo/vendor/objc2-system-configuration-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-ui-kit/objc2-ui-kit-0.2.2.crate", + "sha256": "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f", + "dest": "cargo/vendor/objc2-ui-kit-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f\", \"files\": {}}", + "dest": "cargo/vendor/objc2-ui-kit-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-ui-kit/objc2-ui-kit-0.3.2.crate", + "sha256": "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22", + "dest": "cargo/vendor/objc2-ui-kit-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22\", \"files\": {}}", + "dest": "cargo/vendor/objc2-ui-kit-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-uniform-type-identifiers/objc2-uniform-type-identifiers-0.2.2.crate", + "sha256": "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe", + "dest": "cargo/vendor/objc2-uniform-type-identifiers-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe\", \"files\": {}}", + "dest": "cargo/vendor/objc2-uniform-type-identifiers-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc2-user-notifications/objc2-user-notifications-0.2.2.crate", + "sha256": "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3", + "dest": "cargo/vendor/objc2-user-notifications-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3\", \"files\": {}}", + "dest": "cargo/vendor/objc2-user-notifications-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/once_cell/once_cell-1.21.4.crate", + "sha256": "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50", + "dest": "cargo/vendor/once_cell-1.21.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50\", \"files\": {}}", + "dest": "cargo/vendor/once_cell-1.21.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/oneshot/oneshot-0.1.13.crate", + "sha256": "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107", + "dest": "cargo/vendor/oneshot-0.1.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107\", \"files\": {}}", + "dest": "cargo/vendor/oneshot-0.1.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/onig/onig-6.5.3.crate", + "sha256": "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2", + "dest": "cargo/vendor/onig-6.5.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2\", \"files\": {}}", + "dest": "cargo/vendor/onig-6.5.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/onig_sys/onig_sys-69.9.3.crate", + "sha256": "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7", + "dest": "cargo/vendor/onig_sys-69.9.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7\", \"files\": {}}", + "dest": "cargo/vendor/onig_sys-69.9.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/openssl-probe/openssl-probe-0.2.1.crate", + "sha256": "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe", + "dest": "cargo/vendor/openssl-probe-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe\", \"files\": {}}", + "dest": "cargo/vendor/openssl-probe-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/option-ext/option-ext-0.2.0.crate", + "sha256": "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d", + "dest": "cargo/vendor/option-ext-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d\", \"files\": {}}", + "dest": "cargo/vendor/option-ext-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/orbclient/orbclient-0.3.55.crate", + "sha256": "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747", + "dest": "cargo/vendor/orbclient-0.3.55" + }, + { + "type": "inline", + "contents": "{\"package\": \"5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747\", \"files\": {}}", + "dest": "cargo/vendor/orbclient-0.3.55", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ordered-float/ordered-float-5.5.0.crate", + "sha256": "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0", + "dest": "cargo/vendor/ordered-float-5.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0\", \"files\": {}}", + "dest": "cargo/vendor/ordered-float-5.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ordered-stream/ordered-stream-0.2.0.crate", + "sha256": "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50", + "dest": "cargo/vendor/ordered-stream-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50\", \"files\": {}}", + "dest": "cargo/vendor/ordered-stream-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/os_pipe/os_pipe-1.2.3.crate", + "sha256": "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967", + "dest": "cargo/vendor/os_pipe-1.2.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967\", \"files\": {}}", + "dest": "cargo/vendor/os_pipe-1.2.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/os_str_bytes/os_str_bytes-6.6.1.crate", + "sha256": "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1", + "dest": "cargo/vendor/os_str_bytes-6.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1\", \"files\": {}}", + "dest": "cargo/vendor/os_str_bytes-6.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/owned_ttf_parser/owned_ttf_parser-0.25.1.crate", + "sha256": "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b", + "dest": "cargo/vendor/owned_ttf_parser-0.25.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b\", \"files\": {}}", + "dest": "cargo/vendor/owned_ttf_parser-0.25.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parking/parking-2.2.1.crate", + "sha256": "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba", + "dest": "cargo/vendor/parking-2.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba\", \"files\": {}}", + "dest": "cargo/vendor/parking-2.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parking_lot/parking_lot-0.12.5.crate", + "sha256": "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a", + "dest": "cargo/vendor/parking_lot-0.12.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a\", \"files\": {}}", + "dest": "cargo/vendor/parking_lot-0.12.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parking_lot_core/parking_lot_core-0.9.12.crate", + "sha256": "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1", + "dest": "cargo/vendor/parking_lot_core-0.9.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1\", \"files\": {}}", + "dest": "cargo/vendor/parking_lot_core-0.9.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parlance/parlance-0.1.0.crate", + "sha256": "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5", + "dest": "cargo/vendor/parlance-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5\", \"files\": {}}", + "dest": "cargo/vendor/parlance-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parley/parley-0.10.0.crate", + "sha256": "6b1cfcf399c774719fb1fa51bc6b91e86bdf003b03202a0902f1827ba6750746", + "dest": "cargo/vendor/parley-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6b1cfcf399c774719fb1fa51bc6b91e86bdf003b03202a0902f1827ba6750746\", \"files\": {}}", + "dest": "cargo/vendor/parley-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parley/parley-0.11.1.crate", + "sha256": "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5", + "dest": "cargo/vendor/parley-0.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5\", \"files\": {}}", + "dest": "cargo/vendor/parley-0.11.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parley_data/parley_data-0.10.0.crate", + "sha256": "d1d3755e2cc12c0625b0cd2f2773c6a37dd11d1531940c945ade4aeb78f2b145", + "dest": "cargo/vendor/parley_data-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d1d3755e2cc12c0625b0cd2f2773c6a37dd11d1531940c945ade4aeb78f2b145\", \"files\": {}}", + "dest": "cargo/vendor/parley_data-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parley_data/parley_data-0.11.1.crate", + "sha256": "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889", + "dest": "cargo/vendor/parley_data-0.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889\", \"files\": {}}", + "dest": "cargo/vendor/parley_data-0.11.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/paste/paste-1.0.15.crate", + "sha256": "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a", + "dest": "cargo/vendor/paste-1.0.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a\", \"files\": {}}", + "dest": "cargo/vendor/paste-1.0.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pastey/pastey-0.2.3.crate", + "sha256": "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4", + "dest": "cargo/vendor/pastey-0.2.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4\", \"files\": {}}", + "dest": "cargo/vendor/pastey-0.2.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pathdiff/pathdiff-0.2.3.crate", + "sha256": "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3", + "dest": "cargo/vendor/pathdiff-0.2.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3\", \"files\": {}}", + "dest": "cargo/vendor/pathdiff-0.2.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pdfium-render/pdfium-render-0.9.4.crate", + "sha256": "8948a803616a9e936b15a6637af2cd48c5fb8ae0fcdeb3c32eac3a540e255a19", + "dest": "cargo/vendor/pdfium-render-0.9.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"8948a803616a9e936b15a6637af2cd48c5fb8ae0fcdeb3c32eac3a540e255a19\", \"files\": {}}", + "dest": "cargo/vendor/pdfium-render-0.9.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/peniko/peniko-0.6.1.crate", + "sha256": "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6", + "dest": "cargo/vendor/peniko-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6\", \"files\": {}}", + "dest": "cargo/vendor/peniko-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/percent-encoding/percent-encoding-2.3.2.crate", + "sha256": "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220", + "dest": "cargo/vendor/percent-encoding-2.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220\", \"files\": {}}", + "dest": "cargo/vendor/percent-encoding-2.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/petgraph/petgraph-0.8.3.crate", + "sha256": "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455", + "dest": "cargo/vendor/petgraph-0.8.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455\", \"files\": {}}", + "dest": "cargo/vendor/petgraph-0.8.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/phf/phf-0.13.1.crate", + "sha256": "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf", + "dest": "cargo/vendor/phf-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf\", \"files\": {}}", + "dest": "cargo/vendor/phf-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/phf_codegen/phf_codegen-0.13.1.crate", + "sha256": "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1", + "dest": "cargo/vendor/phf_codegen-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1\", \"files\": {}}", + "dest": "cargo/vendor/phf_codegen-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/phf_generator/phf_generator-0.13.1.crate", + "sha256": "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737", + "dest": "cargo/vendor/phf_generator-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737\", \"files\": {}}", + "dest": "cargo/vendor/phf_generator-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/phf_macros/phf_macros-0.13.1.crate", + "sha256": "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef", + "dest": "cargo/vendor/phf_macros-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef\", \"files\": {}}", + "dest": "cargo/vendor/phf_macros-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/phf_shared/phf_shared-0.13.1.crate", + "sha256": "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266", + "dest": "cargo/vendor/phf_shared-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266\", \"files\": {}}", + "dest": "cargo/vendor/phf_shared-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pico-args/pico-args-0.5.0.crate", + "sha256": "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315", + "dest": "cargo/vendor/pico-args-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315\", \"files\": {}}", + "dest": "cargo/vendor/pico-args-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-project/pin-project-1.1.13.crate", + "sha256": "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924", + "dest": "cargo/vendor/pin-project-1.1.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924\", \"files\": {}}", + "dest": "cargo/vendor/pin-project-1.1.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-project-internal/pin-project-internal-1.1.13.crate", + "sha256": "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b", + "dest": "cargo/vendor/pin-project-internal-1.1.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b\", \"files\": {}}", + "dest": "cargo/vendor/pin-project-internal-1.1.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-project-lite/pin-project-lite-0.2.17.crate", + "sha256": "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd", + "dest": "cargo/vendor/pin-project-lite-0.2.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd\", \"files\": {}}", + "dest": "cargo/vendor/pin-project-lite-0.2.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-utils/pin-utils-0.1.0.crate", + "sha256": "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184", + "dest": "cargo/vendor/pin-utils-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184\", \"files\": {}}", + "dest": "cargo/vendor/pin-utils-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-weak/pin-weak-1.1.0.crate", + "sha256": "b330c9d1b92dfe68442ca20b009c717d5f0b1e3cf4965e62f704c3c6e95a1305", + "dest": "cargo/vendor/pin-weak-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b330c9d1b92dfe68442ca20b009c717d5f0b1e3cf4965e62f704c3c6e95a1305\", \"files\": {}}", + "dest": "cargo/vendor/pin-weak-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/piper/piper-0.2.5.crate", + "sha256": "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1", + "dest": "cargo/vendor/piper-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1\", \"files\": {}}", + "dest": "cargo/vendor/piper-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/piston-float/piston-float-1.0.1.crate", + "sha256": "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590", + "dest": "cargo/vendor/piston-float-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590\", \"files\": {}}", + "dest": "cargo/vendor/piston-float-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pkg-config/pkg-config-0.3.34.crate", + "sha256": "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548", + "dest": "cargo/vendor/pkg-config-0.3.34" + }, + { + "type": "inline", + "contents": "{\"package\": \"f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548\", \"files\": {}}", + "dest": "cargo/vendor/pkg-config-0.3.34", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/plain/plain-0.2.3.crate", + "sha256": "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6", + "dest": "cargo/vendor/plain-0.2.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6\", \"files\": {}}", + "dest": "cargo/vendor/plain-0.2.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/png/png-0.17.16.crate", + "sha256": "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526", + "dest": "cargo/vendor/png-0.17.16" + }, + { + "type": "inline", + "contents": "{\"package\": \"82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526\", \"files\": {}}", + "dest": "cargo/vendor/png-0.17.16", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/png/png-0.18.1.crate", + "sha256": "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61", + "dest": "cargo/vendor/png-0.18.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61\", \"files\": {}}", + "dest": "cargo/vendor/png-0.18.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/polling/polling-3.11.0.crate", + "sha256": "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218", + "dest": "cargo/vendor/polling-3.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218\", \"files\": {}}", + "dest": "cargo/vendor/polling-3.11.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pollster/pollster-0.4.0.crate", + "sha256": "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3", + "dest": "cargo/vendor/pollster-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3\", \"files\": {}}", + "dest": "cargo/vendor/pollster-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/polycool/polycool-0.4.0.crate", + "sha256": "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6", + "dest": "cargo/vendor/polycool-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6\", \"files\": {}}", + "dest": "cargo/vendor/polycool-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/portable-atomic/portable-atomic-1.15.0.crate", + "sha256": "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85", + "dest": "cargo/vendor/portable-atomic-1.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85\", \"files\": {}}", + "dest": "cargo/vendor/portable-atomic-1.15.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/portable-atomic-util/portable-atomic-util-0.2.7.crate", + "sha256": "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618", + "dest": "cargo/vendor/portable-atomic-util-0.2.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618\", \"files\": {}}", + "dest": "cargo/vendor/portable-atomic-util-0.2.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/potential_utf/potential_utf-0.1.6.crate", + "sha256": "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661", + "dest": "cargo/vendor/potential_utf-0.1.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661\", \"files\": {}}", + "dest": "cargo/vendor/potential_utf-0.1.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/powerfmt/powerfmt-0.2.0.crate", + "sha256": "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391", + "dest": "cargo/vendor/powerfmt-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391\", \"files\": {}}", + "dest": "cargo/vendor/powerfmt-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ppv-lite86/ppv-lite86-0.2.21.crate", + "sha256": "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9", + "dest": "cargo/vendor/ppv-lite86-0.2.21" + }, + { + "type": "inline", + "contents": "{\"package\": \"85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9\", \"files\": {}}", + "dest": "cargo/vendor/ppv-lite86-0.2.21", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/precomputed-hash/precomputed-hash-0.1.1.crate", + "sha256": "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c", + "dest": "cargo/vendor/precomputed-hash-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c\", \"files\": {}}", + "dest": "cargo/vendor/precomputed-hash-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/presser/presser-0.3.1.crate", + "sha256": "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa", + "dest": "cargo/vendor/presser-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa\", \"files\": {}}", + "dest": "cargo/vendor/presser-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/prettyplease/prettyplease-0.2.37.crate", + "sha256": "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b", + "dest": "cargo/vendor/prettyplease-0.2.37" + }, + { + "type": "inline", + "contents": "{\"package\": \"479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b\", \"files\": {}}", + "dest": "cargo/vendor/prettyplease-0.2.37", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/prettyplease/prettyplease-0.3.0.crate", + "sha256": "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0", + "dest": "cargo/vendor/prettyplease-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0\", \"files\": {}}", + "dest": "cargo/vendor/prettyplease-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/proc-macro-crate/proc-macro-crate-3.5.0.crate", + "sha256": "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f", + "dest": "cargo/vendor/proc-macro-crate-3.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f\", \"files\": {}}", + "dest": "cargo/vendor/proc-macro-crate-3.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/proc-macro2/proc-macro2-1.0.107.crate", + "sha256": "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9", + "dest": "cargo/vendor/proc-macro2-1.0.107" + }, + { + "type": "inline", + "contents": "{\"package\": \"985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9\", \"files\": {}}", + "dest": "cargo/vendor/proc-macro2-1.0.107", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/profiling/profiling-1.0.18.crate", + "sha256": "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5", + "dest": "cargo/vendor/profiling-1.0.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5\", \"files\": {}}", + "dest": "cargo/vendor/profiling-1.0.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/profiling-procmacros/profiling-procmacros-1.0.18.crate", + "sha256": "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb", + "dest": "cargo/vendor/profiling-procmacros-1.0.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb\", \"files\": {}}", + "dest": "cargo/vendor/profiling-procmacros-1.0.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pulldown-cmark/pulldown-cmark-0.13.4.crate", + "sha256": "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e", + "dest": "cargo/vendor/pulldown-cmark-0.13.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e\", \"files\": {}}", + "dest": "cargo/vendor/pulldown-cmark-0.13.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pulldown-cmark-escape/pulldown-cmark-escape-0.11.0.crate", + "sha256": "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae", + "dest": "cargo/vendor/pulldown-cmark-escape-0.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae\", \"files\": {}}", + "dest": "cargo/vendor/pulldown-cmark-escape-0.11.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pulp/pulp-0.22.3.crate", + "sha256": "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a", + "dest": "cargo/vendor/pulp-0.22.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a\", \"files\": {}}", + "dest": "cargo/vendor/pulp-0.22.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pulp-wasm-simd-flag/pulp-wasm-simd-flag-0.1.1.crate", + "sha256": "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740", + "dest": "cargo/vendor/pulp-wasm-simd-flag-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740\", \"files\": {}}", + "dest": "cargo/vendor/pulp-wasm-simd-flag-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/qoi/qoi-0.4.1.crate", + "sha256": "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001", + "dest": "cargo/vendor/qoi-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001\", \"files\": {}}", + "dest": "cargo/vendor/qoi-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quick-error/quick-error-2.0.1.crate", + "sha256": "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3", + "dest": "cargo/vendor/quick-error-2.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3\", \"files\": {}}", + "dest": "cargo/vendor/quick-error-2.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quick-xml/quick-xml-0.41.0.crate", + "sha256": "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1", + "dest": "cargo/vendor/quick-xml-0.41.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1\", \"files\": {}}", + "dest": "cargo/vendor/quick-xml-0.41.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quick-xml/quick-xml-0.42.0.crate", + "sha256": "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b", + "dest": "cargo/vendor/quick-xml-0.42.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b\", \"files\": {}}", + "dest": "cargo/vendor/quick-xml-0.42.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quinn/quinn-0.11.11.crate", + "sha256": "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8", + "dest": "cargo/vendor/quinn-0.11.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8\", \"files\": {}}", + "dest": "cargo/vendor/quinn-0.11.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quinn-proto/quinn-proto-0.11.17.crate", + "sha256": "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83", + "dest": "cargo/vendor/quinn-proto-0.11.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83\", \"files\": {}}", + "dest": "cargo/vendor/quinn-proto-0.11.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quinn-udp/quinn-udp-0.5.15.crate", + "sha256": "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694", + "dest": "cargo/vendor/quinn-udp-0.5.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694\", \"files\": {}}", + "dest": "cargo/vendor/quinn-udp-0.5.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quote/quote-1.0.47.crate", + "sha256": "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001", + "dest": "cargo/vendor/quote-1.0.47" + }, + { + "type": "inline", + "contents": "{\"package\": \"1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001\", \"files\": {}}", + "dest": "cargo/vendor/quote-1.0.47", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quoted_printable/quoted_printable-0.5.2.crate", + "sha256": "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972", + "dest": "cargo/vendor/quoted_printable-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972\", \"files\": {}}", + "dest": "cargo/vendor/quoted_printable-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/r-efi/r-efi-5.3.0.crate", + "sha256": "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f", + "dest": "cargo/vendor/r-efi-5.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f\", \"files\": {}}", + "dest": "cargo/vendor/r-efi-5.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/r-efi/r-efi-6.0.0.crate", + "sha256": "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf", + "dest": "cargo/vendor/r-efi-6.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf\", \"files\": {}}", + "dest": "cargo/vendor/r-efi-6.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand/rand-0.8.8.crate", + "sha256": "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c", + "dest": "cargo/vendor/rand-0.8.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c\", \"files\": {}}", + "dest": "cargo/vendor/rand-0.8.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand/rand-0.9.5.crate", + "sha256": "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41", + "dest": "cargo/vendor/rand-0.9.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41\", \"files\": {}}", + "dest": "cargo/vendor/rand-0.9.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand/rand-0.10.2.crate", + "sha256": "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80", + "dest": "cargo/vendor/rand-0.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80\", \"files\": {}}", + "dest": "cargo/vendor/rand-0.10.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_chacha/rand_chacha-0.3.1.crate", + "sha256": "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88", + "dest": "cargo/vendor/rand_chacha-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88\", \"files\": {}}", + "dest": "cargo/vendor/rand_chacha-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_chacha/rand_chacha-0.9.0.crate", + "sha256": "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb", + "dest": "cargo/vendor/rand_chacha-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb\", \"files\": {}}", + "dest": "cargo/vendor/rand_chacha-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_core/rand_core-0.6.4.crate", + "sha256": "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c", + "dest": "cargo/vendor/rand_core-0.6.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c\", \"files\": {}}", + "dest": "cargo/vendor/rand_core-0.6.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_core/rand_core-0.9.5.crate", + "sha256": "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c", + "dest": "cargo/vendor/rand_core-0.9.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c\", \"files\": {}}", + "dest": "cargo/vendor/rand_core-0.9.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_core/rand_core-0.10.1.crate", + "sha256": "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69", + "dest": "cargo/vendor/rand_core-0.10.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69\", \"files\": {}}", + "dest": "cargo/vendor/rand_core-0.10.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_distr/rand_distr-0.5.1.crate", + "sha256": "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463", + "dest": "cargo/vendor/rand_distr-0.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463\", \"files\": {}}", + "dest": "cargo/vendor/rand_distr-0.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_pcg/rand_pcg-0.10.2.crate", + "sha256": "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a", + "dest": "cargo/vendor/rand_pcg-0.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a\", \"files\": {}}", + "dest": "cargo/vendor/rand_pcg-0.10.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/range-alloc/range-alloc-0.1.5.crate", + "sha256": "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08", + "dest": "cargo/vendor/range-alloc-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08\", \"files\": {}}", + "dest": "cargo/vendor/range-alloc-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rangemap/rangemap-1.8.0.crate", + "sha256": "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d", + "dest": "cargo/vendor/rangemap-1.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d\", \"files\": {}}", + "dest": "cargo/vendor/rangemap-1.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rav1e/rav1e-0.7.1.crate", + "sha256": "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9", + "dest": "cargo/vendor/rav1e-0.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9\", \"files\": {}}", + "dest": "cargo/vendor/rav1e-0.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ravif/ravif-0.11.20.crate", + "sha256": "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b", + "dest": "cargo/vendor/ravif-0.11.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b\", \"files\": {}}", + "dest": "cargo/vendor/ravif-0.11.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/raw-cpuid/raw-cpuid-11.6.0.crate", + "sha256": "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186", + "dest": "cargo/vendor/raw-cpuid-11.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186\", \"files\": {}}", + "dest": "cargo/vendor/raw-cpuid-11.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/raw-window-handle/raw-window-handle-0.6.2.crate", + "sha256": "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539", + "dest": "cargo/vendor/raw-window-handle-0.6.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539\", \"files\": {}}", + "dest": "cargo/vendor/raw-window-handle-0.6.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/raw-window-metal/raw-window-metal-1.1.0.crate", + "sha256": "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135", + "dest": "cargo/vendor/raw-window-metal-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135\", \"files\": {}}", + "dest": "cargo/vendor/raw-window-metal-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rayon/rayon-1.12.0.crate", + "sha256": "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d", + "dest": "cargo/vendor/rayon-1.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d\", \"files\": {}}", + "dest": "cargo/vendor/rayon-1.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rayon-cond/rayon-cond-0.4.0.crate", + "sha256": "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f", + "dest": "cargo/vendor/rayon-cond-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f\", \"files\": {}}", + "dest": "cargo/vendor/rayon-cond-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rayon-core/rayon-core-1.13.0.crate", + "sha256": "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91", + "dest": "cargo/vendor/rayon-core-1.13.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91\", \"files\": {}}", + "dest": "cargo/vendor/rayon-core-1.13.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/read-fonts/read-fonts-0.37.0.crate", + "sha256": "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5", + "dest": "cargo/vendor/read-fonts-0.37.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5\", \"files\": {}}", + "dest": "cargo/vendor/read-fonts-0.37.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/read-fonts/read-fonts-0.39.2.crate", + "sha256": "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81", + "dest": "cargo/vendor/read-fonts-0.39.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81\", \"files\": {}}", + "dest": "cargo/vendor/read-fonts-0.39.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/read-fonts/read-fonts-0.41.0.crate", + "sha256": "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709", + "dest": "cargo/vendor/read-fonts-0.41.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709\", \"files\": {}}", + "dest": "cargo/vendor/read-fonts-0.41.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/reborrow/reborrow-0.5.5.crate", + "sha256": "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430", + "dest": "cargo/vendor/reborrow-0.5.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430\", \"files\": {}}", + "dest": "cargo/vendor/reborrow-0.5.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/redb/redb-3.1.3.crate", + "sha256": "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a", + "dest": "cargo/vendor/redb-3.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a\", \"files\": {}}", + "dest": "cargo/vendor/redb-3.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.4.1.crate", + "sha256": "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa", + "dest": "cargo/vendor/redox_syscall-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa\", \"files\": {}}", + "dest": "cargo/vendor/redox_syscall-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.5.18.crate", + "sha256": "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d", + "dest": "cargo/vendor/redox_syscall-0.5.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d\", \"files\": {}}", + "dest": "cargo/vendor/redox_syscall-0.5.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.9.3.crate", + "sha256": "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5", + "dest": "cargo/vendor/redox_syscall-0.9.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5\", \"files\": {}}", + "dest": "cargo/vendor/redox_syscall-0.9.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/redox_users/redox_users-0.5.2.crate", + "sha256": "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac", + "dest": "cargo/vendor/redox_users-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac\", \"files\": {}}", + "dest": "cargo/vendor/redox_users-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/regex/regex-1.13.1.crate", + "sha256": "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d", + "dest": "cargo/vendor/regex-1.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d\", \"files\": {}}", + "dest": "cargo/vendor/regex-1.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/regex-automata/regex-automata-0.4.18.crate", + "sha256": "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2", + "dest": "cargo/vendor/regex-automata-0.4.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2\", \"files\": {}}", + "dest": "cargo/vendor/regex-automata-0.4.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/regex-syntax/regex-syntax-0.8.11.crate", + "sha256": "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4", + "dest": "cargo/vendor/regex-syntax-0.8.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4\", \"files\": {}}", + "dest": "cargo/vendor/regex-syntax-0.8.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/renderdoc-sys/renderdoc-sys-1.1.0.crate", + "sha256": "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832", + "dest": "cargo/vendor/renderdoc-sys-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832\", \"files\": {}}", + "dest": "cargo/vendor/renderdoc-sys-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/reqwest/reqwest-0.13.4.crate", + "sha256": "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3", + "dest": "cargo/vendor/reqwest-0.13.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3\", \"files\": {}}", + "dest": "cargo/vendor/reqwest-0.13.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/reqwest-middleware/reqwest-middleware-0.5.2.crate", + "sha256": "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58", + "dest": "cargo/vendor/reqwest-middleware-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58\", \"files\": {}}", + "dest": "cargo/vendor/reqwest-middleware-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/resvg/resvg-0.47.0.crate", + "sha256": "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81", + "dest": "cargo/vendor/resvg-0.47.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81\", \"files\": {}}", + "dest": "cargo/vendor/resvg-0.47.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rfd/rfd-0.16.0.crate", + "sha256": "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672", + "dest": "cargo/vendor/rfd-0.16.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672\", \"files\": {}}", + "dest": "cargo/vendor/rfd-0.16.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rgb/rgb-0.8.53.crate", + "sha256": "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4", + "dest": "cargo/vendor/rgb-0.8.53" + }, + { + "type": "inline", + "contents": "{\"package\": \"47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4\", \"files\": {}}", + "dest": "cargo/vendor/rgb-0.8.53", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ring/ring-0.17.14.crate", + "sha256": "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7", + "dest": "cargo/vendor/ring-0.17.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7\", \"files\": {}}", + "dest": "cargo/vendor/ring-0.17.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rowan/rowan-0.16.1.crate", + "sha256": "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21", + "dest": "cargo/vendor/rowan-0.16.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21\", \"files\": {}}", + "dest": "cargo/vendor/rowan-0.16.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/roxmltree/roxmltree-0.20.0.crate", + "sha256": "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97", + "dest": "cargo/vendor/roxmltree-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97\", \"files\": {}}", + "dest": "cargo/vendor/roxmltree-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/roxmltree/roxmltree-0.21.1.crate", + "sha256": "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb", + "dest": "cargo/vendor/roxmltree-0.21.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb\", \"files\": {}}", + "dest": "cargo/vendor/roxmltree-0.21.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rspolib/rspolib-0.1.2.crate", + "sha256": "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535", + "dest": "cargo/vendor/rspolib-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535\", \"files\": {}}", + "dest": "cargo/vendor/rspolib-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rsqlite-vfs/rsqlite-vfs-0.1.1.crate", + "sha256": "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c", + "dest": "cargo/vendor/rsqlite-vfs-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c\", \"files\": {}}", + "dest": "cargo/vendor/rsqlite-vfs-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rusqlite/rusqlite-0.40.2.crate", + "sha256": "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3", + "dest": "cargo/vendor/rusqlite-0.40.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3\", \"files\": {}}", + "dest": "cargo/vendor/rusqlite-0.40.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustc-hash/rustc-hash-1.1.0.crate", + "sha256": "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2", + "dest": "cargo/vendor/rustc-hash-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2\", \"files\": {}}", + "dest": "cargo/vendor/rustc-hash-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustc-hash/rustc-hash-2.1.3.crate", + "sha256": "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d", + "dest": "cargo/vendor/rustc-hash-2.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d\", \"files\": {}}", + "dest": "cargo/vendor/rustc-hash-2.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustc_version/rustc_version-0.4.1.crate", + "sha256": "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92", + "dest": "cargo/vendor/rustc_version-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92\", \"files\": {}}", + "dest": "cargo/vendor/rustc_version-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustix/rustix-0.38.44.crate", + "sha256": "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154", + "dest": "cargo/vendor/rustix-0.38.44" + }, + { + "type": "inline", + "contents": "{\"package\": \"fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154\", \"files\": {}}", + "dest": "cargo/vendor/rustix-0.38.44", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustix/rustix-1.1.4.crate", + "sha256": "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190", + "dest": "cargo/vendor/rustix-1.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190\", \"files\": {}}", + "dest": "cargo/vendor/rustix-1.1.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls/rustls-0.23.43.crate", + "sha256": "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06", + "dest": "cargo/vendor/rustls-0.23.43" + }, + { + "type": "inline", + "contents": "{\"package\": \"0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06\", \"files\": {}}", + "dest": "cargo/vendor/rustls-0.23.43", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-native-certs/rustls-native-certs-0.8.4.crate", + "sha256": "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d", + "dest": "cargo/vendor/rustls-native-certs-0.8.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d\", \"files\": {}}", + "dest": "cargo/vendor/rustls-native-certs-0.8.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-pki-types/rustls-pki-types-1.15.1.crate", + "sha256": "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96", + "dest": "cargo/vendor/rustls-pki-types-1.15.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96\", \"files\": {}}", + "dest": "cargo/vendor/rustls-pki-types-1.15.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-platform-verifier/rustls-platform-verifier-0.7.0.crate", + "sha256": "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0", + "dest": "cargo/vendor/rustls-platform-verifier-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0\", \"files\": {}}", + "dest": "cargo/vendor/rustls-platform-verifier-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-platform-verifier-android/rustls-platform-verifier-android-0.1.1.crate", + "sha256": "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f", + "dest": "cargo/vendor/rustls-platform-verifier-android-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f\", \"files\": {}}", + "dest": "cargo/vendor/rustls-platform-verifier-android-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.15.crate", + "sha256": "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2", + "dest": "cargo/vendor/rustls-webpki-0.103.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2\", \"files\": {}}", + "dest": "cargo/vendor/rustls-webpki-0.103.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustversion/rustversion-1.0.23.crate", + "sha256": "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f", + "dest": "cargo/vendor/rustversion-1.0.23" + }, + { + "type": "inline", + "contents": "{\"package\": \"cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f\", \"files\": {}}", + "dest": "cargo/vendor/rustversion-1.0.23", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustybuzz/rustybuzz-0.20.1.crate", + "sha256": "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702", + "dest": "cargo/vendor/rustybuzz-0.20.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702\", \"files\": {}}", + "dest": "cargo/vendor/rustybuzz-0.20.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ryu/ryu-1.0.23.crate", + "sha256": "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f", + "dest": "cargo/vendor/ryu-1.0.23" + }, + { + "type": "inline", + "contents": "{\"package\": \"9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f\", \"files\": {}}", + "dest": "cargo/vendor/ryu-1.0.23", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/safe-transmute/safe-transmute-0.11.3.crate", + "sha256": "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d", + "dest": "cargo/vendor/safe-transmute-0.11.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d\", \"files\": {}}", + "dest": "cargo/vendor/safe-transmute-0.11.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/safetensors/safetensors-0.8.0.crate", + "sha256": "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846", + "dest": "cargo/vendor/safetensors-0.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846\", \"files\": {}}", + "dest": "cargo/vendor/safetensors-0.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/same-file/same-file-1.0.6.crate", + "sha256": "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502", + "dest": "cargo/vendor/same-file-1.0.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502\", \"files\": {}}", + "dest": "cargo/vendor/same-file-1.0.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/schannel/schannel-0.1.29.crate", + "sha256": "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939", + "dest": "cargo/vendor/schannel-0.1.29" + }, + { + "type": "inline", + "contents": "{\"package\": \"91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939\", \"files\": {}}", + "dest": "cargo/vendor/schannel-0.1.29", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/scoped-tls/scoped-tls-1.0.1.crate", + "sha256": "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294", + "dest": "cargo/vendor/scoped-tls-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294\", \"files\": {}}", + "dest": "cargo/vendor/scoped-tls-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/scoped-tls-hkt/scoped-tls-hkt-0.1.5.crate", + "sha256": "e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d", + "dest": "cargo/vendor/scoped-tls-hkt-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d\", \"files\": {}}", + "dest": "cargo/vendor/scoped-tls-hkt-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/scopeguard/scopeguard-1.2.0.crate", + "sha256": "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49", + "dest": "cargo/vendor/scopeguard-1.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49\", \"files\": {}}", + "dest": "cargo/vendor/scopeguard-1.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sctk-adwaita/sctk-adwaita-0.10.1.crate", + "sha256": "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec", + "dest": "cargo/vendor/sctk-adwaita-0.10.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec\", \"files\": {}}", + "dest": "cargo/vendor/sctk-adwaita-0.10.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/seccompiler/seccompiler-0.5.0.crate", + "sha256": "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884", + "dest": "cargo/vendor/seccompiler-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884\", \"files\": {}}", + "dest": "cargo/vendor/seccompiler-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/security-framework/security-framework-2.11.1.crate", + "sha256": "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02", + "dest": "cargo/vendor/security-framework-2.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02\", \"files\": {}}", + "dest": "cargo/vendor/security-framework-2.11.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/security-framework/security-framework-3.7.0.crate", + "sha256": "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d", + "dest": "cargo/vendor/security-framework-3.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d\", \"files\": {}}", + "dest": "cargo/vendor/security-framework-3.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/security-framework-sys/security-framework-sys-2.17.0.crate", + "sha256": "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3", + "dest": "cargo/vendor/security-framework-sys-2.17.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3\", \"files\": {}}", + "dest": "cargo/vendor/security-framework-sys-2.17.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/selectors/selectors-0.40.0.crate", + "sha256": "eeee001a77567bb05e71652718d3ff76d8697c151ac523e68e9513194eb2b75a", + "dest": "cargo/vendor/selectors-0.40.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"eeee001a77567bb05e71652718d3ff76d8697c151ac523e68e9513194eb2b75a\", \"files\": {}}", + "dest": "cargo/vendor/selectors-0.40.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/self_cell/self_cell-1.3.0.crate", + "sha256": "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813", + "dest": "cargo/vendor/self_cell-1.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813\", \"files\": {}}", + "dest": "cargo/vendor/self_cell-1.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/semver/semver-1.0.28.crate", + "sha256": "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd", + "dest": "cargo/vendor/semver-1.0.28" + }, + { + "type": "inline", + "contents": "{\"package\": \"8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd\", \"files\": {}}", + "dest": "cargo/vendor/semver-1.0.28", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/seq-macro/seq-macro-0.3.6.crate", + "sha256": "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc", + "dest": "cargo/vendor/seq-macro-0.3.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc\", \"files\": {}}", + "dest": "cargo/vendor/seq-macro-0.3.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde/serde-1.0.229.crate", + "sha256": "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba", + "dest": "cargo/vendor/serde-1.0.229" + }, + { + "type": "inline", + "contents": "{\"package\": \"4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba\", \"files\": {}}", + "dest": "cargo/vendor/serde-1.0.229", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_core/serde_core-1.0.229.crate", + "sha256": "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48", + "dest": "cargo/vendor/serde_core-1.0.229" + }, + { + "type": "inline", + "contents": "{\"package\": \"67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48\", \"files\": {}}", + "dest": "cargo/vendor/serde_core-1.0.229", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_derive/serde_derive-1.0.229.crate", + "sha256": "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348", + "dest": "cargo/vendor/serde_derive-1.0.229" + }, + { + "type": "inline", + "contents": "{\"package\": \"e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348\", \"files\": {}}", + "dest": "cargo/vendor/serde_derive-1.0.229", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_json/serde_json-1.0.151.crate", + "sha256": "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14", + "dest": "cargo/vendor/serde_json-1.0.151" + }, + { + "type": "inline", + "contents": "{\"package\": \"c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14\", \"files\": {}}", + "dest": "cargo/vendor/serde_json-1.0.151", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_plain/serde_plain-1.0.2.crate", + "sha256": "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50", + "dest": "cargo/vendor/serde_plain-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50\", \"files\": {}}", + "dest": "cargo/vendor/serde_plain-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_repr/serde_repr-0.1.21.crate", + "sha256": "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906", + "dest": "cargo/vendor/serde_repr-0.1.21" + }, + { + "type": "inline", + "contents": "{\"package\": \"8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906\", \"files\": {}}", + "dest": "cargo/vendor/serde_repr-0.1.21", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_spanned/serde_spanned-0.6.9.crate", + "sha256": "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3", + "dest": "cargo/vendor/serde_spanned-0.6.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3\", \"files\": {}}", + "dest": "cargo/vendor/serde_spanned-0.6.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.1.crate", + "sha256": "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26", + "dest": "cargo/vendor/serde_spanned-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26\", \"files\": {}}", + "dest": "cargo/vendor/serde_spanned-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_urlencoded/serde_urlencoded-0.7.1.crate", + "sha256": "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd", + "dest": "cargo/vendor/serde_urlencoded-0.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd\", \"files\": {}}", + "dest": "cargo/vendor/serde_urlencoded-0.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/servo_arc/servo_arc-0.4.3.crate", + "sha256": "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930", + "dest": "cargo/vendor/servo_arc-0.4.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930\", \"files\": {}}", + "dest": "cargo/vendor/servo_arc-0.4.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sha2/sha2-0.10.9.crate", + "sha256": "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283", + "dest": "cargo/vendor/sha2-0.10.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283\", \"files\": {}}", + "dest": "cargo/vendor/sha2-0.10.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sha2/sha2-0.11.0.crate", + "sha256": "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4", + "dest": "cargo/vendor/sha2-0.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4\", \"files\": {}}", + "dest": "cargo/vendor/sha2-0.11.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sharded-slab/sharded-slab-0.1.7.crate", + "sha256": "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6", + "dest": "cargo/vendor/sharded-slab-0.1.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6\", \"files\": {}}", + "dest": "cargo/vendor/sharded-slab-0.1.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/shellexpand/shellexpand-3.1.2.crate", + "sha256": "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8", + "dest": "cargo/vendor/shellexpand-3.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8\", \"files\": {}}", + "dest": "cargo/vendor/shellexpand-3.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/shlex/shlex-1.3.0.crate", + "sha256": "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64", + "dest": "cargo/vendor/shlex-1.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64\", \"files\": {}}", + "dest": "cargo/vendor/shlex-1.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/shlex/shlex-2.0.1.crate", + "sha256": "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba", + "dest": "cargo/vendor/shlex-2.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba\", \"files\": {}}", + "dest": "cargo/vendor/shlex-2.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/signal-hook-registry/signal-hook-registry-1.4.8.crate", + "sha256": "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b", + "dest": "cargo/vendor/signal-hook-registry-1.4.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b\", \"files\": {}}", + "dest": "cargo/vendor/signal-hook-registry-1.4.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/simd-adler32/simd-adler32-0.3.10.crate", + "sha256": "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea", + "dest": "cargo/vendor/simd-adler32-0.3.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea\", \"files\": {}}", + "dest": "cargo/vendor/simd-adler32-0.3.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/simd_cesu8/simd_cesu8-1.2.0.crate", + "sha256": "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520", + "dest": "cargo/vendor/simd_cesu8-1.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520\", \"files\": {}}", + "dest": "cargo/vendor/simd_cesu8-1.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/simd_helpers/simd_helpers-0.1.0.crate", + "sha256": "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6", + "dest": "cargo/vendor/simd_helpers-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6\", \"files\": {}}", + "dest": "cargo/vendor/simd_helpers-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/simdutf8/simdutf8-0.1.5.crate", + "sha256": "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e", + "dest": "cargo/vendor/simdutf8-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e\", \"files\": {}}", + "dest": "cargo/vendor/simdutf8-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/simplecss/simplecss-0.2.2.crate", + "sha256": "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c", + "dest": "cargo/vendor/simplecss-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c\", \"files\": {}}", + "dest": "cargo/vendor/simplecss-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/siphasher/siphasher-1.0.3.crate", + "sha256": "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649", + "dest": "cargo/vendor/siphasher-1.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649\", \"files\": {}}", + "dest": "cargo/vendor/siphasher-1.0.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/skia-bindings/skia-bindings-0.99.0.crate", + "sha256": "3e2d1c3ebd697c0cbded0145e9204a38fa6b268446051b7196d0a096414ea7f3", + "dest": "cargo/vendor/skia-bindings-0.99.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"3e2d1c3ebd697c0cbded0145e9204a38fa6b268446051b7196d0a096414ea7f3\", \"files\": {}}", + "dest": "cargo/vendor/skia-bindings-0.99.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/skia-safe/skia-safe-0.99.0.crate", + "sha256": "9f512ac418a64194842dd05566320805dad1c957c521039db2486fd6368865bc", + "dest": "cargo/vendor/skia-safe-0.99.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9f512ac418a64194842dd05566320805dad1c957c521039db2486fd6368865bc\", \"files\": {}}", + "dest": "cargo/vendor/skia-safe-0.99.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/skrifa/skrifa-0.40.0.crate", + "sha256": "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac", + "dest": "cargo/vendor/skrifa-0.40.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac\", \"files\": {}}", + "dest": "cargo/vendor/skrifa-0.40.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/skrifa/skrifa-0.42.1.crate", + "sha256": "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564", + "dest": "cargo/vendor/skrifa-0.42.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564\", \"files\": {}}", + "dest": "cargo/vendor/skrifa-0.42.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/skrifa/skrifa-0.44.0.crate", + "sha256": "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68", + "dest": "cargo/vendor/skrifa-0.44.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68\", \"files\": {}}", + "dest": "cargo/vendor/skrifa-0.44.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/slab/slab-0.4.12.crate", + "sha256": "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5", + "dest": "cargo/vendor/slab-0.4.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5\", \"files\": {}}", + "dest": "cargo/vendor/slab-0.4.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/slint/slint-1.17.1.crate", + "sha256": "ce12038f57f8f3a423564b0c9301a2ab3fda4246e4cbadd6b97acf9124bdc315", + "dest": "cargo/vendor/slint-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce12038f57f8f3a423564b0c9301a2ab3fda4246e4cbadd6b97acf9124bdc315\", \"files\": {}}", + "dest": "cargo/vendor/slint-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/slint-build/slint-build-1.17.1.crate", + "sha256": "76684c11441f775b10bbc7b0e6b3016a39f17efdd659ffca112f17cfd6bdaed1", + "dest": "cargo/vendor/slint-build-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"76684c11441f775b10bbc7b0e6b3016a39f17efdd659ffca112f17cfd6bdaed1\", \"files\": {}}", + "dest": "cargo/vendor/slint-build-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/slint-macros/slint-macros-1.17.1.crate", + "sha256": "9f2a763e72e1e53c496f571d36327e777da1eb0d9e58fceb83890c2a83ee7223", + "dest": "cargo/vendor/slint-macros-1.17.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9f2a763e72e1e53c496f571d36327e777da1eb0d9e58fceb83890c2a83ee7223\", \"files\": {}}", + "dest": "cargo/vendor/slint-macros-1.17.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/slotmap/slotmap-1.1.1.crate", + "sha256": "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038", + "dest": "cargo/vendor/slotmap-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038\", \"files\": {}}", + "dest": "cargo/vendor/slotmap-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smallbitvec/smallbitvec-2.6.1.crate", + "sha256": "9b0e903ee191d8f7a8fbf0d712c3a1699d19e04ceba5ad1eb673053c7d938a09", + "dest": "cargo/vendor/smallbitvec-2.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9b0e903ee191d8f7a8fbf0d712c3a1699d19e04ceba5ad1eb673053c7d938a09\", \"files\": {}}", + "dest": "cargo/vendor/smallbitvec-2.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smallvec/smallvec-1.15.2.crate", + "sha256": "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90", + "dest": "cargo/vendor/smallvec-1.15.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90\", \"files\": {}}", + "dest": "cargo/vendor/smallvec-1.15.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smithay-client-toolkit/smithay-client-toolkit-0.19.2.crate", + "sha256": "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016", + "dest": "cargo/vendor/smithay-client-toolkit-0.19.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016\", \"files\": {}}", + "dest": "cargo/vendor/smithay-client-toolkit-0.19.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smithay-client-toolkit/smithay-client-toolkit-0.20.0.crate", + "sha256": "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0", + "dest": "cargo/vendor/smithay-client-toolkit-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0\", \"files\": {}}", + "dest": "cargo/vendor/smithay-client-toolkit-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smithay-clipboard/smithay-clipboard-0.7.3.crate", + "sha256": "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226", + "dest": "cargo/vendor/smithay-clipboard-0.7.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226\", \"files\": {}}", + "dest": "cargo/vendor/smithay-clipboard-0.7.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smol_str/smol_str-0.2.2.crate", + "sha256": "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead", + "dest": "cargo/vendor/smol_str-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead\", \"files\": {}}", + "dest": "cargo/vendor/smol_str-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smol_str/smol_str-0.3.6.crate", + "sha256": "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523", + "dest": "cargo/vendor/smol_str-0.3.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523\", \"files\": {}}", + "dest": "cargo/vendor/smol_str-0.3.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/snafu/snafu-0.8.9.crate", + "sha256": "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2", + "dest": "cargo/vendor/snafu-0.8.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2\", \"files\": {}}", + "dest": "cargo/vendor/snafu-0.8.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/snafu-derive/snafu-derive-0.8.9.crate", + "sha256": "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451", + "dest": "cargo/vendor/snafu-derive-0.8.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451\", \"files\": {}}", + "dest": "cargo/vendor/snafu-derive-0.8.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/socket2/socket2-0.6.5.crate", + "sha256": "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4", + "dest": "cargo/vendor/socket2-0.6.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4\", \"files\": {}}", + "dest": "cargo/vendor/socket2-0.6.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/softbuffer/softbuffer-0.4.8.crate", + "sha256": "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3", + "dest": "cargo/vendor/softbuffer-0.4.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3\", \"files\": {}}", + "dest": "cargo/vendor/softbuffer-0.4.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/spin_on/spin_on-0.1.1.crate", + "sha256": "076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2", + "dest": "cargo/vendor/spin_on-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2\", \"files\": {}}", + "dest": "cargo/vendor/spin_on-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/spirv/spirv-0.4.0+sdk-1.4.341.0.crate", + "sha256": "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f", + "dest": "cargo/vendor/spirv-0.4.0+sdk-1.4.341.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f\", \"files\": {}}", + "dest": "cargo/vendor/spirv-0.4.0+sdk-1.4.341.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/spm_precompiled/spm_precompiled-0.1.4.crate", + "sha256": "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326", + "dest": "cargo/vendor/spm_precompiled-0.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326\", \"files\": {}}", + "dest": "cargo/vendor/spm_precompiled-0.1.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sqlite-wasm-rs/sqlite-wasm-rs-0.5.5.crate", + "sha256": "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75", + "dest": "cargo/vendor/sqlite-wasm-rs-0.5.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75\", \"files\": {}}", + "dest": "cargo/vendor/sqlite-wasm-rs-0.5.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stable_deref_trait/stable_deref_trait-1.2.1.crate", + "sha256": "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596", + "dest": "cargo/vendor/stable_deref_trait-1.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596\", \"files\": {}}", + "dest": "cargo/vendor/stable_deref_trait-1.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/static_assertions/static_assertions-1.1.0.crate", + "sha256": "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f", + "dest": "cargo/vendor/static_assertions-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f\", \"files\": {}}", + "dest": "cargo/vendor/static_assertions-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/statrs/statrs-0.18.0.crate", + "sha256": "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e", + "dest": "cargo/vendor/statrs-0.18.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e\", \"files\": {}}", + "dest": "cargo/vendor/statrs-0.18.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stop-token/stop-token-0.7.0.crate", + "sha256": "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b", + "dest": "cargo/vendor/stop-token-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b\", \"files\": {}}", + "dest": "cargo/vendor/stop-token-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/strict-num/strict-num-0.1.1.crate", + "sha256": "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731", + "dest": "cargo/vendor/strict-num-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731\", \"files\": {}}", + "dest": "cargo/vendor/strict-num-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/string_cache/string_cache-0.9.0.crate", + "sha256": "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901", + "dest": "cargo/vendor/string_cache-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901\", \"files\": {}}", + "dest": "cargo/vendor/string_cache-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/string_cache_codegen/string_cache_codegen-0.6.1.crate", + "sha256": "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69", + "dest": "cargo/vendor/string_cache_codegen-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69\", \"files\": {}}", + "dest": "cargo/vendor/string_cache_codegen-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/strsim/strsim-0.11.1.crate", + "sha256": "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f", + "dest": "cargo/vendor/strsim-0.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f\", \"files\": {}}", + "dest": "cargo/vendor/strsim-0.11.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/strum/strum-0.28.0.crate", + "sha256": "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd", + "dest": "cargo/vendor/strum-0.28.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd\", \"files\": {}}", + "dest": "cargo/vendor/strum-0.28.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/strum_macros/strum_macros-0.28.0.crate", + "sha256": "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664", + "dest": "cargo/vendor/strum_macros-0.28.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664\", \"files\": {}}", + "dest": "cargo/vendor/strum_macros-0.28.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stylo/stylo-0.20.0.crate", + "sha256": "ebaeb0b51f5e574bf36606c4dc982430a20d7a17175bdc0deab4d43d2063040b", + "dest": "cargo/vendor/stylo-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ebaeb0b51f5e574bf36606c4dc982430a20d7a17175bdc0deab4d43d2063040b\", \"files\": {}}", + "dest": "cargo/vendor/stylo-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stylo_atoms/stylo_atoms-0.20.0.crate", + "sha256": "d448de89b7d18169a160ca258d2ee63efe79bd6cb20360654101835e9b80e386", + "dest": "cargo/vendor/stylo_atoms-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d448de89b7d18169a160ca258d2ee63efe79bd6cb20360654101835e9b80e386\", \"files\": {}}", + "dest": "cargo/vendor/stylo_atoms-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stylo_derive/stylo_derive-0.20.0.crate", + "sha256": "9cfdbf36a2d2db4fc75db0bfc1abd16e90971c360f00773cbd2501a5175dbec7", + "dest": "cargo/vendor/stylo_derive-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9cfdbf36a2d2db4fc75db0bfc1abd16e90971c360f00773cbd2501a5175dbec7\", \"files\": {}}", + "dest": "cargo/vendor/stylo_derive-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stylo_dom/stylo_dom-0.20.0.crate", + "sha256": "346fde14a66fdc568ef79a8c81f8b6a35722a0906295bce2997df6c90c7b848f", + "dest": "cargo/vendor/stylo_dom-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"346fde14a66fdc568ef79a8c81f8b6a35722a0906295bce2997df6c90c7b848f\", \"files\": {}}", + "dest": "cargo/vendor/stylo_dom-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stylo_malloc_size_of/stylo_malloc_size_of-0.20.0.crate", + "sha256": "5a1ee5ec323e0207ebaafff2210fec808214575d67cfe25e288d34a12010e2b3", + "dest": "cargo/vendor/stylo_malloc_size_of-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5a1ee5ec323e0207ebaafff2210fec808214575d67cfe25e288d34a12010e2b3\", \"files\": {}}", + "dest": "cargo/vendor/stylo_malloc_size_of-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stylo_static_prefs/stylo_static_prefs-0.20.0.crate", + "sha256": "0e017eb7fd506fa0fb0da653a6bc3793d9fc11edf92d4721c16b350432f8116f", + "dest": "cargo/vendor/stylo_static_prefs-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0e017eb7fd506fa0fb0da653a6bc3793d9fc11edf92d4721c16b350432f8116f\", \"files\": {}}", + "dest": "cargo/vendor/stylo_static_prefs-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stylo_taffy/stylo_taffy-0.3.0-beta.2.crate", + "sha256": "bfac7e7262ee946db8937b127e07688880d7224e539e504ac2fe033f83f41323", + "dest": "cargo/vendor/stylo_taffy-0.3.0-beta.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"bfac7e7262ee946db8937b127e07688880d7224e539e504ac2fe033f83f41323\", \"files\": {}}", + "dest": "cargo/vendor/stylo_taffy-0.3.0-beta.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stylo_traits/stylo_traits-0.20.0.crate", + "sha256": "00a8d2ca5b29c7f5dd94ec66fbb256b274b76ecaab164417c92ce9a4586c8259", + "dest": "cargo/vendor/stylo_traits-0.20.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"00a8d2ca5b29c7f5dd94ec66fbb256b274b76ecaab164417c92ce9a4586c8259\", \"files\": {}}", + "dest": "cargo/vendor/stylo_traits-0.20.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/subtle/subtle-2.6.1.crate", + "sha256": "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292", + "dest": "cargo/vendor/subtle-2.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292\", \"files\": {}}", + "dest": "cargo/vendor/subtle-2.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/svg_fmt/svg_fmt-0.4.5.crate", + "sha256": "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb", + "dest": "cargo/vendor/svg_fmt-0.4.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb\", \"files\": {}}", + "dest": "cargo/vendor/svg_fmt-0.4.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/svgtypes/svgtypes-0.16.1.crate", + "sha256": "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d", + "dest": "cargo/vendor/svgtypes-0.16.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d\", \"files\": {}}", + "dest": "cargo/vendor/svgtypes-0.16.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/swash/swash-0.2.10.crate", + "sha256": "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e", + "dest": "cargo/vendor/swash-0.2.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e\", \"files\": {}}", + "dest": "cargo/vendor/swash-0.2.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/symlink/symlink-0.1.0.crate", + "sha256": "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a", + "dest": "cargo/vendor/symlink-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a\", \"files\": {}}", + "dest": "cargo/vendor/symlink-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/syn/syn-2.0.119.crate", + "sha256": "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297", + "dest": "cargo/vendor/syn-2.0.119" + }, + { + "type": "inline", + "contents": "{\"package\": \"872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297\", \"files\": {}}", + "dest": "cargo/vendor/syn-2.0.119", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/syn/syn-3.0.4.crate", + "sha256": "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f", + "dest": "cargo/vendor/syn-3.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f\", \"files\": {}}", + "dest": "cargo/vendor/syn-3.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sync_wrapper/sync_wrapper-1.0.2.crate", + "sha256": "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263", + "dest": "cargo/vendor/sync_wrapper-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263\", \"files\": {}}", + "dest": "cargo/vendor/sync_wrapper-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/synstructure/synstructure-0.13.2.crate", + "sha256": "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2", + "dest": "cargo/vendor/synstructure-0.13.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2\", \"files\": {}}", + "dest": "cargo/vendor/synstructure-0.13.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sys-locale/sys-locale-0.3.2.crate", + "sha256": "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4", + "dest": "cargo/vendor/sys-locale-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4\", \"files\": {}}", + "dest": "cargo/vendor/sys-locale-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sysctl/sysctl-0.6.0.crate", + "sha256": "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc", + "dest": "cargo/vendor/sysctl-0.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc\", \"files\": {}}", + "dest": "cargo/vendor/sysctl-0.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sysinfo/sysinfo-0.38.4.crate", + "sha256": "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f", + "dest": "cargo/vendor/sysinfo-0.38.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f\", \"files\": {}}", + "dest": "cargo/vendor/sysinfo-0.38.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/system-configuration/system-configuration-0.7.0.crate", + "sha256": "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b", + "dest": "cargo/vendor/system-configuration-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b\", \"files\": {}}", + "dest": "cargo/vendor/system-configuration-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/system-configuration-sys/system-configuration-sys-0.6.0.crate", + "sha256": "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4", + "dest": "cargo/vendor/system-configuration-sys-0.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4\", \"files\": {}}", + "dest": "cargo/vendor/system-configuration-sys-0.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/system-deps/system-deps-6.2.2.crate", + "sha256": "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349", + "dest": "cargo/vendor/system-deps-6.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349\", \"files\": {}}", + "dest": "cargo/vendor/system-deps-6.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/taffy/taffy-0.10.1.crate", + "sha256": "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859", + "dest": "cargo/vendor/taffy-0.10.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859\", \"files\": {}}", + "dest": "cargo/vendor/taffy-0.10.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/taffy/taffy-0.14.0.crate", + "sha256": "639627c87f43b9181c811f40a6296409e093a17bc761214cba3c15df74f86b99", + "dest": "cargo/vendor/taffy-0.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"639627c87f43b9181c811f40a6296409e093a17bc761214cba3c15df74f86b99\", \"files\": {}}", + "dest": "cargo/vendor/taffy-0.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tar/tar-0.4.46.crate", + "sha256": "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840", + "dest": "cargo/vendor/tar-0.4.46" + }, + { + "type": "inline", + "contents": "{\"package\": \"3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840\", \"files\": {}}", + "dest": "cargo/vendor/tar-0.4.46", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/target-lexicon/target-lexicon-0.12.16.crate", + "sha256": "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1", + "dest": "cargo/vendor/target-lexicon-0.12.16" + }, + { + "type": "inline", + "contents": "{\"package\": \"61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1\", \"files\": {}}", + "dest": "cargo/vendor/target-lexicon-0.12.16", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/task-local/task-local-0.1.1.crate", + "sha256": "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626", + "dest": "cargo/vendor/task-local-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626\", \"files\": {}}", + "dest": "cargo/vendor/task-local-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tempfile/tempfile-3.27.0.crate", + "sha256": "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd", + "dest": "cargo/vendor/tempfile-3.27.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd\", \"files\": {}}", + "dest": "cargo/vendor/tempfile-3.27.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tendril/tendril-0.5.1.crate", + "sha256": "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08", + "dest": "cargo/vendor/tendril-0.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08\", \"files\": {}}", + "dest": "cargo/vendor/tendril-0.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/termcolor/termcolor-1.4.1.crate", + "sha256": "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755", + "dest": "cargo/vendor/termcolor-1.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755\", \"files\": {}}", + "dest": "cargo/vendor/termcolor-1.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/text-size/text-size-1.1.1.crate", + "sha256": "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233", + "dest": "cargo/vendor/text-size-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233\", \"files\": {}}", + "dest": "cargo/vendor/text-size-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thin-vec/thin-vec-0.2.19.crate", + "sha256": "79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d", + "dest": "cargo/vendor/thin-vec-0.2.19" + }, + { + "type": "inline", + "contents": "{\"package\": \"79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d\", \"files\": {}}", + "dest": "cargo/vendor/thin-vec-0.2.19", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thiserror/thiserror-1.0.69.crate", + "sha256": "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52", + "dest": "cargo/vendor/thiserror-1.0.69" + }, + { + "type": "inline", + "contents": "{\"package\": \"b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52\", \"files\": {}}", + "dest": "cargo/vendor/thiserror-1.0.69", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thiserror/thiserror-2.0.20.crate", + "sha256": "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f", + "dest": "cargo/vendor/thiserror-2.0.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f\", \"files\": {}}", + "dest": "cargo/vendor/thiserror-2.0.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thiserror-impl/thiserror-impl-1.0.69.crate", + "sha256": "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1", + "dest": "cargo/vendor/thiserror-impl-1.0.69" + }, + { + "type": "inline", + "contents": "{\"package\": \"4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1\", \"files\": {}}", + "dest": "cargo/vendor/thiserror-impl-1.0.69", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thiserror-impl/thiserror-impl-2.0.20.crate", + "sha256": "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af", + "dest": "cargo/vendor/thiserror-impl-2.0.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af\", \"files\": {}}", + "dest": "cargo/vendor/thiserror-impl-2.0.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thread_local/thread_local-1.1.10.crate", + "sha256": "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070", + "dest": "cargo/vendor/thread_local-1.1.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070\", \"files\": {}}", + "dest": "cargo/vendor/thread_local-1.1.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tiff/tiff-0.9.1.crate", + "sha256": "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e", + "dest": "cargo/vendor/tiff-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e\", \"files\": {}}", + "dest": "cargo/vendor/tiff-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/time/time-0.3.55.crate", + "sha256": "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134", + "dest": "cargo/vendor/time-0.3.55" + }, + { + "type": "inline", + "contents": "{\"package\": \"cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134\", \"files\": {}}", + "dest": "cargo/vendor/time-0.3.55", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/time-core/time-core-0.1.9.crate", + "sha256": "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109", + "dest": "cargo/vendor/time-core-0.1.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109\", \"files\": {}}", + "dest": "cargo/vendor/time-core-0.1.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/time-macros/time-macros-0.2.32.crate", + "sha256": "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85", + "dest": "cargo/vendor/time-macros-0.2.32" + }, + { + "type": "inline", + "contents": "{\"package\": \"7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85\", \"files\": {}}", + "dest": "cargo/vendor/time-macros-0.2.32", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tiny-skia/tiny-skia-0.11.4.crate", + "sha256": "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab", + "dest": "cargo/vendor/tiny-skia-0.11.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab\", \"files\": {}}", + "dest": "cargo/vendor/tiny-skia-0.11.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tiny-skia/tiny-skia-0.12.0.crate", + "sha256": "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea", + "dest": "cargo/vendor/tiny-skia-0.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea\", \"files\": {}}", + "dest": "cargo/vendor/tiny-skia-0.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tiny-skia-path/tiny-skia-path-0.11.4.crate", + "sha256": "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93", + "dest": "cargo/vendor/tiny-skia-path-0.11.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93\", \"files\": {}}", + "dest": "cargo/vendor/tiny-skia-path-0.11.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tiny-skia-path/tiny-skia-path-0.12.0.crate", + "sha256": "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9", + "dest": "cargo/vendor/tiny-skia-path-0.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9\", \"files\": {}}", + "dest": "cargo/vendor/tiny-skia-path-0.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tiny-xlib/tiny-xlib-0.2.5.crate", + "sha256": "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4", + "dest": "cargo/vendor/tiny-xlib-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4\", \"files\": {}}", + "dest": "cargo/vendor/tiny-xlib-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tinystr/tinystr-0.8.4.crate", + "sha256": "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643", + "dest": "cargo/vendor/tinystr-0.8.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643\", \"files\": {}}", + "dest": "cargo/vendor/tinystr-0.8.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tinyvec/tinyvec-1.12.0.crate", + "sha256": "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f", + "dest": "cargo/vendor/tinyvec-1.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f\", \"files\": {}}", + "dest": "cargo/vendor/tinyvec-1.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tinyvec_macros/tinyvec_macros-0.1.1.crate", + "sha256": "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20", + "dest": "cargo/vendor/tinyvec_macros-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20\", \"files\": {}}", + "dest": "cargo/vendor/tinyvec_macros-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/to_shmem/to_shmem-0.5.0.crate", + "sha256": "b8c81cee0d28327337a94f3f32a1a77c03bbfd926510f3b42956f9d914e7810c", + "dest": "cargo/vendor/to_shmem-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b8c81cee0d28327337a94f3f32a1a77c03bbfd926510f3b42956f9d914e7810c\", \"files\": {}}", + "dest": "cargo/vendor/to_shmem-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/to_shmem_derive/to_shmem_derive-0.1.0.crate", + "sha256": "2ba1f5563024b63bb6acb4558452d9ba737518c1d11fcc1861febe98d1e31cf4", + "dest": "cargo/vendor/to_shmem_derive-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2ba1f5563024b63bb6acb4558452d9ba737518c1d11fcc1861febe98d1e31cf4\", \"files\": {}}", + "dest": "cargo/vendor/to_shmem_derive-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokenizers/tokenizers-0.22.2.crate", + "sha256": "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223", + "dest": "cargo/vendor/tokenizers-0.22.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223\", \"files\": {}}", + "dest": "cargo/vendor/tokenizers-0.22.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio/tokio-1.53.1.crate", + "sha256": "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed", + "dest": "cargo/vendor/tokio-1.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed\", \"files\": {}}", + "dest": "cargo/vendor/tokio-1.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-macros/tokio-macros-2.7.2.crate", + "sha256": "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e", + "dest": "cargo/vendor/tokio-macros-2.7.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e\", \"files\": {}}", + "dest": "cargo/vendor/tokio-macros-2.7.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-retry/tokio-retry-0.3.2.crate", + "sha256": "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470", + "dest": "cargo/vendor/tokio-retry-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470\", \"files\": {}}", + "dest": "cargo/vendor/tokio-retry-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-rustls/tokio-rustls-0.26.4.crate", + "sha256": "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61", + "dest": "cargo/vendor/tokio-rustls-0.26.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61\", \"files\": {}}", + "dest": "cargo/vendor/tokio-rustls-0.26.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-util/tokio-util-0.7.19.crate", + "sha256": "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52", + "dest": "cargo/vendor/tokio-util-0.7.19" + }, + { + "type": "inline", + "contents": "{\"package\": \"494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52\", \"files\": {}}", + "dest": "cargo/vendor/tokio-util-0.7.19", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio_with_wasm/tokio_with_wasm-0.8.8.crate", + "sha256": "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6", + "dest": "cargo/vendor/tokio_with_wasm-0.8.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6\", \"files\": {}}", + "dest": "cargo/vendor/tokio_with_wasm-0.8.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio_with_wasm_proc/tokio_with_wasm_proc-0.8.8.crate", + "sha256": "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992", + "dest": "cargo/vendor/tokio_with_wasm_proc-0.8.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992\", \"files\": {}}", + "dest": "cargo/vendor/tokio_with_wasm_proc-0.8.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml/toml-0.8.23.crate", + "sha256": "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362", + "dest": "cargo/vendor/toml-0.8.23" + }, + { + "type": "inline", + "contents": "{\"package\": \"dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362\", \"files\": {}}", + "dest": "cargo/vendor/toml-0.8.23", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml/toml-1.1.4+spec-1.1.0.crate", + "sha256": "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5", + "dest": "cargo/vendor/toml-1.1.4+spec-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5\", \"files\": {}}", + "dest": "cargo/vendor/toml-1.1.4+spec-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_datetime/toml_datetime-0.6.11.crate", + "sha256": "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c", + "dest": "cargo/vendor/toml_datetime-0.6.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c\", \"files\": {}}", + "dest": "cargo/vendor/toml_datetime-0.6.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.1+spec-1.1.0.crate", + "sha256": "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7", + "dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7\", \"files\": {}}", + "dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_edit/toml_edit-0.22.27.crate", + "sha256": "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a", + "dest": "cargo/vendor/toml_edit-0.22.27" + }, + { + "type": "inline", + "contents": "{\"package\": \"41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a\", \"files\": {}}", + "dest": "cargo/vendor/toml_edit-0.22.27", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.13+spec-1.1.0.crate", + "sha256": "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b", + "dest": "cargo/vendor/toml_edit-0.25.13+spec-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b\", \"files\": {}}", + "dest": "cargo/vendor/toml_edit-0.25.13+spec-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.3+spec-1.1.0.crate", + "sha256": "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56", + "dest": "cargo/vendor/toml_parser-1.1.3+spec-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56\", \"files\": {}}", + "dest": "cargo/vendor/toml_parser-1.1.3+spec-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.2+spec-1.1.0.crate", + "sha256": "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2", + "dest": "cargo/vendor/toml_writer-1.1.2+spec-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2\", \"files\": {}}", + "dest": "cargo/vendor/toml_writer-1.1.2+spec-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tower/tower-0.5.3.crate", + "sha256": "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4", + "dest": "cargo/vendor/tower-0.5.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4\", \"files\": {}}", + "dest": "cargo/vendor/tower-0.5.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tower-http/tower-http-0.6.11.crate", + "sha256": "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840", + "dest": "cargo/vendor/tower-http-0.6.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840\", \"files\": {}}", + "dest": "cargo/vendor/tower-http-0.6.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tower-layer/tower-layer-0.3.3.crate", + "sha256": "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e", + "dest": "cargo/vendor/tower-layer-0.3.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e\", \"files\": {}}", + "dest": "cargo/vendor/tower-layer-0.3.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tower-service/tower-service-0.3.3.crate", + "sha256": "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3", + "dest": "cargo/vendor/tower-service-0.3.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3\", \"files\": {}}", + "dest": "cargo/vendor/tower-service-0.3.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing/tracing-0.1.44.crate", + "sha256": "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100", + "dest": "cargo/vendor/tracing-0.1.44" + }, + { + "type": "inline", + "contents": "{\"package\": \"63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100\", \"files\": {}}", + "dest": "cargo/vendor/tracing-0.1.44", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-appender/tracing-appender-0.2.5.crate", + "sha256": "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c", + "dest": "cargo/vendor/tracing-appender-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c\", \"files\": {}}", + "dest": "cargo/vendor/tracing-appender-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-attributes/tracing-attributes-0.1.31.crate", + "sha256": "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da", + "dest": "cargo/vendor/tracing-attributes-0.1.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da\", \"files\": {}}", + "dest": "cargo/vendor/tracing-attributes-0.1.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-core/tracing-core-0.1.36.crate", + "sha256": "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a", + "dest": "cargo/vendor/tracing-core-0.1.36" + }, + { + "type": "inline", + "contents": "{\"package\": \"db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a\", \"files\": {}}", + "dest": "cargo/vendor/tracing-core-0.1.36", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-log/tracing-log-0.2.0.crate", + "sha256": "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3", + "dest": "cargo/vendor/tracing-log-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3\", \"files\": {}}", + "dest": "cargo/vendor/tracing-log-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-serde/tracing-serde-0.2.0.crate", + "sha256": "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1", + "dest": "cargo/vendor/tracing-serde-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1\", \"files\": {}}", + "dest": "cargo/vendor/tracing-serde-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-subscriber/tracing-subscriber-0.3.23.crate", + "sha256": "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319", + "dest": "cargo/vendor/tracing-subscriber-0.3.23" + }, + { + "type": "inline", + "contents": "{\"package\": \"cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319\", \"files\": {}}", + "dest": "cargo/vendor/tracing-subscriber-0.3.23", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tree_magic_mini/tree_magic_mini-3.2.2.crate", + "sha256": "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6", + "dest": "cargo/vendor/tree_magic_mini-3.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6\", \"files\": {}}", + "dest": "cargo/vendor/tree_magic_mini-3.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/try-lock/try-lock-0.2.5.crate", + "sha256": "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b", + "dest": "cargo/vendor/try-lock-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b\", \"files\": {}}", + "dest": "cargo/vendor/try-lock-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ttf-parser/ttf-parser-0.25.1.crate", + "sha256": "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31", + "dest": "cargo/vendor/ttf-parser-0.25.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31\", \"files\": {}}", + "dest": "cargo/vendor/ttf-parser-0.25.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/twox-hash/twox-hash-2.1.4.crate", + "sha256": "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a", + "dest": "cargo/vendor/twox-hash-2.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a\", \"files\": {}}", + "dest": "cargo/vendor/twox-hash-2.1.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/typed-index-collections/typed-index-collections-3.5.0.crate", + "sha256": "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd", + "dest": "cargo/vendor/typed-index-collections-3.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd\", \"files\": {}}", + "dest": "cargo/vendor/typed-index-collections-3.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/typed-path/typed-path-0.12.3.crate", + "sha256": "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e", + "dest": "cargo/vendor/typed-path-0.12.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e\", \"files\": {}}", + "dest": "cargo/vendor/typed-path-0.12.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/typenum/typenum-1.20.1.crate", + "sha256": "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20", + "dest": "cargo/vendor/typenum-1.20.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20\", \"files\": {}}", + "dest": "cargo/vendor/typenum-1.20.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/typewit/typewit-1.15.2.crate", + "sha256": "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6", + "dest": "cargo/vendor/typewit-1.15.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6\", \"files\": {}}", + "dest": "cargo/vendor/typewit-1.15.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/udev/udev-0.9.3.crate", + "sha256": "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f", + "dest": "cargo/vendor/udev-0.9.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f\", \"files\": {}}", + "dest": "cargo/vendor/udev-0.9.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/uds_windows/uds_windows-1.2.1.crate", + "sha256": "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e", + "dest": "cargo/vendor/uds_windows-1.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e\", \"files\": {}}", + "dest": "cargo/vendor/uds_windows-1.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/uluru/uluru-3.1.0.crate", + "sha256": "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da", + "dest": "cargo/vendor/uluru-3.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da\", \"files\": {}}", + "dest": "cargo/vendor/uluru-3.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicase/unicase-2.9.0.crate", + "sha256": "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142", + "dest": "cargo/vendor/unicase-2.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142\", \"files\": {}}", + "dest": "cargo/vendor/unicase-2.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-bidi/unicode-bidi-0.3.18.crate", + "sha256": "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5", + "dest": "cargo/vendor/unicode-bidi-0.3.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5\", \"files\": {}}", + "dest": "cargo/vendor/unicode-bidi-0.3.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-bidi-mirroring/unicode-bidi-mirroring-0.4.0.crate", + "sha256": "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe", + "dest": "cargo/vendor/unicode-bidi-mirroring-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe\", \"files\": {}}", + "dest": "cargo/vendor/unicode-bidi-mirroring-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-ccc/unicode-ccc-0.4.0.crate", + "sha256": "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e", + "dest": "cargo/vendor/unicode-ccc-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e\", \"files\": {}}", + "dest": "cargo/vendor/unicode-ccc-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-ident/unicode-ident-1.0.24.crate", + "sha256": "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75", + "dest": "cargo/vendor/unicode-ident-1.0.24" + }, + { + "type": "inline", + "contents": "{\"package\": \"e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75\", \"files\": {}}", + "dest": "cargo/vendor/unicode-ident-1.0.24", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-linebreak/unicode-linebreak-0.1.5.crate", + "sha256": "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f", + "dest": "cargo/vendor/unicode-linebreak-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f\", \"files\": {}}", + "dest": "cargo/vendor/unicode-linebreak-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-normalization-alignments/unicode-normalization-alignments-0.1.12.crate", + "sha256": "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de", + "dest": "cargo/vendor/unicode-normalization-alignments-0.1.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de\", \"files\": {}}", + "dest": "cargo/vendor/unicode-normalization-alignments-0.1.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-properties/unicode-properties-0.1.4.crate", + "sha256": "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d", + "dest": "cargo/vendor/unicode-properties-0.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d\", \"files\": {}}", + "dest": "cargo/vendor/unicode-properties-0.1.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-script/unicode-script-0.5.8.crate", + "sha256": "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee", + "dest": "cargo/vendor/unicode-script-0.5.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee\", \"files\": {}}", + "dest": "cargo/vendor/unicode-script-0.5.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-segmentation/unicode-segmentation-1.13.3.crate", + "sha256": "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8", + "dest": "cargo/vendor/unicode-segmentation-1.13.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8\", \"files\": {}}", + "dest": "cargo/vendor/unicode-segmentation-1.13.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-vo/unicode-vo-0.1.0.crate", + "sha256": "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94", + "dest": "cargo/vendor/unicode-vo-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94\", \"files\": {}}", + "dest": "cargo/vendor/unicode-vo-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-width/unicode-width-0.2.2.crate", + "sha256": "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254", + "dest": "cargo/vendor/unicode-width-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254\", \"files\": {}}", + "dest": "cargo/vendor/unicode-width-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-xid/unicode-xid-0.2.6.crate", + "sha256": "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853", + "dest": "cargo/vendor/unicode-xid-0.2.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853\", \"files\": {}}", + "dest": "cargo/vendor/unicode-xid-0.2.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode_categories/unicode_categories-0.1.1.crate", + "sha256": "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e", + "dest": "cargo/vendor/unicode_categories-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e\", \"files\": {}}", + "dest": "cargo/vendor/unicode_categories-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/untrusted/untrusted-0.9.0.crate", + "sha256": "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1", + "dest": "cargo/vendor/untrusted-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1\", \"files\": {}}", + "dest": "cargo/vendor/untrusted-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unty/unty-0.0.4.crate", + "sha256": "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae", + "dest": "cargo/vendor/unty-0.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae\", \"files\": {}}", + "dest": "cargo/vendor/unty-0.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/url/url-2.5.8.crate", + "sha256": "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed", + "dest": "cargo/vendor/url-2.5.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed\", \"files\": {}}", + "dest": "cargo/vendor/url-2.5.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/urlencoding/urlencoding-2.1.3.crate", + "sha256": "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da", + "dest": "cargo/vendor/urlencoding-2.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da\", \"files\": {}}", + "dest": "cargo/vendor/urlencoding-2.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/usvg/usvg-0.47.0.crate", + "sha256": "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de", + "dest": "cargo/vendor/usvg-0.47.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de\", \"files\": {}}", + "dest": "cargo/vendor/usvg-0.47.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/usvg/usvg-0.48.1.crate", + "sha256": "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91", + "dest": "cargo/vendor/usvg-0.48.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91\", \"files\": {}}", + "dest": "cargo/vendor/usvg-0.48.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/utf16string/utf16string-0.2.0.crate", + "sha256": "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216", + "dest": "cargo/vendor/utf16string-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216\", \"files\": {}}", + "dest": "cargo/vendor/utf16string-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/utf8_iter/utf8_iter-1.0.4.crate", + "sha256": "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be", + "dest": "cargo/vendor/utf8_iter-1.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be\", \"files\": {}}", + "dest": "cargo/vendor/utf8_iter-1.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/uuid/uuid-1.26.0.crate", + "sha256": "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812", + "dest": "cargo/vendor/uuid-1.26.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812\", \"files\": {}}", + "dest": "cargo/vendor/uuid-1.26.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/v_frame/v_frame-0.3.9.crate", + "sha256": "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2", + "dest": "cargo/vendor/v_frame-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2\", \"files\": {}}", + "dest": "cargo/vendor/v_frame-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/valuable/valuable-0.1.1.crate", + "sha256": "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65", + "dest": "cargo/vendor/valuable-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65\", \"files\": {}}", + "dest": "cargo/vendor/valuable-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vcpkg/vcpkg-0.2.15.crate", + "sha256": "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426", + "dest": "cargo/vendor/vcpkg-0.2.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426\", \"files\": {}}", + "dest": "cargo/vendor/vcpkg-0.2.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vecmath/vecmath-1.0.0.crate", + "sha256": "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a", + "dest": "cargo/vendor/vecmath-1.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a\", \"files\": {}}", + "dest": "cargo/vendor/vecmath-1.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vello/vello-0.10.0.crate", + "sha256": "af76ceb17b2869be23598baef40a9c8db4de86b87c60510c3bc245559275a617", + "dest": "cargo/vendor/vello-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"af76ceb17b2869be23598baef40a9c8db4de86b87c60510c3bc245559275a617\", \"files\": {}}", + "dest": "cargo/vendor/vello-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vello_common/vello_common-0.1.0.crate", + "sha256": "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2", + "dest": "cargo/vendor/vello_common-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2\", \"files\": {}}", + "dest": "cargo/vendor/vello_common-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vello_cpu/vello_cpu-0.1.0.crate", + "sha256": "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea", + "dest": "cargo/vendor/vello_cpu-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea\", \"files\": {}}", + "dest": "cargo/vendor/vello_cpu-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vello_encoding/vello_encoding-0.10.0.crate", + "sha256": "1e31cd622201690d8dfe9fd8fea8d1ae59db1bfeea414856a617d1d03438418c", + "dest": "cargo/vendor/vello_encoding-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e31cd622201690d8dfe9fd8fea8d1ae59db1bfeea414856a617d1d03438418c\", \"files\": {}}", + "dest": "cargo/vendor/vello_encoding-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vello_shaders/vello_shaders-0.10.0.crate", + "sha256": "abf943bd2920bfd22928a9c1bad39866f7ffcc1109b6ed924ab52773e3868a83", + "dest": "cargo/vendor/vello_shaders-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"abf943bd2920bfd22928a9c1bad39866f7ffcc1109b6ed924ab52773e3868a83\", \"files\": {}}", + "dest": "cargo/vendor/vello_shaders-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/version-compare/version-compare-0.2.1.crate", + "sha256": "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e", + "dest": "cargo/vendor/version-compare-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e\", \"files\": {}}", + "dest": "cargo/vendor/version-compare-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/version_check/version_check-0.9.5.crate", + "sha256": "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a", + "dest": "cargo/vendor/version_check-0.9.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a\", \"files\": {}}", + "dest": "cargo/vendor/version_check-0.9.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/void/void-1.0.2.crate", + "sha256": "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d", + "dest": "cargo/vendor/void-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d\", \"files\": {}}", + "dest": "cargo/vendor/void-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vtable/vtable-0.4.0.crate", + "sha256": "5ea953f65593feb5287b022b83c35046d1ff49c7417b6f5851a079d8a231fe31", + "dest": "cargo/vendor/vtable-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5ea953f65593feb5287b022b83c35046d1ff49c7417b6f5851a079d8a231fe31\", \"files\": {}}", + "dest": "cargo/vendor/vtable-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vtable-macro/vtable-macro-0.4.0.crate", + "sha256": "482a37f9777480673d3a99c80ac2edc69f92382230440946855fa27ebc47cba6", + "dest": "cargo/vendor/vtable-macro-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"482a37f9777480673d3a99c80ac2edc69f92382230440946855fa27ebc47cba6\", \"files\": {}}", + "dest": "cargo/vendor/vtable-macro-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/walkdir/walkdir-2.5.0.crate", + "sha256": "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b", + "dest": "cargo/vendor/walkdir-2.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b\", \"files\": {}}", + "dest": "cargo/vendor/walkdir-2.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/want/want-0.3.1.crate", + "sha256": "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e", + "dest": "cargo/vendor/want-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e\", \"files\": {}}", + "dest": "cargo/vendor/want-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasi/wasi-0.11.1+wasi-snapshot-preview1.crate", + "sha256": "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b", + "dest": "cargo/vendor/wasi-0.11.1+wasi-snapshot-preview1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b\", \"files\": {}}", + "dest": "cargo/vendor/wasi-0.11.1+wasi-snapshot-preview1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasi/wasi-0.14.7+wasi-0.2.4.crate", + "sha256": "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c", + "dest": "cargo/vendor/wasi-0.14.7+wasi-0.2.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c\", \"files\": {}}", + "dest": "cargo/vendor/wasi-0.14.7+wasi-0.2.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasip2/wasip2-1.0.4+wasi-0.2.12.crate", + "sha256": "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487", + "dest": "cargo/vendor/wasip2-1.0.4+wasi-0.2.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487\", \"files\": {}}", + "dest": "cargo/vendor/wasip2-1.0.4+wasi-0.2.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasite/wasite-1.0.2.crate", + "sha256": "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42", + "dest": "cargo/vendor/wasite-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42\", \"files\": {}}", + "dest": "cargo/vendor/wasite-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.127.crate", + "sha256": "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70", + "dest": "cargo/vendor/wasm-bindgen-0.2.127" + }, + { + "type": "inline", + "contents": "{\"package\": \"1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-0.2.127", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.77.crate", + "sha256": "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950", + "dest": "cargo/vendor/wasm-bindgen-futures-0.4.77" + }, + { + "type": "inline", + "contents": "{\"package\": \"6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-futures-0.4.77", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.127.crate", + "sha256": "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1", + "dest": "cargo/vendor/wasm-bindgen-macro-0.2.127" + }, + { + "type": "inline", + "contents": "{\"package\": \"77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-macro-0.2.127", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.127.crate", + "sha256": "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284", + "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.127" + }, + { + "type": "inline", + "contents": "{\"package\": \"e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.127", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.127.crate", + "sha256": "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf", + "dest": "cargo/vendor/wasm-bindgen-shared-0.2.127" + }, + { + "type": "inline", + "contents": "{\"package\": \"7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-shared-0.2.127", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-streams/wasm-streams-0.5.0.crate", + "sha256": "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb", + "dest": "cargo/vendor/wasm-streams-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb\", \"files\": {}}", + "dest": "cargo/vendor/wasm-streams-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-backend/wayland-backend-0.3.17.crate", + "sha256": "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078", + "dest": "cargo/vendor/wayland-backend-0.3.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078\", \"files\": {}}", + "dest": "cargo/vendor/wayland-backend-0.3.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-client/wayland-client-0.31.15.crate", + "sha256": "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073", + "dest": "cargo/vendor/wayland-client-0.31.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073\", \"files\": {}}", + "dest": "cargo/vendor/wayland-client-0.31.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-csd-frame/wayland-csd-frame-0.3.0.crate", + "sha256": "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e", + "dest": "cargo/vendor/wayland-csd-frame-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e\", \"files\": {}}", + "dest": "cargo/vendor/wayland-csd-frame-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-cursor/wayland-cursor-0.31.14.crate", + "sha256": "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d", + "dest": "cargo/vendor/wayland-cursor-0.31.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d\", \"files\": {}}", + "dest": "cargo/vendor/wayland-cursor-0.31.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-protocols/wayland-protocols-0.32.13.crate", + "sha256": "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6", + "dest": "cargo/vendor/wayland-protocols-0.32.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6\", \"files\": {}}", + "dest": "cargo/vendor/wayland-protocols-0.32.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-protocols-experimental/wayland-protocols-experimental-20250721.0.1.crate", + "sha256": "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1", + "dest": "cargo/vendor/wayland-protocols-experimental-20250721.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1\", \"files\": {}}", + "dest": "cargo/vendor/wayland-protocols-experimental-20250721.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-protocols-misc/wayland-protocols-misc-0.3.12.crate", + "sha256": "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8", + "dest": "cargo/vendor/wayland-protocols-misc-0.3.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8\", \"files\": {}}", + "dest": "cargo/vendor/wayland-protocols-misc-0.3.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-protocols-plasma/wayland-protocols-plasma-0.3.12.crate", + "sha256": "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91", + "dest": "cargo/vendor/wayland-protocols-plasma-0.3.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91\", \"files\": {}}", + "dest": "cargo/vendor/wayland-protocols-plasma-0.3.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-protocols-wlr/wayland-protocols-wlr-0.3.12.crate", + "sha256": "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234", + "dest": "cargo/vendor/wayland-protocols-wlr-0.3.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234\", \"files\": {}}", + "dest": "cargo/vendor/wayland-protocols-wlr-0.3.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-scanner/wayland-scanner-0.31.11.crate", + "sha256": "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0", + "dest": "cargo/vendor/wayland-scanner-0.31.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0\", \"files\": {}}", + "dest": "cargo/vendor/wayland-scanner-0.31.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wayland-sys/wayland-sys-0.31.11.crate", + "sha256": "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be", + "dest": "cargo/vendor/wayland-sys-0.31.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be\", \"files\": {}}", + "dest": "cargo/vendor/wayland-sys-0.31.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/web-sys/web-sys-0.3.104.crate", + "sha256": "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30", + "dest": "cargo/vendor/web-sys-0.3.104" + }, + { + "type": "inline", + "contents": "{\"package\": \"c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30\", \"files\": {}}", + "dest": "cargo/vendor/web-sys-0.3.104", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/web-time/web-time-1.1.0.crate", + "sha256": "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb", + "dest": "cargo/vendor/web-time-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb\", \"files\": {}}", + "dest": "cargo/vendor/web-time-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/web_atoms/web_atoms-0.2.6.crate", + "sha256": "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3", + "dest": "cargo/vendor/web_atoms-0.2.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3\", \"files\": {}}", + "dest": "cargo/vendor/web_atoms-0.2.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/webbrowser/webbrowser-1.2.4.crate", + "sha256": "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94", + "dest": "cargo/vendor/webbrowser-1.2.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94\", \"files\": {}}", + "dest": "cargo/vendor/webbrowser-1.2.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/webpki-root-certs/webpki-root-certs-1.0.9.crate", + "sha256": "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b", + "dest": "cargo/vendor/webpki-root-certs-1.0.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b\", \"files\": {}}", + "dest": "cargo/vendor/webpki-root-certs-1.0.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/webpki-roots/webpki-roots-1.0.9.crate", + "sha256": "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a", + "dest": "cargo/vendor/webpki-roots-1.0.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a\", \"files\": {}}", + "dest": "cargo/vendor/webpki-roots-1.0.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/weezl/weezl-0.1.12.crate", + "sha256": "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88", + "dest": "cargo/vendor/weezl-0.1.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88\", \"files\": {}}", + "dest": "cargo/vendor/weezl-0.1.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu/wgpu-29.0.4.crate", + "sha256": "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07", + "dest": "cargo/vendor/wgpu-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07\", \"files\": {}}", + "dest": "cargo/vendor/wgpu-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu-core/wgpu-core-29.0.4.crate", + "sha256": "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7", + "dest": "cargo/vendor/wgpu-core-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7\", \"files\": {}}", + "dest": "cargo/vendor/wgpu-core-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu-core-deps-apple/wgpu-core-deps-apple-29.0.4.crate", + "sha256": "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3", + "dest": "cargo/vendor/wgpu-core-deps-apple-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3\", \"files\": {}}", + "dest": "cargo/vendor/wgpu-core-deps-apple-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu-core-deps-emscripten/wgpu-core-deps-emscripten-29.0.4.crate", + "sha256": "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af", + "dest": "cargo/vendor/wgpu-core-deps-emscripten-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af\", \"files\": {}}", + "dest": "cargo/vendor/wgpu-core-deps-emscripten-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu-core-deps-windows-linux-android/wgpu-core-deps-windows-linux-android-29.0.4.crate", + "sha256": "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257", + "dest": "cargo/vendor/wgpu-core-deps-windows-linux-android-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257\", \"files\": {}}", + "dest": "cargo/vendor/wgpu-core-deps-windows-linux-android-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu-hal/wgpu-hal-29.0.4.crate", + "sha256": "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d", + "dest": "cargo/vendor/wgpu-hal-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d\", \"files\": {}}", + "dest": "cargo/vendor/wgpu-hal-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu-naga-bridge/wgpu-naga-bridge-29.0.4.crate", + "sha256": "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161", + "dest": "cargo/vendor/wgpu-naga-bridge-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161\", \"files\": {}}", + "dest": "cargo/vendor/wgpu-naga-bridge-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu-types/wgpu-types-29.0.4.crate", + "sha256": "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5", + "dest": "cargo/vendor/wgpu-types-29.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5\", \"files\": {}}", + "dest": "cargo/vendor/wgpu-types-29.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wgpu_context/wgpu_context-0.9.0.crate", + "sha256": "7d6c2553d5b114dfd941fa3372c7df1e1cde7bd81800e89aa13b7e3af2e4be9b", + "dest": "cargo/vendor/wgpu_context-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7d6c2553d5b114dfd941fa3372c7df1e1cde7bd81800e89aa13b7e3af2e4be9b\", \"files\": {}}", + "dest": "cargo/vendor/wgpu_context-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/whoami/whoami-2.1.3.crate", + "sha256": "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c", + "dest": "cargo/vendor/whoami-2.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c\", \"files\": {}}", + "dest": "cargo/vendor/whoami-2.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winapi/winapi-0.3.9.crate", + "sha256": "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419", + "dest": "cargo/vendor/winapi-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419\", \"files\": {}}", + "dest": "cargo/vendor/winapi-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winapi-i686-pc-windows-gnu/winapi-i686-pc-windows-gnu-0.4.0.crate", + "sha256": "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6", + "dest": "cargo/vendor/winapi-i686-pc-windows-gnu-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6\", \"files\": {}}", + "dest": "cargo/vendor/winapi-i686-pc-windows-gnu-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winapi-util/winapi-util-0.1.11.crate", + "sha256": "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22", + "dest": "cargo/vendor/winapi-util-0.1.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22\", \"files\": {}}", + "dest": "cargo/vendor/winapi-util-0.1.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winapi-x86_64-pc-windows-gnu/winapi-x86_64-pc-windows-gnu-0.4.0.crate", + "sha256": "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f", + "dest": "cargo/vendor/winapi-x86_64-pc-windows-gnu-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f\", \"files\": {}}", + "dest": "cargo/vendor/winapi-x86_64-pc-windows-gnu-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows/windows-0.62.2.crate", + "sha256": "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580", + "dest": "cargo/vendor/windows-0.62.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580\", \"files\": {}}", + "dest": "cargo/vendor/windows-0.62.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-collections/windows-collections-0.3.2.crate", + "sha256": "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610", + "dest": "cargo/vendor/windows-collections-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610\", \"files\": {}}", + "dest": "cargo/vendor/windows-collections-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-core/windows-core-0.62.2.crate", + "sha256": "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb", + "dest": "cargo/vendor/windows-core-0.62.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb\", \"files\": {}}", + "dest": "cargo/vendor/windows-core-0.62.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-future/windows-future-0.3.2.crate", + "sha256": "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb", + "dest": "cargo/vendor/windows-future-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb\", \"files\": {}}", + "dest": "cargo/vendor/windows-future-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-implement/windows-implement-0.60.2.crate", + "sha256": "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf", + "dest": "cargo/vendor/windows-implement-0.60.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf\", \"files\": {}}", + "dest": "cargo/vendor/windows-implement-0.60.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-interface/windows-interface-0.59.3.crate", + "sha256": "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358", + "dest": "cargo/vendor/windows-interface-0.59.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358\", \"files\": {}}", + "dest": "cargo/vendor/windows-interface-0.59.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-link/windows-link-0.2.1.crate", + "sha256": "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5", + "dest": "cargo/vendor/windows-link-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5\", \"files\": {}}", + "dest": "cargo/vendor/windows-link-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-numerics/windows-numerics-0.3.1.crate", + "sha256": "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26", + "dest": "cargo/vendor/windows-numerics-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26\", \"files\": {}}", + "dest": "cargo/vendor/windows-numerics-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-registry/windows-registry-0.6.1.crate", + "sha256": "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720", + "dest": "cargo/vendor/windows-registry-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720\", \"files\": {}}", + "dest": "cargo/vendor/windows-registry-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-result/windows-result-0.4.1.crate", + "sha256": "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5", + "dest": "cargo/vendor/windows-result-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5\", \"files\": {}}", + "dest": "cargo/vendor/windows-result-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-strings/windows-strings-0.5.1.crate", + "sha256": "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091", + "dest": "cargo/vendor/windows-strings-0.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091\", \"files\": {}}", + "dest": "cargo/vendor/windows-strings-0.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.48.0.crate", + "sha256": "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9", + "dest": "cargo/vendor/windows-sys-0.48.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.48.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.52.0.crate", + "sha256": "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d", + "dest": "cargo/vendor/windows-sys-0.52.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.52.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.59.0.crate", + "sha256": "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b", + "dest": "cargo/vendor/windows-sys-0.59.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.59.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.60.2.crate", + "sha256": "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb", + "dest": "cargo/vendor/windows-sys-0.60.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.60.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.61.2.crate", + "sha256": "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc", + "dest": "cargo/vendor/windows-sys-0.61.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.61.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-targets/windows-targets-0.48.5.crate", + "sha256": "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c", + "dest": "cargo/vendor/windows-targets-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c\", \"files\": {}}", + "dest": "cargo/vendor/windows-targets-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-targets/windows-targets-0.52.6.crate", + "sha256": "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973", + "dest": "cargo/vendor/windows-targets-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973\", \"files\": {}}", + "dest": "cargo/vendor/windows-targets-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-targets/windows-targets-0.53.5.crate", + "sha256": "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3", + "dest": "cargo/vendor/windows-targets-0.53.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3\", \"files\": {}}", + "dest": "cargo/vendor/windows-targets-0.53.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-threading/windows-threading-0.2.1.crate", + "sha256": "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37", + "dest": "cargo/vendor/windows-threading-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37\", \"files\": {}}", + "dest": "cargo/vendor/windows-threading-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.48.5.crate", + "sha256": "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.52.6.crate", + "sha256": "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.53.1.crate", + "sha256": "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.48.5.crate", + "sha256": "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc", + "dest": "cargo/vendor/windows_aarch64_msvc-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_msvc-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.52.6.crate", + "sha256": "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469", + "dest": "cargo/vendor/windows_aarch64_msvc-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_msvc-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.53.1.crate", + "sha256": "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006", + "dest": "cargo/vendor/windows_aarch64_msvc-0.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_msvc-0.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.48.5.crate", + "sha256": "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e", + "dest": "cargo/vendor/windows_i686_gnu-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnu-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.52.6.crate", + "sha256": "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b", + "dest": "cargo/vendor/windows_i686_gnu-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnu-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.53.1.crate", + "sha256": "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3", + "dest": "cargo/vendor/windows_i686_gnu-0.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnu-0.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnullvm/windows_i686_gnullvm-0.52.6.crate", + "sha256": "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66", + "dest": "cargo/vendor/windows_i686_gnullvm-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnullvm-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnullvm/windows_i686_gnullvm-0.53.1.crate", + "sha256": "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c", + "dest": "cargo/vendor/windows_i686_gnullvm-0.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnullvm-0.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.48.5.crate", + "sha256": "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406", + "dest": "cargo/vendor/windows_i686_msvc-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_msvc-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.52.6.crate", + "sha256": "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66", + "dest": "cargo/vendor/windows_i686_msvc-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_msvc-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.53.1.crate", + "sha256": "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2", + "dest": "cargo/vendor/windows_i686_msvc-0.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_msvc-0.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.48.5.crate", + "sha256": "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e", + "dest": "cargo/vendor/windows_x86_64_gnu-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnu-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.52.6.crate", + "sha256": "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78", + "dest": "cargo/vendor/windows_x86_64_gnu-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnu-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.53.1.crate", + "sha256": "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499", + "dest": "cargo/vendor/windows_x86_64_gnu-0.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnu-0.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.48.5.crate", + "sha256": "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.52.6.crate", + "sha256": "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.53.1.crate", + "sha256": "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.48.5.crate", + "sha256": "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538", + "dest": "cargo/vendor/windows_x86_64_msvc-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_msvc-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.52.6.crate", + "sha256": "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec", + "dest": "cargo/vendor/windows_x86_64_msvc-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_msvc-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.53.1.crate", + "sha256": "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650", + "dest": "cargo/vendor/windows_x86_64_msvc-0.53.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_msvc-0.53.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winit/winit-0.30.13.crate", + "sha256": "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d", + "dest": "cargo/vendor/winit-0.30.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d\", \"files\": {}}", + "dest": "cargo/vendor/winit-0.30.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winnow/winnow-0.7.15.crate", + "sha256": "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945", + "dest": "cargo/vendor/winnow-0.7.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945\", \"files\": {}}", + "dest": "cargo/vendor/winnow-0.7.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winnow/winnow-1.0.4.crate", + "sha256": "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81", + "dest": "cargo/vendor/winnow-1.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81\", \"files\": {}}", + "dest": "cargo/vendor/winnow-1.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winresource/winresource-0.1.31.crate", + "sha256": "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087", + "dest": "cargo/vendor/winresource-0.1.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087\", \"files\": {}}", + "dest": "cargo/vendor/winresource-0.1.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wit-bindgen/wit-bindgen-0.57.1.crate", + "sha256": "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e", + "dest": "cargo/vendor/wit-bindgen-0.57.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e\", \"files\": {}}", + "dest": "cargo/vendor/wit-bindgen-0.57.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wl-clipboard-rs/wl-clipboard-rs-0.9.3.crate", + "sha256": "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3", + "dest": "cargo/vendor/wl-clipboard-rs-0.9.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3\", \"files\": {}}", + "dest": "cargo/vendor/wl-clipboard-rs-0.9.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/write-fonts/write-fonts-0.48.1.crate", + "sha256": "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8", + "dest": "cargo/vendor/write-fonts-0.48.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8\", \"files\": {}}", + "dest": "cargo/vendor/write-fonts-0.48.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/writeable/writeable-0.6.4.crate", + "sha256": "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc", + "dest": "cargo/vendor/writeable-0.6.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc\", \"files\": {}}", + "dest": "cargo/vendor/writeable-0.6.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wuff/wuff-0.2.8.crate", + "sha256": "f625c6894838ab68e8c1c6e0f728e67363c9d4391cfa82333e58e6e30bc3a627", + "dest": "cargo/vendor/wuff-0.2.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"f625c6894838ab68e8c1c6e0f728e67363c9d4391cfa82333e58e6e30bc3a627\", \"files\": {}}", + "dest": "cargo/vendor/wuff-0.2.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/x11-clipboard/x11-clipboard-0.9.3.crate", + "sha256": "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3", + "dest": "cargo/vendor/x11-clipboard-0.9.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3\", \"files\": {}}", + "dest": "cargo/vendor/x11-clipboard-0.9.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/x11-dl/x11-dl-2.21.0.crate", + "sha256": "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f", + "dest": "cargo/vendor/x11-dl-2.21.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f\", \"files\": {}}", + "dest": "cargo/vendor/x11-dl-2.21.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/x11rb/x11rb-0.13.2.crate", + "sha256": "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414", + "dest": "cargo/vendor/x11rb-0.13.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414\", \"files\": {}}", + "dest": "cargo/vendor/x11rb-0.13.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/x11rb-protocol/x11rb-protocol-0.13.2.crate", + "sha256": "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd", + "dest": "cargo/vendor/x11rb-protocol-0.13.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd\", \"files\": {}}", + "dest": "cargo/vendor/x11rb-protocol-0.13.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xattr/xattr-1.6.1.crate", + "sha256": "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156", + "dest": "cargo/vendor/xattr-1.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156\", \"files\": {}}", + "dest": "cargo/vendor/xattr-1.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xcursor/xcursor-0.3.11.crate", + "sha256": "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23", + "dest": "cargo/vendor/xcursor-0.3.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23\", \"files\": {}}", + "dest": "cargo/vendor/xcursor-0.3.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xet-client/xet-client-1.6.0.crate", + "sha256": "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca", + "dest": "cargo/vendor/xet-client-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca\", \"files\": {}}", + "dest": "cargo/vendor/xet-client-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xet-core-structures/xet-core-structures-1.6.0.crate", + "sha256": "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99", + "dest": "cargo/vendor/xet-core-structures-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99\", \"files\": {}}", + "dest": "cargo/vendor/xet-core-structures-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xet-data/xet-data-1.6.0.crate", + "sha256": "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb", + "dest": "cargo/vendor/xet-data-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb\", \"files\": {}}", + "dest": "cargo/vendor/xet-data-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xet-runtime/xet-runtime-1.6.0.crate", + "sha256": "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b", + "dest": "cargo/vendor/xet-runtime-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b\", \"files\": {}}", + "dest": "cargo/vendor/xet-runtime-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xkbcommon/xkbcommon-0.9.0.crate", + "sha256": "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d", + "dest": "cargo/vendor/xkbcommon-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d\", \"files\": {}}", + "dest": "cargo/vendor/xkbcommon-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xkbcommon-dl/xkbcommon-dl-0.4.2.crate", + "sha256": "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5", + "dest": "cargo/vendor/xkbcommon-dl-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5\", \"files\": {}}", + "dest": "cargo/vendor/xkbcommon-dl-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xkeysym/xkeysym-0.2.1.crate", + "sha256": "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56", + "dest": "cargo/vendor/xkeysym-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56\", \"files\": {}}", + "dest": "cargo/vendor/xkeysym-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xml-rs/xml-rs-0.8.29.crate", + "sha256": "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7", + "dest": "cargo/vendor/xml-rs-0.8.29" + }, + { + "type": "inline", + "contents": "{\"package\": \"e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7\", \"files\": {}}", + "dest": "cargo/vendor/xml-rs-0.8.29", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xml5ever/xml5ever-0.39.0.crate", + "sha256": "5ab627f34ff61b80d756180d556f9c68801d836d271b3b8c094504ceca69d221", + "dest": "cargo/vendor/xml5ever-0.39.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5ab627f34ff61b80d756180d556f9c68801d836d271b3b8c094504ceca69d221\", \"files\": {}}", + "dest": "cargo/vendor/xml5ever-0.39.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xmlwriter/xmlwriter-0.1.0.crate", + "sha256": "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9", + "dest": "cargo/vendor/xmlwriter-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9\", \"files\": {}}", + "dest": "cargo/vendor/xmlwriter-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/yazi/yazi-0.2.1.crate", + "sha256": "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5", + "dest": "cargo/vendor/yazi-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5\", \"files\": {}}", + "dest": "cargo/vendor/yazi-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/yeslogic-fontconfig-sys/yeslogic-fontconfig-sys-6.0.1.crate", + "sha256": "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76", + "dest": "cargo/vendor/yeslogic-fontconfig-sys-6.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76\", \"files\": {}}", + "dest": "cargo/vendor/yeslogic-fontconfig-sys-6.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/yoke/yoke-0.8.3.crate", + "sha256": "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5", + "dest": "cargo/vendor/yoke-0.8.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5\", \"files\": {}}", + "dest": "cargo/vendor/yoke-0.8.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/yoke-derive/yoke-derive-0.8.2.crate", + "sha256": "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e", + "dest": "cargo/vendor/yoke-derive-0.8.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e\", \"files\": {}}", + "dest": "cargo/vendor/yoke-derive-0.8.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zbus/zbus-5.19.0.crate", + "sha256": "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907", + "dest": "cargo/vendor/zbus-5.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907\", \"files\": {}}", + "dest": "cargo/vendor/zbus-5.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zbus_macros/zbus_macros-5.19.0.crate", + "sha256": "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40", + "dest": "cargo/vendor/zbus_macros-5.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40\", \"files\": {}}", + "dest": "cargo/vendor/zbus_macros-5.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zbus_names/zbus_names-4.3.4.crate", + "sha256": "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e", + "dest": "cargo/vendor/zbus_names-4.3.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e\", \"files\": {}}", + "dest": "cargo/vendor/zbus_names-4.3.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zcheapstr/zcheapstr-1.1.0.crate", + "sha256": "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473", + "dest": "cargo/vendor/zcheapstr-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473\", \"files\": {}}", + "dest": "cargo/vendor/zcheapstr-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zeno/zeno-0.3.3.crate", + "sha256": "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524", + "dest": "cargo/vendor/zeno-0.3.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524\", \"files\": {}}", + "dest": "cargo/vendor/zeno-0.3.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.56.crate", + "sha256": "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb", + "dest": "cargo/vendor/zerocopy-0.8.56" + }, + { + "type": "inline", + "contents": "{\"package\": \"556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb\", \"files\": {}}", + "dest": "cargo/vendor/zerocopy-0.8.56", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.56.crate", + "sha256": "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1", + "dest": "cargo/vendor/zerocopy-derive-0.8.56" + }, + { + "type": "inline", + "contents": "{\"package\": \"f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1\", \"files\": {}}", + "dest": "cargo/vendor/zerocopy-derive-0.8.56", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.8.crate", + "sha256": "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272", + "dest": "cargo/vendor/zerofrom-0.1.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272\", \"files\": {}}", + "dest": "cargo/vendor/zerofrom-0.1.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerofrom-derive/zerofrom-derive-0.1.7.crate", + "sha256": "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1", + "dest": "cargo/vendor/zerofrom-derive-0.1.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1\", \"files\": {}}", + "dest": "cargo/vendor/zerofrom-derive-0.1.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zeroize/zeroize-1.9.0.crate", + "sha256": "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e", + "dest": "cargo/vendor/zeroize-1.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e\", \"files\": {}}", + "dest": "cargo/vendor/zeroize-1.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zeroize_derive/zeroize_derive-1.5.0.crate", + "sha256": "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328", + "dest": "cargo/vendor/zeroize_derive-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328\", \"files\": {}}", + "dest": "cargo/vendor/zeroize_derive-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerotrie/zerotrie-0.2.5.crate", + "sha256": "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f", + "dest": "cargo/vendor/zerotrie-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f\", \"files\": {}}", + "dest": "cargo/vendor/zerotrie-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerovec/zerovec-0.11.8.crate", + "sha256": "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8", + "dest": "cargo/vendor/zerovec-0.11.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8\", \"files\": {}}", + "dest": "cargo/vendor/zerovec-0.11.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerovec-derive/zerovec-derive-0.11.6.crate", + "sha256": "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da", + "dest": "cargo/vendor/zerovec-derive-0.11.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da\", \"files\": {}}", + "dest": "cargo/vendor/zerovec-derive-0.11.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zip/zip-8.6.0.crate", + "sha256": "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b", + "dest": "cargo/vendor/zip-8.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b\", \"files\": {}}", + "dest": "cargo/vendor/zip-8.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zlib-rs/zlib-rs-0.6.7.crate", + "sha256": "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12", + "dest": "cargo/vendor/zlib-rs-0.6.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12\", \"files\": {}}", + "dest": "cargo/vendor/zlib-rs-0.6.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zmij/zmij-1.0.23.crate", + "sha256": "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b", + "dest": "cargo/vendor/zmij-1.0.23" + }, + { + "type": "inline", + "contents": "{\"package\": \"29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b\", \"files\": {}}", + "dest": "cargo/vendor/zmij-1.0.23", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zune-core/zune-core-0.4.12.crate", + "sha256": "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a", + "dest": "cargo/vendor/zune-core-0.4.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a\", \"files\": {}}", + "dest": "cargo/vendor/zune-core-0.4.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zune-core/zune-core-0.5.3.crate", + "sha256": "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b", + "dest": "cargo/vendor/zune-core-0.5.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b\", \"files\": {}}", + "dest": "cargo/vendor/zune-core-0.5.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zune-inflate/zune-inflate-0.2.54.crate", + "sha256": "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02", + "dest": "cargo/vendor/zune-inflate-0.2.54" + }, + { + "type": "inline", + "contents": "{\"package\": \"73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02\", \"files\": {}}", + "dest": "cargo/vendor/zune-inflate-0.2.54", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zune-jpeg/zune-jpeg-0.4.21.crate", + "sha256": "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713", + "dest": "cargo/vendor/zune-jpeg-0.4.21" + }, + { + "type": "inline", + "contents": "{\"package\": \"29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713\", \"files\": {}}", + "dest": "cargo/vendor/zune-jpeg-0.4.21", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zune-jpeg/zune-jpeg-0.5.15.crate", + "sha256": "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296", + "dest": "cargo/vendor/zune-jpeg-0.5.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296\", \"files\": {}}", + "dest": "cargo/vendor/zune-jpeg-0.5.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zvariant/zvariant-5.15.0.crate", + "sha256": "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147", + "dest": "cargo/vendor/zvariant-5.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147\", \"files\": {}}", + "dest": "cargo/vendor/zvariant-5.15.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zvariant_derive/zvariant_derive-5.15.0.crate", + "sha256": "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497", + "dest": "cargo/vendor/zvariant_derive-5.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497\", \"files\": {}}", + "dest": "cargo/vendor/zvariant_derive-5.15.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zvariant_utils/zvariant_utils-4.2.0.crate", + "sha256": "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3", + "dest": "cargo/vendor/zvariant_utils-4.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3\", \"files\": {}}", + "dest": "cargo/vendor/zvariant_utils-4.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "inline", + "contents": "[source.vendored-sources]\ndirectory = \"cargo/vendor\"\n\n[source.crates-io]\nreplace-with = \"vendored-sources\"\n", + "dest": "cargo", + "dest-filename": "config" + } +] \ No newline at end of file diff --git a/packaging/flatpak/com.flectar.mail.yml b/packaging/flatpak/com.flectar.mail.yml new file mode 100644 index 0000000..91291e8 --- /dev/null +++ b/packaging/flatpak/com.flectar.mail.yml @@ -0,0 +1,78 @@ +app-id: com.flectar.mail +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable +command: flectar-mail +separate-locales: false + +# The app already uses portals for browser and file-picker requests. Credential +# storage uses the freedesktop Secret Service. The Flatpak feature disables the +# legacy StatusNotifier tray backend so the sandbox needs no unsafe D-Bus name. +finish-args: + - --share=ipc + - --share=network + - --socket=wayland + - --socket=fallback-x11 + - --talk-name=org.freedesktop.secrets + +cleanup: + - /include + - /lib/pkgconfig + - /share/pkgconfig + - /share/man + - '*.a' + - '*.la' + +modules: + - name: flectar-mail + buildsystem: simple + build-options: + append-path: /usr/lib/sdk/rust-stable/bin + env: + CARGO_BUILD_JOBS: '1' + CARGO_HOME: /run/build/flectar-mail/cargo + CARGO_NET_OFFLINE: 'true' + build-commands: + - cargo build --offline --locked --release --no-default-features --features remote-content,flatpak --bin flectar-mail + - install -Dm0755 target/release/flectar-mail /app/bin/flectar-mail + - install -Dm0755 pdfium-runtime/lib/libpdfium.so /app/lib/flectar-mail/libpdfium.so + - python3 scripts/test-pdf-preview.py /app/bin/flectar-mail + - python3 scripts/stage-linux-metadata.py --flatpak _flatpak-stage + - install -Dm0644 _flatpak-stage/usr/share/applications/com.flectar.mail.desktop /app/share/applications/com.flectar.mail.desktop + - install -Dm0644 _flatpak-stage/usr/share/metainfo/com.flectar.mail.metainfo.xml /app/share/metainfo/com.flectar.mail.metainfo.xml + - install -Dm0644 resources/app-icon/flectar-mail-masked-512.png /app/share/icons/hicolor/512x512/apps/com.flectar.mail.png + - install -Dm0644 resources/app-icon/flectar-mail-masked.svg /app/share/icons/hicolor/scalable/apps/com.flectar.mail.svg + - install -Dm0644 LICENSE /app/share/licenses/flectar-mail/LICENSE + - cp -a LICENSES /app/share/licenses/flectar-mail/LICENSES + - install -Dm0644 THIRD_PARTY_NOTICES.md /app/share/doc/flectar-mail/THIRD_PARTY_NOTICES.md + - install -Dm0644 resources/fonts/google-sans-flex/OFL.txt /app/share/licenses/flectar-mail/google-sans-flex/OFL.txt + - install -Dm0644 resources/fonts/google-sans-flex/README.md /app/share/doc/flectar-mail/google-sans-flex/README.md + - mkdir -p /app/share/licenses/flectar-mail/pdfium + - cp -a pdfium-runtime/LICENSE pdfium-runtime/licenses /app/share/licenses/flectar-mail/pdfium/ + sources: + # Package the exact checkout selected by the release workflow. + - type: dir + path: ../.. + skip: + - .artifacts + - .devcontainer + - .env + - .flatpak-builder + - .git + - .idea + - .Trash-* + - .vscode + - libs + - target + - tmp + - cargo-sources.json + # Include PDF preview in the standalone alpha bundle. + - type: archive + url: https://github.com/bblanchon/pdfium-binaries/releases/download/chromium%2F8044/pdfium-linux-x64.tgz + sha256: eb142f416aed3a72fc5a02dbd5884868a16cb99dc0cf53e6bdd64afbf67b05f4 + dest: pdfium-runtime + strip-components: 0 + only-arches: + - x86_64 diff --git a/packaging/flatpak/flathub.json b/packaging/flatpak/flathub.json new file mode 100644 index 0000000..0788b52 --- /dev/null +++ b/packaging/flatpak/flathub.json @@ -0,0 +1,5 @@ +{ + "only-arches": [ + "x86_64" + ] +} diff --git a/platform/android/Cargo.lock b/platform/android/Cargo.lock index b13a7ed..277caa6 100644 --- a/platform/android/Cargo.lock +++ b/platform/android/Cargo.lock @@ -961,9 +961,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1072,6 +1072,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1" +dependencies = [ + "log", + "web-sys", +] + [[package]] name = "const-field-offset" version = "0.2.1" @@ -1823,16 +1843,18 @@ dependencies = [ [[package]] name = "flectar-mail" -version = "0.1.0-alpha.4" +version = "0.1.0-alpha.5" dependencies = [ "anyrender", "anyrender_vello_cpu", "arboard", + "ashpd", "base64 0.22.1", "blitz-dom", "blitz-html", "blitz-paint", "blitz-traits", + "cc", "chrono", "cosmic-text", "cssparser", @@ -1844,11 +1866,15 @@ dependencies = [ "html5ever", "image", "keyboard-types", + "landlock", + "libc", "parley 0.11.1", + "pdfium-render", "percent-encoding", "pulldown-cmark", "reqwest", "rfd", + "seccompiler", "serde", "serde_json", "sha2", @@ -1862,6 +1888,7 @@ dependencies = [ "unicode-segmentation", "url", "webbrowser", + "windows-sys 0.61.2", "winresource", ] @@ -1901,12 +1928,14 @@ dependencies = [ "mail-builder", "mail-parser", "once_cell", + "percent-encoding", "quick-xml 0.42.0", "rand 0.9.5", "regex", "reqwest", "ring", "rusqlite", + "rustix 1.1.4", "rustls", "serde", "serde_json", @@ -3555,6 +3584,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.20", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3843,6 +3883,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4661,6 +4707,31 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pdfium-render" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8948a803616a9e936b15a6637af2cd48c5fb8ae0fcdeb3c32eac3a540e255a19" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "bytes", + "chrono", + "console_error_panic_hook", + "console_log", + "itertools 0.14.0", + "js-sys", + "libloading", + "log", + "maybe-owned", + "once_cell", + "utf16string", + "vecmath", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "peniko" version = "0.6.1" @@ -4799,6 +4870,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "piston-float" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" + [[package]] name = "pkg-config" version = "0.3.34" @@ -5775,6 +5852,15 @@ dependencies = [ "tiny-skia 0.11.4", ] +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -7367,6 +7453,15 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" +dependencies = [ + "byteorder", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -7401,6 +7496,15 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vecmath" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +dependencies = [ + "piston-float", +] + [[package]] name = "vello_common" version = "0.1.0" diff --git a/platform/android/gradle/app/build.gradle.kts b/platform/android/gradle/app/build.gradle.kts index afdd912..9c17540 100644 --- a/platform/android/gradle/app/build.gradle.kts +++ b/platform/android/gradle/app/build.gradle.kts @@ -26,6 +26,10 @@ android { layout.buildDirectory.dir("generated/flectar-assets"), ) + // PDFium is loaded by its absolute installed nativeLibraryDir path. Let the + // package manager extract and protect it; no temporary executable files. + packaging.jniLibs.useLegacyPackaging = true + signingConfigs { create("releaseFromEnvironment") { val keystore = providers.environmentVariable("FLECTAR_ANDROID_KEYSTORE").orNull @@ -56,8 +60,28 @@ val generateFlectarLicenseAssets by tasks.registering(Copy::class) { } } +val generatePdfiumLicenseAssets by tasks.registering(Copy::class) { + into(layout.buildDirectory.dir("generated/flectar-assets/licenses/PDFium")) + from(repositoryAndroidTarget.dir("gradle-jni")) { + include("*/pdfium-licenses/**") + } +} +val verifyPdfiumRuntime by tasks.registering { + doLast { + fileTree(repositoryAndroidTarget.dir("gradle-jni")).matching { + include("*/libflectar_mail_android.so") + }.forEach { appLibrary -> + check(appLibrary.resolveSibling("libpdfium.so").isFile) { + "Missing bundled PDFium for ${appLibrary.parentFile.name}; run scripts/stage-pdfium.py for this ABI." + } + check(appLibrary.parentFile.resolve("pdfium-licenses/build.json").isFile) { + "Missing PDFium provenance and license notices." + } + } + } +} tasks.named("preBuild").configure { - dependsOn(generateFlectarLicenseAssets) + dependsOn(generateFlectarLicenseAssets, generatePdfiumLicenseAssets, verifyPdfiumRuntime) } dependencies { diff --git a/platform/android/gradle/app/src/main/java/com/flectar/mail/FlectarActivity.java b/platform/android/gradle/app/src/main/java/com/flectar/mail/FlectarActivity.java index a394375..8c0de56 100644 --- a/platform/android/gradle/app/src/main/java/com/flectar/mail/FlectarActivity.java +++ b/platform/android/gradle/app/src/main/java/com/flectar/mail/FlectarActivity.java @@ -1,5 +1,6 @@ package com.flectar.mail; +import java.io.File; import android.app.NativeActivity; import android.content.Intent; import android.content.IntentSender; @@ -24,6 +25,110 @@ /** NativeActivity host plus the supported Google AuthorizationClient bridge. */ public final class FlectarActivity extends NativeActivity { + private static native void nativeSuspendPdfPreview(); + + @Override + protected void onStop() { + nativeSuspendPdfPreview(); + super.onStop(); + } + + @Override + public void onTrimMemory(int level) { + super.onTrimMemory(level); + if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) { + nativeSuspendPdfPreview(); + } + } + + private static final int FILE_DOCUMENT_REQUEST = 0xF11E; + private long pendingDocumentRequest = -1; + private String pendingDocumentExport = ""; + private static native void nativeDocumentResult(long requestId, String path, String error); + private static native boolean nativeDocumentRequestActive(long requestId); + + public void cancelDocumentRequest(long requestId) { + runOnUiThread(() -> { + if (pendingDocumentRequest == requestId) { + // Retain the request ID until its result arrives. Reusing the + // activity result code earlier could misroute a late result. + finishActivity(FILE_DOCUMENT_REQUEST); + } + }); + } + + public void releaseImportedDocument(String path) { + try { + File root=new File(getCacheDir(),"file-imports").getCanonicalFile(); + File file=new File(path).getCanonicalFile(); + File folder=file.getParentFile(); + if(folder!=null && root.equals(folder.getParentFile()) && file.isFile()) { + if(file.delete())folder.delete(); + } + } catch(java.io.IOException ignored) { } + } + public void chooseDocument(long requestId, String exportPath, String name) { + runOnUiThread(() -> { + if (!nativeDocumentRequestActive(requestId)) return; + if (pendingDocumentRequest >= 0) {nativeDocumentResult(requestId, "", "Another document picker is open."); return;} + pendingDocumentRequest = requestId; + pendingDocumentExport = exportPath; + Intent intent = new Intent(exportPath.isEmpty() ? Intent.ACTION_OPEN_DOCUMENT : Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(exportPath.isEmpty() ? "*/*" : "application/octet-stream"); + if (!exportPath.isEmpty()) intent.putExtra(Intent.EXTRA_TITLE, name); + try {startActivityForResult(intent, FILE_DOCUMENT_REQUEST);} + catch (RuntimeException error) {pendingDocumentRequest=-1;pendingDocumentExport="";nativeDocumentResult(requestId,"","Could not open the system document picker.");} + }); + } + + private void finishDocument(int resultCode, Intent data) { + final long id=pendingDocumentRequest; + final String exportPath=pendingDocumentExport; + pendingDocumentRequest=-1;pendingDocumentExport=""; + if(id<0)return; + if(resultCode!=RESULT_OK||data==null||data.getData()==null){nativeDocumentResult(id,"","");return;} + final Uri uri=data.getData(); + new Thread(() -> { + java.io.File imported=null; + java.io.File importDirectory=null; + try { + if (!nativeDocumentRequestActive(id)) throw new java.io.IOException("Document transfer cancelled"); + if(exportPath.isEmpty()) { + String name="upload"; + try(android.database.Cursor cursor=getContentResolver().query(uri,new String[]{android.provider.OpenableColumns.DISPLAY_NAME},null,null,null)) { + if(cursor!=null&&cursor.moveToFirst())name=cursor.getString(0); + } + if(name==null||name.isEmpty()||name.equals(".")||name.equals("..")||name.contains("/")||name.contains("\\"))name="upload"; + java.io.File dir=new java.io.File(getCacheDir(),"file-imports/"+java.util.UUID.randomUUID()); + importDirectory=dir; + if(!dir.mkdirs())throw new java.io.IOException("Cannot create import directory"); + imported=new java.io.File(dir,name); + try(java.io.InputStream in=getContentResolver().openInputStream(uri);java.io.OutputStream out=new java.io.FileOutputStream(imported)) {copyDocument(id,in,out);} + nativeDocumentResult(id,imported.getAbsolutePath(),""); + } else { + java.io.File source=new java.io.File(exportPath).getCanonicalFile(); + // Only private application paths supplied by our Rust host. + if(!source.toPath().startsWith(getCacheDir().getCanonicalFile().toPath()) && !source.toPath().startsWith(getFilesDir().getCanonicalFile().toPath()))throw new java.io.IOException("Invalid export source"); + try(java.io.InputStream in=new java.io.FileInputStream(source);java.io.OutputStream out=getContentResolver().openOutputStream(uri,"w")){copyDocument(id,in,out);} + nativeDocumentResult(id,exportPath,""); + } + } catch(Exception error) { + if(imported!=null)imported.delete(); + if(importDirectory!=null)importDirectory.delete(); + if(!exportPath.isEmpty()) {try {android.provider.DocumentsContract.deleteDocument(getContentResolver(),uri);} catch(Exception ignored) {}} + nativeDocumentResult(id,"","The document could not be transferred: "+error.getMessage()); + } + },"flectar-documents").start(); + } + private static void copyDocument(long id,java.io.InputStream in,java.io.OutputStream out) throws java.io.IOException { + if(in==null||out==null)throw new java.io.IOException("Document provider did not open a stream"); + byte[] buffer=new byte[65536];long total=0;int count; + while((count=in.read(buffer))!=-1){if(!nativeDocumentRequestActive(id))throw new java.io.IOException("Document transfer cancelled");total+=count;if(total>512L*1024*1024)throw new java.io.IOException("File exceeds the transfer limit");out.write(buffer,0,count);} + if(!nativeDocumentRequestActive(id))throw new java.io.IOException("Document transfer cancelled"); + out.flush(); + } + private static final int GOOGLE_AUTHORIZATION_REQUEST = 0xF1EC; private long pendingGoogleRequest = -1; @@ -39,12 +144,16 @@ private static native void nativeGoogleAuthorizationResult( protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (savedInstanceState != null) { + pendingDocumentRequest=savedInstanceState.getLong("pendingDocumentRequest",-1); + pendingDocumentExport=savedInstanceState.getString("pendingDocumentExport",""); pendingGoogleRequest = savedInstanceState.getLong("pendingGoogleRequest", -1); } } @Override protected void onSaveInstanceState(Bundle state) { + state.putLong("pendingDocumentRequest",pendingDocumentRequest); + state.putString("pendingDocumentExport",pendingDocumentExport); state.putLong("pendingGoogleRequest", pendingGoogleRequest); super.onSaveInstanceState(state); } @@ -134,6 +243,7 @@ private void handleAuthorization( @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); + if (requestCode == FILE_DOCUMENT_REQUEST) {finishDocument(resultCode,data);return;} if (requestCode != GOOGLE_AUTHORIZATION_REQUEST) { return; } diff --git a/platform/android/src/credentials.rs b/platform/android/src/credentials.rs index c524be2..ac13e98 100644 --- a/platform/android/src/credentials.rs +++ b/platform/android/src/credentials.rs @@ -170,12 +170,7 @@ impl AndroidCredentialStore { let iv = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?; let input = env.byte_array_from_slice(plaintext)?; let ciphertext = env - .call_method( - cipher, - "doFinal", - "([B)[B", - &[JValue::Object(&input)], - )? + .call_method(cipher, "doFinal", "([B)[B", &[JValue::Object(&input)])? .l()?; let iv = env.convert_byte_array(JByteArray::from(iv))?; let ciphertext = env.convert_byte_array(JByteArray::from(ciphertext))?; @@ -196,13 +191,25 @@ impl AndroidCredentialStore { } let iv = fields .next() - .and_then(|value| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value).ok()) - .ok_or_else(|| CoreError::Auth("stored credentials are damaged; sign in again".into()))?; + .and_then(|value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value) + .ok() + }) + .ok_or_else(|| { + CoreError::Auth("stored credentials are damaged; sign in again".into()) + })?; let ciphertext = fields .next() - .and_then(|value| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value).ok()) + .and_then(|value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value) + .ok() + }) .filter(|_| fields.next().is_none()) - .ok_or_else(|| CoreError::Auth("stored credentials are damaged; sign in again".into()))?; + .ok_or_else(|| { + CoreError::Auth("stored credentials are damaged; sign in again".into()) + })?; self.with_env(|env| { let key = Self::key(env)?; @@ -233,12 +240,7 @@ impl AndroidCredentialStore { )?; let input = env.byte_array_from_slice(&ciphertext)?; let plaintext = env - .call_method( - cipher, - "doFinal", - "([B)[B", - &[JValue::Object(&input)], - )? + .call_method(cipher, "doFinal", "([B)[B", &[JValue::Object(&input)])? .l()?; env.convert_byte_array(JByteArray::from(plaintext)) }) @@ -322,6 +324,15 @@ impl CredentialStore for AndroidCredentialStore { .map_err(|_| CoreError::Auth("stored credentials are damaged; sign in again".into())) } + fn delete(&self, account_id: i64, slot: Slot) -> Result<()> { + let _transaction = self.transaction.lock().map_err(|_| { + CoreError::Other("Android credential transaction lock is unavailable".into()) + })?; + let mut values = self.read_map()?; + values.remove(&Self::slot_key(account_id, slot)); + self.write_map(&values) + } + fn delete_all(&self, account_id: i64) -> Result<()> { let _transaction = self.transaction.lock().map_err(|_| { CoreError::Other("Android credential transaction lock is unavailable".into()) diff --git a/platform/android/src/documents.rs b/platform/android/src/documents.rs new file mode 100644 index 0000000..fd8eb80 --- /dev/null +++ b/platform/android/src/documents.rs @@ -0,0 +1,195 @@ +use flectar_mail::documents::{ + DocumentFuture, DocumentProvider, DocumentRequestGuard, ImportedDocument, +}; +use jni::{ + JNIEnv, JavaVM, + objects::{GlobalRef, JClass, JObject, JString, JValue}, + sys::{jboolean, jlong}, +}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicI64, Ordering}, + }, +}; +use tokio::sync::oneshot; +type PendingReply = ( + oneshot::Sender, String>>, + bool, +); +pub struct AndroidDocuments { + vm: JavaVM, + activity: Mutex, + pending: Mutex>, + next: AtomicI64, +} +static PROVIDER: OnceLock> = OnceLock::new(); +impl AndroidDocuments { + pub fn new(app: &slint::android::AndroidApp) -> Result, String> { + // Android owns both references; GlobalRef retains the Activity. + let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?; + let env = vm.attach_current_thread().map_err(|e| e.to_string())?; + let activity = env + .new_global_ref(unsafe { JObject::from_raw(app.activity_as_ptr().cast()) }) + .map_err(|e| e.to_string())?; + drop(env); + if let Some(provider) = PROVIDER.get() { + *provider.activity.lock().unwrap() = activity; + return Ok(provider.clone()); + } + let provider = Arc::new(Self { + vm, + activity: Mutex::new(activity), + pending: Mutex::new(HashMap::new()), + next: AtomicI64::new(1), + }); + PROVIDER + .set(provider.clone()) + .map_err(|_| "Document provider already initialized")?; + Ok(provider) + } + fn request(&self, path: String, name: String) -> DocumentFuture> { + let id = self.next.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + self.pending + .lock() + .unwrap() + .insert(id, (sender, path.is_empty())); + let guard = DocumentRequestGuard(Box::new(move || { + if let Some(provider) = PROVIDER.get() { + let pending = provider.pending.lock().unwrap().remove(&id); + if pending.is_some() { + let _ = provider.cancel(id); + } + } + })); + let result = (|| -> Result<(), String> { + let mut env = self.vm.attach_current_thread().map_err(|e| e.to_string())?; + let path = env.new_string(path).map_err(|e| e.to_string())?; + let name = env.new_string(name).map_err(|e| e.to_string())?; + env.call_method( + self.activity.lock().unwrap().as_obj(), + "chooseDocument", + "(JLjava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Long(id), + JValue::Object(&path), + JValue::Object(&name), + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) + })(); + Box::pin(async move { + let _guard = guard; + if let Err(e) = result { + if let Some(p) = PROVIDER.get() { + p.pending.lock().unwrap().remove(&id); + } + return Err(e); + } + tokio::time::timeout(std::time::Duration::from_secs(600), receiver) + .await + .map_err(|_| "Document selection timed out.".to_string()) + .and_then(|v| v.map_err(|_| "Document selection ended.".to_string())) + .and_then(|v| v) + }) + } +} +impl AndroidDocuments { + fn cancel(&self, id: i64) -> Result<(), String> { + let mut env = self.vm.attach_current_thread().map_err(|e| e.to_string())?; + let result = env.call_method( + self.activity.lock().unwrap().as_obj(), + "cancelDocumentRequest", + "(J)V", + &[JValue::Long(id)], + ); + if result.is_err() { + let _ = env.exception_clear(); + } + result.map(|_| ()).map_err(|e| e.to_string()) + } + fn release(path: PathBuf) { + let Some(provider) = PROVIDER.get() else { + return; + }; + let Ok(mut env) = provider.vm.attach_current_thread() else { + return; + }; + let Ok(path) = env.new_string(path.to_string_lossy()) else { + return; + }; + if env + .call_method( + provider.activity.lock().unwrap().as_obj(), + "releaseImportedDocument", + "(Ljava/lang/String;)V", + &[JValue::Object(&path)], + ) + .is_err() + { + let _ = env.exception_clear(); + } + } +} +impl DocumentProvider for AndroidDocuments { + fn import(&self) -> DocumentFuture> { + self.request(String::new(), String::new()) + } + fn export(&self, path: PathBuf, name: String) -> DocumentFuture { + let request = self.request(path.to_string_lossy().into_owned(), name); + Box::pin(async move { Ok(request.await?.is_some()) }) + } +} +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_flectar_mail_FlectarActivity_nativeDocumentResult( + mut env: JNIEnv, + _class: JClass, + id: jlong, + path: JString, + error: JString, +) { + let path = env + .get_string(&path) + .map(|s| s.to_string_lossy().into_owned()); + let error = env + .get_string(&error) + .map(|s| s.to_string_lossy().into_owned()); + if let Some(provider) = PROVIDER.get() { + let pending = provider.pending.lock().unwrap().remove(&id); + let importing = pending.as_ref().is_none_or(|(_, importing)| *importing); + let result = match (path, error) { + (Ok(p), Ok(e)) if e.is_empty() => Ok((!p.is_empty()).then(|| { + if importing { + ImportedDocument::temporary(PathBuf::from(p), AndroidDocuments::release) + } else { + ImportedDocument::source(PathBuf::from(p)) + } + })), + (Ok(_), Ok(e)) => Err(e), + _ => Err("Invalid document picker result.".into()), + }; + if let Some((sender, _)) = pending { + // A dropped receiver drops its owned import, including the race + // between native completion and cancellation of the Rust task. + let _ = sender.send(result); + } + } +} +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_flectar_mail_FlectarActivity_nativeDocumentRequestActive( + _env: JNIEnv, + _class: JClass, + id: jlong, +) -> jboolean { + u8::from(PROVIDER.get().is_some_and(|p| { + p.pending + .lock() + .unwrap() + .get(&id) + .is_some_and(|(sender, _)| !sender.is_closed()) + })) +} diff --git a/platform/android/src/lib.rs b/platform/android/src/lib.rs index 67ba8c3..6d5a83b 100644 --- a/platform/android/src/lib.rs +++ b/platform/android/src/lib.rs @@ -95,12 +95,16 @@ fn android_main(app: slint::android::AndroidApp) { } let credential_store = credentials::AndroidCredentialStore::new(&app, &internal) .expect("failed to initialize Android Keystore credential storage"); - let platform = flectar_mail::PlatformContext::app_private( + pdf::configure(&app).expect("failed to configure bundled PDF preview"); + let documents = + documents::AndroidDocuments::new(&app).expect("failed to initialize document access"); + let mut platform = flectar_mail::PlatformContext::app_private( internal.clone(), internal, std::sync::Arc::new(credential_store), oauth_redirects, ); + platform.documents = documents; slint::android::init(app).expect("failed to initialize Slint's Android backend"); flectar_mail::run(platform).expect("Flectar Mail terminated with an error"); } @@ -108,3 +112,9 @@ fn android_main(app: slint::android::AndroidApp) { mod credentials; #[cfg(target_os = "android")] mod oauth; + +#[cfg(target_os = "android")] +mod documents; + +#[cfg(target_os = "android")] +mod pdf; diff --git a/platform/android/src/oauth.rs b/platform/android/src/oauth.rs index a8b6c41..50f38e3 100644 --- a/platform/android/src/oauth.rs +++ b/platform/android/src/oauth.rs @@ -5,8 +5,7 @@ use flectar_mail::flectar_mail_core::{ oauth::{ loopback::AuthCode, redirect::{ - OAuthRedirectBroker, OAuthRedirectGuard, OAuthRedirectSession, - PlatformAuthorization, + OAuthRedirectBroker, OAuthRedirectGuard, OAuthRedirectSession, PlatformAuthorization, }, }, }; @@ -57,7 +56,10 @@ struct AndroidRedirectSession { static BROKER: OnceLock> = OnceLock::new(); impl AndroidOAuthBroker { - pub(super) fn global(app: &slint::android::AndroidApp, private_root: &Path) -> Result> { + pub(super) fn global( + app: &slint::android::AndroidApp, + private_root: &Path, + ) -> Result> { let transient_microsoft_callback = activity_intent_data(app) .is_some_and(|uri| uri.starts_with("msauth://com.flectar.mail/")); // SAFETY: Android owns this process-wide VM pointer for the lifetime of @@ -163,11 +165,10 @@ impl AndroidOAuthBroker { } fn microsoft_redirect_uri(&self) -> Result { - let mut env = self - .state - .vm - .attach_current_thread() - .map_err(|error| CoreError::Other(format!("attach Android OAuth thread: {error}")))?; + let mut env = + self.state.vm.attach_current_thread().map_err(|error| { + CoreError::Other(format!("attach Android OAuth thread: {error}")) + })?; let activity = self .state .activity @@ -181,9 +182,7 @@ impl AndroidOAuthBroker { &[], ) .and_then(|value| value.l()) - .map_err(|error| { - CoreError::Other(format!("read Android signing identity: {error}")) - })?; + .map_err(|error| CoreError::Other(format!("read Android signing identity: {error}")))?; if value.is_null() { return Err(CoreError::Auth( "Android could not derive the Microsoft signing redirect".into(), @@ -391,7 +390,9 @@ pub extern "system" fn Java_com_flectar_mail_FlectarActivity_nativeGoogleAuthori if error.starts_with("needs_reauth:") { Err(CoreError::NeedsReauth) } else { - Err(CoreError::Auth(format!("Google authorization failed: {error}"))) + Err(CoreError::Auth(format!( + "Google authorization failed: {error}" + ))) } } else { let token = env diff --git a/platform/android/src/pdf.rs b/platform/android/src/pdf.rs new file mode 100644 index 0000000..5bf109c --- /dev/null +++ b/platform/android/src/pdf.rs @@ -0,0 +1,37 @@ +use jni::{ + JavaVM, + objects::{JClass, JObject, JString}, +}; + +pub fn configure(app: &slint::android::AndroidApp) -> Result<(), String> { + // Android owns the VM and Activity. Both remain valid throughout android_main. + let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?; + let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; + let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) }; + let info = env + .call_method( + activity, + "getApplicationInfo", + "()Landroid/content/pm/ApplicationInfo;", + &[], + ) + .and_then(|value| value.l()) + .map_err(|e| e.to_string())?; + let directory = env + .get_field(info, "nativeLibraryDir", "Ljava/lang/String;") + .and_then(|value| value.l()) + .map_err(|e| e.to_string())?; + let directory: String = env + .get_string(&JString::from(directory)) + .map_err(|e| e.to_string())? + .into(); + flectar_mail::pdf_preview::configure_android(directory.into()) +} + +#[unsafe(no_mangle)] +extern "system" fn Java_com_flectar_mail_FlectarActivity_nativeSuspendPdfPreview( + _env: jni::JNIEnv, + _class: JClass, +) { + flectar_mail::suspend_file_preview(); +} diff --git a/platform/ios/Documents.m b/platform/ios/Documents.m new file mode 100644 index 0000000..a576083 --- /dev/null +++ b/platform/ios/Documents.m @@ -0,0 +1,159 @@ +#import +#import + +typedef void (*FlectarDocumentCallback)(long long, const char *, const char *); +// Accessed only on the main queue. The delegate survives picker dismissal +// while a coordinated background import is still running. +static NSMutableDictionary *requests; + +static NSError *documentError(NSString *message) { + return [NSError errorWithDomain:@"FlectarDocuments" code:1 + userInfo:@{NSLocalizedDescriptionKey: message}]; +} + +@interface FlectarDocumentDelegate : NSObject +@property(nonatomic) long long requestId; +@property(nonatomic) FlectarDocumentCallback callback; +@property(nonatomic) BOOL exporting; +@property(nonatomic) BOOL finished; +@property(atomic) BOOL cancelled; +@property(nonatomic, strong) UIDocumentPickerViewController *picker; +@end + +@implementation FlectarDocumentDelegate +- (void)finish:(NSString *)path error:(NSString *)error { + if (self.finished) return; + self.finished = YES; + self.callback(self.requestId, path.UTF8String ?: "", error.UTF8String ?: ""); + [requests removeObjectForKey:@(self.requestId)]; + self.picker = nil; +} +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { + self.cancelled = YES; + [self finish:@"" error:@""]; +} +- (void)presentationControllerDidDismiss:(UIPresentationController *)controller { + self.cancelled = YES; + [self finish:@"" error:@""]; +} +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { + NSURL *url = urls.firstObject; + if (!url) { [self finish:@"" error:@"No document was selected."]; return; } + if (self.exporting) { [self finish:@"exported" error:@""]; return; } + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + BOOL scoped = [url startAccessingSecurityScopedResource]; + __block NSError *readError = nil; + NSError *coordinationError = nil; + __block NSURL *destination = nil; + NSURL *folder = [[[[NSFileManager defaultManager] URLsForDirectory:NSCachesDirectory + inDomains:NSUserDomainMask].firstObject URLByAppendingPathComponent:@"file-imports" + isDirectory:YES] URLByAppendingPathComponent:NSUUID.UUID.UUIDString isDirectory:YES]; + NSFileCoordinator *coordinator = [[NSFileCoordinator alloc] initWithFilePresenter:nil]; + [coordinator coordinateReadingItemAtURL:url options:0 error:&coordinationError + byAccessor:^(NSURL *readURL) { + NSNumber *regular = nil; + [readURL getResourceValue:®ular forKey:NSURLIsRegularFileKey error:&readError]; + NSString *name = readURL.lastPathComponent; + if (readError) return; + if (!regular.boolValue || name.length == 0 || [name isEqualToString:@"."] || + [name isEqualToString:@".."] || [name containsString:@"/"] || [name containsString:@"\\"]) { + readError = documentError(@"Select a regular file with a valid name."); + return; + } + if (self.cancelled) { readError = documentError(@"Document transfer cancelled."); return; } + destination = [folder URLByAppendingPathComponent:name]; + [[NSFileManager defaultManager] createDirectoryAtURL:folder withIntermediateDirectories:YES + attributes:@{NSFilePosixPermissions: @0700} error:&readError]; + if (readError) return; + NSInputStream *input = [NSInputStream inputStreamWithURL:readURL]; + NSOutputStream *output = [NSOutputStream outputStreamWithURL:destination append:NO]; + if (!input || !output) { readError = documentError(@"Could not open the document streams."); return; } + [input open]; + [output open]; + uint8_t buffer[65536]; + unsigned long long total = 0; + while (!readError) { + if (self.cancelled) { readError = documentError(@"Document transfer cancelled."); break; } + NSInteger count = [input read:buffer maxLength:sizeof(buffer)]; + if (count == 0) break; + if (count < 0) { readError = input.streamError ?: documentError(@"Could not read the document."); break; } + total += (unsigned long long)count; + if (total > 512ULL * 1024 * 1024) { + readError = documentError(@"File exceeds the transfer limit."); + break; + } + NSInteger offset = 0; + while (offset < count) { + NSInteger written = [output write:buffer + offset maxLength:(NSUInteger)(count - offset)]; + if (written <= 0) { readError = output.streamError ?: documentError(@"Could not write the imported file."); break; } + offset += written; + } + } + [input close]; + [output close]; + }]; + if (scoped) [url stopAccessingSecurityScopedResource]; + NSError *error = coordinationError ?: readError; + dispatch_async(dispatch_get_main_queue(), ^{ + if (error || self.cancelled || self.finished || !destination) { + [[NSFileManager defaultManager] removeItemAtURL:folder error:nil]; + [self finish:@"" error:error.localizedDescription ?: @""]; + } else { + // Rust owns the copy from this callback, even if its receiver + // was cancelled immediately before delivery. + [self finish:destination.path error:@""]; + } + }); + }); +} +@end + +void flectar_cancel_document(long long requestId) { + dispatch_async(dispatch_get_main_queue(), ^{ + FlectarDocumentDelegate *delegate = requests[@(requestId)]; + if (!delegate) return; + delegate.cancelled = YES; + [delegate.picker dismissViewControllerAnimated:YES completion:nil]; + [delegate finish:@"" error:@""]; + }); +} + +void flectar_choose_document(long long requestId, const char *exportPath, FlectarDocumentCallback callback) { + NSString *path = exportPath ? [NSString stringWithUTF8String:exportPath] : @""; + dispatch_async(dispatch_get_main_queue(), ^{ + if (!requests) requests = [NSMutableDictionary dictionary]; + UIWindow *window = nil; + if (@available(iOS 13.0, *)) { + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (scene.activationState == UISceneActivationStateForegroundActive && [scene isKindOfClass:UIWindowScene.class]) { + for (UIWindow *candidate in ((UIWindowScene *)scene).windows) { + if (candidate.isKeyWindow) { window = candidate; break; } + } + } + if (window) break; + } + } + if (!window) window = UIApplication.sharedApplication.keyWindow; + UIViewController *presenter = window.rootViewController; + while (presenter.presentedViewController) presenter = presenter.presentedViewController; + if (!presenter || requests.count) { callback(requestId, "", "A document picker cannot be opened right now."); return; } + FlectarDocumentDelegate *delegate = [FlectarDocumentDelegate new]; + delegate.requestId = requestId; + delegate.callback = callback; + delegate.exporting = path.length > 0; + UIDocumentPickerViewController *picker; + if (delegate.exporting) { + picker = [[UIDocumentPickerViewController alloc] initWithURL:[NSURL fileURLWithPath:path] + inMode:UIDocumentPickerModeExportToService]; + } else { + picker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[@"public.data", @"public.content"] + inMode:UIDocumentPickerModeImport]; + } + picker.delegate = delegate; + picker.allowsMultipleSelection = NO; + delegate.picker = picker; + requests[@(requestId)] = delegate; + [presenter presentViewController:picker animated:YES completion:nil]; + picker.presentationController.delegate = delegate; + }); +} diff --git a/platform/ios/PdfPreview.m b/platform/ios/PdfPreview.m new file mode 100644 index 0000000..c8b9de9 --- /dev/null +++ b/platform/ios/PdfPreview.m @@ -0,0 +1,22 @@ +#import + +extern void flectar_suspend_pdf_preview(void); + +// NotificationCenter retains these process-lifetime observer blocks. Install +// once on the main queue; the Rust callback schedules UI work on Slint. +void install_pdf_lifecycle_observers(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + for (NSNotificationName name in @[UIApplicationDidEnterBackgroundNotification, + UIApplicationDidReceiveMemoryWarningNotification]) { + [center addObserverForName:name object:nil queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification *notification) { + (void)notification; + flectar_suspend_pdf_preview(); + }]; + } + }); + }); +} diff --git a/platform/ios/build-with-cargo.sh b/platform/ios/build-with-cargo.sh index 6f78040..34ef2d9 100755 --- a/platform/ios/build-with-cargo.sh +++ b/platform/ios/build-with-cargo.sh @@ -15,11 +15,12 @@ export CARGO_PROFILE_RELEASE_DEBUG="${CARGO_PROFILE_RELEASE_DEBUG:-1}" export CARGO_TARGET_DIR="${DERIVED_FILE_DIR:?}/cargo" is_simulator=0 -if [[ "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then +if [[ "${PLATFORM_NAME:-}" == "iphonesimulator" || "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then is_simulator=1 fi executables=() +pdfium_libraries=() for arch in $ARCHS; do case "$arch" in arm64) @@ -47,10 +48,48 @@ for arch in $ARCHS; do --features remote-content,ios \ "${release_args[@]}" executables+=("$CARGO_TARGET_DIR/$cargo_target/$profile/$binary_name") + case "$cargo_target" in + aarch64-apple-ios) pdfium_target=ios-device-arm64 ;; + aarch64-apple-ios-sim) pdfium_target=ios-simulator-arm64 ;; + x86_64-apple-ios) pdfium_target=ios-simulator-x64 ;; + esac + pdfium_stage="$DERIVED_FILE_DIR/pdfium/$pdfium_target" + python3 "$project_dir/scripts/stage-pdfium.py" "$pdfium_target" "$pdfium_stage" \ + --cache "$project_dir/target/pdfium-downloads" + pdfium_libraries+=("$pdfium_stage/libpdfium.dylib") + pdfium_notices="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Licenses/PDFium/$pdfium_target" + mkdir -p "$pdfium_notices" + cp -R "$pdfium_stage/pdfium-licenses/." "$pdfium_notices/" done lipo -create -output "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" "${executables[@]}" +# iOS permits embedded dynamic frameworks, not loose third-party dylibs. +# Device and simulator slices are never mixed; Xcode chooses the SDK above. +framework="$TARGET_BUILD_DIR/${FRAMEWORKS_FOLDER_PATH:?}/PDFium.framework" +mkdir -p "$framework" +lipo -create -output "$framework/PDFium" "${pdfium_libraries[@]}" +chmod 755 "$framework/PDFium" +install_name_tool -id '@rpath/PDFium.framework/PDFium' "$framework/PDFium" +python3 "$project_dir/scripts/write-pdfium-framework-plist.py" "$framework/Info.plist" "$is_simulator" +if [[ "${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]]; then + # Simulator frameworks also need an ad-hoc signature after install_name_tool. + if [[ "$is_simulator" -eq 1 ]]; then + framework_identity="${EXPANDED_CODE_SIGN_IDENTITY:--}" + else + framework_identity="${EXPANDED_CODE_SIGN_IDENTITY:?A device build requires an Xcode signing identity}" + fi + codesign --force --sign "$framework_identity" "$framework" + codesign --verify --strict "$framework" +fi +# Fail packaging if an unexpected dependency or install name escaped staging. +framework_dependencies="$(otool -L "$framework/PDFium")" +if printf '%s\n' "$framework_dependencies" | awk '/^[[:space:]]/ {print $1}' \ + | grep -Ev '^(@rpath/PDFium.framework/PDFium|/System/Library/|/usr/lib/)'; then + printf 'PDFium framework has an unbundled dependency.\n' >&2 + exit 1 +fi + # The UI fonts are embedded in the executable. Ship their OFL notices in the # application bundle as required for redistributed copies of the fonts. font_license_root="$TARGET_BUILD_DIR/${UNLOCALIZED_RESOURCES_FOLDER_PATH:?}/Licenses" diff --git a/platform/ios/project.yml b/platform/ios/project.yml index b13834e..71dcdad 100644 --- a/platform/ios/project.yml +++ b/platform/ios/project.yml @@ -10,7 +10,7 @@ targets: FlectarMail: type: application platform: iOS - deploymentTarget: "12.0" + deploymentTarget: "17.0" info: path: Info.plist properties: diff --git a/resources/AppRun b/resources/AppRun index 0ad4377..f4a84b0 100755 --- a/resources/AppRun +++ b/resources/AppRun @@ -85,6 +85,10 @@ fi export MALLOC_ARENA_MAX="${MALLOC_ARENA_MAX:-1}" export MALLOC_TRIM_THRESHOLD_="${MALLOC_TRIM_THRESHOLD_:-65536}" +# Preserve the host search path for external programs such as the browser. +export FLECTAR_MAIL_HOST_LD_LIBRARY_PATH="${LD_LIBRARY_PATH-}" +export FLECTAR_MAIL_BUNDLED=1 + # linuxdeploy places any non-system ELF dependencies here. export LD_LIBRARY_PATH="$app_dir/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" # The Linux binary supports Slint's Wayland and X11 winit integrations. Slint diff --git a/resources/debian/control.in b/resources/debian/control.in index 4ba6337..5da2e6e 100644 --- a/resources/debian/control.in +++ b/resources/debian/control.in @@ -5,7 +5,7 @@ Priority: optional Architecture: amd64 Maintainer: Flectar Depends: @DEPENDS@ -Recommends: xdg-desktop-portal +Recommends: xdg-desktop-portal, gnome-keyring, gnupg, pinentry-gnome3 | pinentry-qt Homepage: https://flectar.com Description: Native Slint mail client Flectar Mail is a native desktop mail client built with Rust and Slint. diff --git a/resources/debian/source-control.in b/resources/debian/source-control.in index 7f5b32d..714c5de 100644 --- a/resources/debian/source-control.in +++ b/resources/debian/source-control.in @@ -8,7 +8,7 @@ Homepage: https://flectar.com Package: flectar-mail Architecture: amd64 -Depends: ${shlibs:Depends}, libfontconfig1, libwayland-client0, libx11-6, libx11-xcb1, libxkbcommon0, libxkbcommon-x11-0, hicolor-icon-theme -Recommends: xdg-desktop-portal +Depends: ${shlibs:Depends}, libfontconfig1, libwayland-client0, libx11-6, libx11-xcb1, libxkbcommon0, libxkbcommon-x11-0, hicolor-icon-theme, xdg-utils +Recommends: xdg-desktop-portal, gnome-keyring Description: Native Slint mail client Flectar Mail is a native desktop mail client built with Rust and Slint. diff --git a/resources/rpm/flectar-mail.spec.in b/resources/rpm/flectar-mail.spec.in new file mode 100644 index 0000000..7dcecef --- /dev/null +++ b/resources/rpm/flectar-mail.spec.in @@ -0,0 +1,79 @@ +%global debug_package %{nil} + +Name: flectar-mail +Version: @RPM_VERSION@ +Release: @RPM_RELEASE@%{?dist} +Summary: Native, offline-first email client +License: AGPL-3.0-only +URL: https://flectar.com +ExclusiveArch: x86_64 + +Source0: flectar-mail +Source1: libpdfium.so +Source2: pdfium-licenses.tar.gz +Source3: com.flectar.mail.desktop +Source4: com.flectar.mail.metainfo.xml +Source5: com.flectar.mail.png +Source6: com.flectar.mail.svg +Source7: LICENSE +Source8: LICENSES.tar.gz +Source9: THIRD_PARTY_NOTICES.md +Source10: google-sans-flex-OFL.txt +Source11: google-sans-flex-README.md + +# rpmbuild derives linked ELF requirements. Slint and winit load the display +# and font libraries at runtime, so keep those packages explicit. +Requires: fontconfig +Requires: hicolor-icon-theme +Requires: libX11 +Requires: libX11-xcb +Requires: libwayland-client +Requires: libxkbcommon +Requires: libxkbcommon-x11 +Requires: xdg-utils +Recommends: gnome-keyring +Recommends: gnupg2 +Recommends: pinentry +Recommends: xdg-desktop-portal + +%description +Flectar Mail is a native Rust and Slint mail client with offline-first +storage, Gmail and Microsoft account support, contacts, and calendars. + +%prep + +%build + +%install +install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/flectar-mail +install -Dm0755 %{SOURCE1} %{buildroot}%{_libdir}/flectar-mail/libpdfium.so +install -Dm0644 %{SOURCE3} %{buildroot}%{_datadir}/applications/com.flectar.mail.desktop +install -Dm0644 %{SOURCE4} %{buildroot}%{_datadir}/metainfo/com.flectar.mail.metainfo.xml +install -Dm0644 %{SOURCE5} %{buildroot}%{_datadir}/icons/hicolor/512x512/apps/com.flectar.mail.png +install -Dm0644 %{SOURCE6} %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/com.flectar.mail.svg +install -Dm0644 %{SOURCE7} %{buildroot}%{_licensedir}/%{name}/LICENSE +mkdir -p %{buildroot}%{_licensedir}/%{name}/LICENSES +tar -xzf %{SOURCE8} -C %{buildroot}%{_licensedir}/%{name}/LICENSES +install -Dm0644 %{SOURCE9} %{buildroot}%{_docdir}/%{name}/THIRD_PARTY_NOTICES.md +install -Dm0644 %{SOURCE10} %{buildroot}%{_licensedir}/%{name}/google-sans-flex/OFL.txt +install -Dm0644 %{SOURCE11} %{buildroot}%{_docdir}/%{name}/google-sans-flex/README.md +mkdir -p %{buildroot}%{_licensedir}/%{name}/pdfium +tar -xzf %{SOURCE2} -C %{buildroot}%{_licensedir}/%{name}/pdfium + +%check +desktop-file-validate %{buildroot}%{_datadir}/applications/com.flectar.mail.desktop +appstreamcli validate --no-net %{buildroot}%{_datadir}/metainfo/com.flectar.mail.metainfo.xml + +%files +%{_bindir}/flectar-mail +%{_libdir}/flectar-mail/libpdfium.so +%{_datadir}/applications/com.flectar.mail.desktop +%{_datadir}/metainfo/com.flectar.mail.metainfo.xml +%{_datadir}/icons/hicolor/512x512/apps/com.flectar.mail.png +%{_datadir}/icons/hicolor/scalable/apps/com.flectar.mail.svg +%license %{_licensedir}/%{name} +%doc %{_docdir}/%{name} + +%changelog +* @RPM_CHANGELOG_DATE@ Flectar - @RPM_VERSION@-@RPM_RELEASE@ +- Package the Flectar Mail preview for RPM-based test systems diff --git a/resources/screenshots/demo-data.json b/resources/screenshots/demo-data.json index ab6a1a4..b53c826 100644 --- a/resources/screenshots/demo-data.json +++ b/resources/screenshots/demo-data.json @@ -7,7 +7,7 @@ "total_count": "25 messages", "has_selected": true, "text_mode": false, - "email-content-aspect": 1, + "email-content-aspect": 1.055556, "email_tiles": [ { "image": "resources/screenshots/demo-email.svg", @@ -277,7 +277,7 @@ "selected_has_favicon": false, "selected_starred": true, "selected_unread": true, - "selected_plain_text": "Your week on the web\n\nHere’s how flectar.com performed over the last seven days.\n\nRequests: 1.24M\nBandwidth: 38.6 GB\nThreats blocked: 2,849\n\nTraffic was up 18%. Your busiest hour was Tuesday at 14:00 UTC.", + "selected_plain_text": "A faster week for flectar.com\n\nTraffic grew while performance and security stayed strong.\n\nRequests: 1.24M (+18.2%)\nBandwidth: 38.6 GB (+9.4%)\nThreats blocked: 2,849\n\nPeak traffic was Tuesday at 14:00 UTC. All systems are healthy, and no action is needed for your domains this week.", "connected_accounts": [ { "id": 1, @@ -318,6 +318,154 @@ "calendar_status": "Synced" } ], + "FilesUi": { + "accounts": ["All accounts", "Flectar", "Flectar Support"], + "account": 1, + "selected-account-id": 1, + "storage-accounts": ["My files", "Shared with me"], + "storage-account": 0, + "attachments": false, + "connected": true, + "can-create": true, + "can-lock": true, + "path": "/Documents", + "status": "Available offline", + "quota": "2.4 GB of 10 GB used", + "selected": 3, + "preview-open": true, + "preview-is-image": true, + "preview-image": "resources/screenshots/demo-pdf-preview.svg", + "pdf-page": 0, + "pdf-pages": 6, + "rows": [ + { + "identity": "folder:brand", + "name": "Brand assets", + "detail": "Folder · Modified yesterday", + "size": "—", + "date": "Yesterday", + "directory": true, + "attachment": false, + "readable": true, + "writable": true, + "renameable": true, + "deletable": true, + "shareable": true, + "locked": false, + "owns-lock": false, + "created": "Sep 1", + "modified": "Yesterday", + "accessed": "Today", + "executable": false, + "subscribed": false + }, + { + "identity": "folder:research", + "name": "Research", + "detail": "Folder · Modified Sep 8", + "size": "—", + "date": "Sep 8", + "directory": true, + "attachment": false, + "readable": true, + "writable": true, + "renameable": true, + "deletable": true, + "shareable": true, + "locked": false, + "owns-lock": false, + "created": "Aug 22", + "modified": "Sep 8", + "accessed": "Sep 8", + "executable": false, + "subscribed": false + }, + { + "identity": "file:project-brief", + "name": "Project brief.txt", + "detail": "Text document · Modified today", + "size": "4 KB", + "date": "Today", + "directory": false, + "attachment": false, + "readable": true, + "writable": true, + "renameable": true, + "deletable": true, + "shareable": true, + "locked": false, + "owns-lock": false, + "created": "Sep 2", + "modified": "Today", + "accessed": "Today", + "executable": false, + "subscribed": false + }, + { + "identity": "file:launch-plan", + "name": "Launch plan.pdf", + "detail": "PDF document · Modified Sep 7", + "size": "1.8 MB", + "date": "Sep 7", + "directory": false, + "attachment": false, + "readable": true, + "writable": true, + "renameable": true, + "deletable": true, + "shareable": true, + "locked": false, + "owns-lock": false, + "created": "Aug 29", + "modified": "Sep 7", + "accessed": "Sep 9", + "executable": false, + "subscribed": false + }, + { + "identity": "file:inbox-concepts", + "name": "Inbox concepts.png", + "detail": "PNG image · Modified Sep 5", + "size": "842 KB", + "date": "Sep 5", + "directory": false, + "attachment": false, + "readable": true, + "writable": true, + "renameable": true, + "deletable": true, + "shareable": true, + "locked": false, + "owns-lock": false, + "created": "Sep 5", + "modified": "Sep 5", + "accessed": "Sep 8", + "executable": false, + "subscribed": false + }, + { + "identity": "file:release-notes", + "name": "Release notes.md", + "detail": "Markdown document · Modified Sep 3", + "size": "12 KB", + "date": "Sep 3", + "directory": false, + "attachment": false, + "readable": true, + "writable": true, + "renameable": true, + "deletable": true, + "shareable": true, + "locked": true, + "owns-lock": true, + "created": "Aug 25", + "modified": "Sep 3", + "accessed": "Sep 3", + "executable": false, + "subscribed": false + } + ] + }, "sidebar_rows": [ {"key": "unified-heading", "kind": "unified-heading", "mailbox": {"account_id": -1, "folder_id": -1, "parent_folder_id": -1, "depth": 0, "has_children": false, "expanded": true, "is_standard": true, "label_has_emoji": false, "label": "", "scope": "", "context": "", "detail": "", "avatar": "", "has_avatar": false, "is_account": false, "count": "", "color": "#000000", "has_custom_color": false}, "label": {"id": -1, "name": "", "name_has_emoji": false, "color": "#000000", "applied": false, "is_auto": false}, "open": false}, {"key": "unified", "kind": "unified-section", "mailbox": {"account_id": -1, "folder_id": -1, "parent_folder_id": -1, "depth": 0, "has_children": false, "expanded": true, "is_standard": true, "label_has_emoji": false, "label": "", "scope": "", "context": "", "detail": "", "avatar": "", "has_avatar": false, "is_account": false, "count": "", "color": "#000000", "has_custom_color": false}, "label": {"id": -1, "name": "", "name_has_emoji": false, "color": "#000000", "applied": false, "is_auto": false}, "open": true}, diff --git a/resources/screenshots/demo-email.svg b/resources/screenshots/demo-email.svg index 6862787..6d75d41 100644 --- a/resources/screenshots/demo-email.svg +++ b/resources/screenshots/demo-email.svg @@ -1,28 +1,74 @@ - - - - - - - CF - CLOUDFLARE - Weekly traffic report - Your week on the web - Here’s how flectar.com performed over the last seven days. - - - - REQUESTS - 1.24M - BANDWIDTH - 38.6 GB - THREATS BLOCKED - 2,849 - Traffic was up 18% - Your busiest hour was Tuesday at 14:00 UTC, with most - visitors arriving from Spain, the United States, and Germany. - - View analytics - - This fictional message is used only for Flectar Mail screenshots. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/screenshots/demo-pdf-preview.svg b/resources/screenshots/demo-pdf-preview.svg new file mode 100644 index 0000000..c2b82d3 --- /dev/null +++ b/resources/screenshots/demo-pdf-preview.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/screenshots/desktop-calendar-light.png b/resources/screenshots/desktop-calendar-light.png index 6d898d8..9a1abf5 100644 Binary files a/resources/screenshots/desktop-calendar-light.png and b/resources/screenshots/desktop-calendar-light.png differ diff --git a/resources/screenshots/desktop-contacts-light.png b/resources/screenshots/desktop-contacts-light.png index 2babf06..40878fa 100644 Binary files a/resources/screenshots/desktop-contacts-light.png and b/resources/screenshots/desktop-contacts-light.png differ diff --git a/resources/screenshots/desktop-dark.png b/resources/screenshots/desktop-dark.png index 048229b..9806ab7 100644 Binary files a/resources/screenshots/desktop-dark.png and b/resources/screenshots/desktop-dark.png differ diff --git a/resources/screenshots/desktop-files-light.png b/resources/screenshots/desktop-files-light.png new file mode 100644 index 0000000..c952f4b Binary files /dev/null and b/resources/screenshots/desktop-files-light.png differ diff --git a/resources/screenshots/desktop-green-dark.png b/resources/screenshots/desktop-green-dark.png index 1f8667e..36489e1 100644 Binary files a/resources/screenshots/desktop-green-dark.png and b/resources/screenshots/desktop-green-dark.png differ diff --git a/resources/screenshots/desktop-green-light.png b/resources/screenshots/desktop-green-light.png index 6df7ffc..5a77a9d 100644 Binary files a/resources/screenshots/desktop-green-light.png and b/resources/screenshots/desktop-green-light.png differ diff --git a/resources/screenshots/desktop-light.png b/resources/screenshots/desktop-light.png index a16af99..26bcf3f 100644 Binary files a/resources/screenshots/desktop-light.png and b/resources/screenshots/desktop-light.png differ diff --git a/resources/screenshots/desktop-minimal-dark.png b/resources/screenshots/desktop-minimal-dark.png index 44891ff..d8d2318 100644 Binary files a/resources/screenshots/desktop-minimal-dark.png and b/resources/screenshots/desktop-minimal-dark.png differ diff --git a/resources/screenshots/desktop-minimal-light.png b/resources/screenshots/desktop-minimal-light.png index 003e6dc..2725be5 100644 Binary files a/resources/screenshots/desktop-minimal-light.png and b/resources/screenshots/desktop-minimal-light.png differ diff --git a/resources/screenshots/desktop-purple-dark.png b/resources/screenshots/desktop-purple-dark.png index d0a88f1..8ed59c6 100644 Binary files a/resources/screenshots/desktop-purple-dark.png and b/resources/screenshots/desktop-purple-dark.png differ diff --git a/resources/screenshots/desktop-purple-light.png b/resources/screenshots/desktop-purple-light.png index bccd604..232d9e0 100644 Binary files a/resources/screenshots/desktop-purple-light.png and b/resources/screenshots/desktop-purple-light.png differ diff --git a/resources/screenshots/desktop-teal-dark.png b/resources/screenshots/desktop-teal-dark.png index b5bd633..f30c7ef 100644 Binary files a/resources/screenshots/desktop-teal-dark.png and b/resources/screenshots/desktop-teal-dark.png differ diff --git a/resources/screenshots/desktop-teal-light.png b/resources/screenshots/desktop-teal-light.png index 4c6d156..198ee25 100644 Binary files a/resources/screenshots/desktop-teal-light.png and b/resources/screenshots/desktop-teal-light.png differ diff --git a/resources/screenshots/mobile-dark.png b/resources/screenshots/mobile-dark.png index 507b630..32e87ea 100644 Binary files a/resources/screenshots/mobile-dark.png and b/resources/screenshots/mobile-dark.png differ diff --git a/resources/screenshots/mobile-light.png b/resources/screenshots/mobile-light.png index 1211979..426f152 100644 Binary files a/resources/screenshots/mobile-light.png and b/resources/screenshots/mobile-light.png differ diff --git a/scripts/build-android-release.sh b/scripts/build-android-release.sh index d4bf1c2..61afb62 100755 --- a/scripts/build-android-release.sh +++ b/scripts/build-android-release.sh @@ -91,6 +91,8 @@ native_lib="$target_dir/aarch64-linux-android/release/libflectar_mail_android.so jni_dir="$target_dir/android/gradle-jni/arm64-v8a" mkdir -p "$jni_dir" cp "$native_lib" "$jni_dir/libflectar_mail_android.so" +python3 "$project_dir/scripts/stage-pdfium.py" android-arm64 "$jni_dir" \ + --cache "$target_dir/pdfium-downloads" "$gradle_command" --no-daemon \ --project-cache-dir "$target_dir/android/gradle-cache" \ diff --git a/scripts/build-android-test-apk.sh b/scripts/build-android-test-apk.sh index ea85ec2..fa2c736 100755 --- a/scripts/build-android-test-apk.sh +++ b/scripts/build-android-test-apk.sh @@ -87,6 +87,8 @@ native_lib="$target_dir/aarch64-linux-android/release/libflectar_mail_android.so jni_dir="$target_dir/android/gradle-jni/arm64-v8a" mkdir -p "$jni_dir" cp "$native_lib" "$jni_dir/libflectar_mail_android.so" +python3 "$project_dir/scripts/stage-pdfium.py" android-arm64 "$jni_dir" \ + --cache "$target_dir/pdfium-downloads" export FLECTAR_ANDROID_KEYSTORE="$keystore" export FLECTAR_ANDROID_KEYSTORE_PASSWORD=android diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index a17c7ae..c775c04 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -38,7 +38,14 @@ if [[ -n "$app_features" ]]; then build_args+=(--features "$app_features") fi # The controls are app-owned and the Linux shell uses winit directly. -cargo build "${build_args[@]}" +if [[ "${FLECTAR_SKIP_BUILD:-0}" == "1" ]]; then + if [[ ! -x "$project_dir/target/release/flectar-mail" ]]; then + printf 'FLECTAR_SKIP_BUILD=1 requires an existing release executable.\n' >&2 + exit 1 + fi +else + cargo build "${build_args[@]}" +fi if ldd "$project_dir/target/release/flectar-mail" | grep -q 'libQt'; then printf 'The release binary unexpectedly links Qt; refusing to package it.\n' >&2 @@ -54,6 +61,7 @@ mkdir -p \ "$app_dir/usr/share/icons/hicolor/scalable/apps" \ "$app_dir/usr/share/metainfo" cp "$project_dir/target/release/flectar-mail" "$app_dir/usr/bin/flectar-mail" +python3 "$project_dir/scripts/stage-pdfium.py" linux-x64 "$app_dir/usr/lib/flectar-mail" app_version="$(python3 "$project_dir/scripts/stage-linux-metadata.py" "$app_dir")" cp "$project_dir/resources/app-icon/flectar-mail-masked-512.png" "$app_dir/usr/share/icons/hicolor/512x512/apps/com.flectar.mail.png" cp "$project_dir/resources/app-icon/flectar-mail-masked.svg" "$app_dir/usr/share/icons/hicolor/scalable/apps/com.flectar.mail.svg" @@ -87,6 +95,10 @@ VERSION="$app_version" APPIMAGE_EXTRACT_AND_RUN="${APPIMAGE_EXTRACT_AND_RUN:-1}" cp "$project_dir/resources/AppRun" "$app_dir/AppRun" chmod +x "$app_dir/AppRun" +# The pinned PDFium runtime depends only on the ordinary glibc runtime. Keep +# its private path intact and verify it after deployment has adjusted the ELF files. +python3 "$project_dir/scripts/test-pdf-preview.py" "$app_dir/usr/bin/flectar-mail" + # AppDir specifies a PNG .DirIcon for file-manager thumbnails. linuxdeploy # prefers the scalable icon for the root entry, so restore the PNG thumbnail # explicitly while retaining the SVG and themed icon copies for integration. diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index 4b272d5..a16c137 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -19,7 +19,14 @@ build_args=(--locked --bin flectar-mail --manifest-path "$project_dir/Cargo.toml if [[ -n "$app_features" ]]; then build_args+=(--features "$app_features") fi -cargo build "${build_args[@]}" +if [[ "${FLECTAR_SKIP_BUILD:-0}" == "1" ]]; then + if [[ ! -x "$project_dir/target/release/flectar-mail" ]]; then + printf 'FLECTAR_SKIP_BUILD=1 requires an existing release executable.\n' >&2 + exit 1 + fi +else + cargo build "${build_args[@]}" +fi rm -rf "$package_root" mkdir -p \ @@ -33,6 +40,8 @@ mkdir -p \ install -m755 "$project_dir/target/release/flectar-mail" \ "$package_root/usr/bin/flectar-mail" +python3 "$project_dir/scripts/stage-pdfium.py" linux-x64 "$package_root/usr/lib/flectar-mail" +python3 "$project_dir/scripts/test-pdf-preview.py" "$package_root/usr/bin/flectar-mail" python3 "$project_dir/scripts/stage-linux-metadata.py" "$package_root" install -m644 "$project_dir/resources/app-icon/flectar-mail-masked-512.png" \ "$package_root/usr/share/icons/hicolor/512x512/apps/com.flectar.mail.png" @@ -55,10 +64,10 @@ install -m644 "$project_dir/resources/fonts/google-sans-flex/README.md" \ cp "$project_dir/resources/debian/source-control.in" "$build_dir/debian/control" shlib_substitution="$( cd "$build_dir" - dpkg-shlibdeps -O -edebian/flectar-mail/usr/bin/flectar-mail + dpkg-shlibdeps -O -edebian/flectar-mail/usr/bin/flectar-mail -edebian/flectar-mail/usr/lib/flectar-mail/libpdfium.so )" runtime_dependencies="${shlib_substitution#shlibs:Depends=}" -runtime_dependencies+=", libfontconfig1, libwayland-client0, libx11-6, libx11-xcb1, libxkbcommon0, libxkbcommon-x11-0, hicolor-icon-theme" +runtime_dependencies+=", libfontconfig1, libwayland-client0, libx11-6, libx11-xcb1, libxkbcommon0, libxkbcommon-x11-0, hicolor-icon-theme, xdg-utils" sed \ -e "s/@VERSION@/$version/g" \ -e "s/@DEPENDS@/$runtime_dependencies/g" \ diff --git a/scripts/build-flatpak.sh b/scripts/build-flatpak.sh new file mode 100755 index 0000000..908f9a2 --- /dev/null +++ b/scripts/build-flatpak.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +manifest="$project_dir/packaging/flatpak/com.flectar.mail.yml" +build_root="$project_dir/target/flatpak" +build_dir="$build_root/build" +repo_dir="$build_root/repo" +output="$build_root/flectar-mail.flatpak" + +for command in flatpak flatpak-builder; do + command -v "$command" >/dev/null || { + printf 'Required tool is missing: %s\n' "$command" >&2 + exit 1 + } +done + +rm -rf "$build_dir" "$repo_dir" "$output" +mkdir -p "$build_root" +builder_args=( + --force-clean + --disable-rofiles-fuse + --user + --assumeyes + --repo="$repo_dir" + --install-deps-from=flathub +) +if [[ -n "${SOURCE_DATE_EPOCH:-}" ]] && flatpak-builder --help | grep -- --override-source-date-epoch >/dev/null; then + builder_args+=(--override-source-date-epoch="$SOURCE_DATE_EPOCH") +fi +flatpak-builder "${builder_args[@]}" "$build_dir" "$manifest" +flatpak build-bundle \ + --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \ + "$repo_dir" "$output" com.flectar.mail +test -s "$output" +printf 'Built %s\n' "$output" diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..a9ed0ea --- /dev/null +++ b/scripts/build-rpm.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +build_dir="$project_dir/target/rpm" +top_dir="$build_dir/rpmbuild" +source_dir="$top_dir/SOURCES" +package_root="$build_dir/package" +rpm_libdir="$(rpm --eval '%{_libdir}')" +version="$(sed -n '/^\[package\]$/,/^\[/s/^version = "\([^"]*\)"/\1/p' "$project_dir/Cargo.toml" | head -n 1)" + +if [[ -z "$version" ]]; then + printf 'Could not read the package version from Cargo.toml.\n' >&2 + exit 1 +fi +if [[ "$version" == *-* ]]; then + rpm_version="${version%%-*}" + rpm_release="0.${version#*-}" +else + rpm_version="$version" + rpm_release="1" +fi + +build_args=(--locked --bin flectar-mail --manifest-path "$project_dir/Cargo.toml" --release --no-default-features --features remote-content) +if [[ "${FLECTAR_SKIP_BUILD:-0}" == "1" ]]; then + if [[ ! -x "$project_dir/target/release/flectar-mail" ]]; then + printf 'FLECTAR_SKIP_BUILD=1 requires an existing release executable.\n' >&2 + exit 1 + fi +else + cargo build "${build_args[@]}" +fi + +rm -rf "$build_dir" +mkdir -p "$source_dir" "$top_dir/BUILD" "$top_dir/BUILDROOT" "$top_dir/RPMS" "$top_dir/SPECS" "$top_dir/SRPMS" +install -Dm0755 "$project_dir/target/release/flectar-mail" "$package_root/usr/bin/flectar-mail" +python3 "$project_dir/scripts/stage-pdfium.py" linux-x64 "$package_root$rpm_libdir/flectar-mail" +python3 "$project_dir/scripts/test-pdf-preview.py" "$package_root/usr/bin/flectar-mail" +install -m0755 "$package_root/usr/bin/flectar-mail" "$source_dir/flectar-mail" +install -m0755 "$package_root$rpm_libdir/flectar-mail/libpdfium.so" "$source_dir/libpdfium.so" +tar -C "$package_root$rpm_libdir/flectar-mail/pdfium-licenses" -czf "$source_dir/pdfium-licenses.tar.gz" . + +python3 "$project_dir/scripts/stage-linux-metadata.py" "$build_dir/metadata" >/dev/null +install -m0644 "$build_dir/metadata/usr/share/applications/com.flectar.mail.desktop" "$source_dir/com.flectar.mail.desktop" +install -m0644 "$build_dir/metadata/usr/share/metainfo/com.flectar.mail.metainfo.xml" "$source_dir/com.flectar.mail.metainfo.xml" +install -m0644 "$project_dir/resources/app-icon/flectar-mail-masked-512.png" "$source_dir/com.flectar.mail.png" +install -m0644 "$project_dir/resources/app-icon/flectar-mail-masked.svg" "$source_dir/com.flectar.mail.svg" +install -m0644 "$project_dir/LICENSE" "$source_dir/LICENSE" +tar -C "$project_dir/LICENSES" -czf "$source_dir/LICENSES.tar.gz" . +install -m0644 "$project_dir/THIRD_PARTY_NOTICES.md" "$source_dir/THIRD_PARTY_NOTICES.md" +install -m0644 "$project_dir/resources/fonts/google-sans-flex/OFL.txt" "$source_dir/google-sans-flex-OFL.txt" +install -m0644 "$project_dir/resources/fonts/google-sans-flex/README.md" "$source_dir/google-sans-flex-README.md" + +changelog_date="$(LC_ALL=C date --utc --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" '+%a %b %d %Y')" +sed \ + -e "s/@RPM_VERSION@/$rpm_version/g" \ + -e "s/@RPM_RELEASE@/$rpm_release/g" \ + -e "s/@RPM_CHANGELOG_DATE@/$changelog_date/g" \ + "$project_dir/resources/rpm/flectar-mail.spec.in" > "$top_dir/SPECS/flectar-mail.spec" + +rpmbuild -bb \ + --define "_topdir $top_dir" \ + "$top_dir/SPECS/flectar-mail.spec" + +mapfile -t packages < <(find "$top_dir/RPMS" -type f -name '*.rpm' -print) +if [[ "${#packages[@]}" != 1 ]]; then + printf 'Expected one RPM, found %s.\n' "${#packages[@]}" >&2 + exit 1 +fi +output="$build_dir/flectar-mail.rpm" +cp "${packages[0]}" "$output" +rpm -K "$output" +rpm -qpl "$output" >/dev/null +printf 'Built %s\n' "$output" diff --git a/scripts/generate-screenshots.sh b/scripts/generate-screenshots.sh index bf98f95..1712eb8 100755 --- a/scripts/generate-screenshots.sh +++ b/scripts/generate-screenshots.sh @@ -102,5 +102,6 @@ render desktop-green-dark 1320 800 dark default green render desktop-purple-dark 1320 800 dark default purple render desktop-calendar-light 1320 800 light default default calendar render desktop-contacts-light 1320 800 light default default contacts +render desktop-files-light 1320 800 light default default files render mobile-light 390 844 light render mobile-dark 390 844 dark diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 55e1888..dcc2b12 100755 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -19,6 +19,8 @@ mkdir -p \ "$app_dir/Contents/Resources/Licenses/Noto Emoji" cp "$binary_path" "$app_dir/Contents/MacOS/flectar-mail" +python3 "$project_dir/scripts/stage-pdfium.py" mac-arm64 "$app_dir/Contents/MacOS" + icon_source="$project_dir/resources/app-icon/flectar-mail-masked.png" iconset_dir="$output_dir/FlectarMail.iconset" rm -rf "$iconset_dir" @@ -58,6 +60,7 @@ sed \ # An ad-hoc signature makes the CI artifact internally consistent. Distribution # signing and notarization replace it when Apple credentials are configured. codesign --force --deep --sign - "$app_dir" +python3 "$project_dir/scripts/test-pdf-preview.py" "$app_dir/Contents/MacOS/flectar-mail" mkdir -p "$output_dir" ditto -c -k --sequesterRsrc --keepParent "$app_dir" "$output_dir/flectar-mail-macos-arm64.zip" diff --git a/scripts/prepare-release.py b/scripts/prepare-release.py index e1fd039..72f37d6 100644 --- a/scripts/prepare-release.py +++ b/scripts/prepare-release.py @@ -15,6 +15,8 @@ def prepare(source: Path, destination: Path, version: str) -> None: packages = { "flectar-mail.AppImage": f"flectar-mail-{version}-linux-x64.AppImage", f"flectar-mail_{deb_version}_amd64.deb": f"flectar-mail_{deb_filename_version}_amd64.deb", + "flectar-mail.rpm": f"flectar-mail-{version}-linux-x64.rpm", + "flectar-mail.flatpak": f"flectar-mail-{version}-linux-x64.flatpak", "flectar-mail-windows-x64.zip": f"flectar-mail-{version}-windows-x64.zip", "flectar-mail-windows-x64-setup.exe": f"flectar-mail-{version}-windows-x64-setup.exe", "flectar-mail-macos-arm64.zip": f"flectar-mail-{version}-macos-arm64.zip", @@ -52,7 +54,8 @@ def release_notes(version: str) -> str: if "-" in version else "The Android test APK is available in prereleases only.\n" ) return ( - "Desktop builds for Linux x64, Windows x64, and macOS Apple silicon.\n\n" + "Desktop builds for Linux x64, Windows x64, and macOS Apple silicon. " + "Linux testers can choose AppImage, Debian, Fedora RPM, or Flatpak.\n\n" "Gmail and Outlook sign-in require a bundled OAuth registration or your own " "keys in **Sign-in settings** on the welcome screen. IMAP/SMTP and JMAP " "do not require Google or Microsoft app keys. Provider policies still apply.\n\n" @@ -60,7 +63,10 @@ def release_notes(version: str) -> str: "not Developer ID signed or notarized; OS security prompts are expected. " "macOS requires version 14 or later. Updates are installed manually.\n\n" "Download the installer for your platform, or use a ZIP for manual " - "installation. SHA256SUMS covers every download. iOS archives are " + "installation. The preview Flatpak is a sideloaded bundle and does not " + "receive Flathub updates yet; close-to-tray is disabled in that package. " + "SHA256SUMS covers every download, and GitHub " + "records signed build-provenance attestations. iOS archives are " "experimental and available through manual workflow runs only.\n\n" + android ) diff --git a/scripts/stage-linux-metadata.py b/scripts/stage-linux-metadata.py index f88ac58..e2e9d56 100644 --- a/scripts/stage-linux-metadata.py +++ b/scripts/stage-linux-metadata.py @@ -10,7 +10,7 @@ from pathlib import Path -def stage(project: Path, destination: Path) -> str: +def stage(project: Path, destination: Path, *, flatpak: bool = False) -> str: with (project / "Cargo.toml").open("rb") as stream: version = tomllib.load(stream)["package"]["version"] if not re.fullmatch(r"\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.[1-9]\d*)?", version): @@ -29,6 +29,8 @@ def stage(project: Path, destination: Path) -> str: ) if count != 1: raise ValueError("Expected one X-AppImage-Version field in the desktop template") + if flatpak: + desktop = re.sub(r"^X-AppImage-[^\n]*\n?", "", desktop, flags=re.MULTILINE) parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) metainfo = ET.parse(resources / "com.flectar.mail.metainfo.xml", parser=parser) # The first release describes the build being packaged. Retain older entries. @@ -50,6 +52,7 @@ def stage(project: Path, destination: Path) -> str: if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--flatpak", action="store_true", help="omit AppImage-only desktop keys") parser.add_argument("destination", type=Path, help="AppDir or Debian package root") args = parser.parse_args() - print(stage(Path(__file__).resolve().parent.parent, args.destination)) + print(stage(Path(__file__).resolve().parent.parent, args.destination, flatpak=args.flatpak)) diff --git a/scripts/stage-pdfium.py b/scripts/stage-pdfium.py new file mode 100644 index 0000000..826af40 --- /dev/null +++ b/scripts/stage-pdfium.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Stage a checksum-pinned, non-V8 PDFium runtime for desktop and mobile app packaging. + +No downloads happen at application startup or during cargo build. Release +packaging calls this explicitly; developers can target target/debug. +""" +import argparse +import hashlib +import io +import json +import pathlib +import shutil +import tarfile +import urllib.request + +RELEASE = "chromium/8044" +ASSETS = { + "linux-x64": ("eb142f416aed3a72fc5a02dbd5884868a16cb99dc0cf53e6bdd64afbf67b05f4", "lib/libpdfium.so"), + "mac-arm64": ("61424884d4a7f153b808deba6437848e4400834ce30aaf95d3050da44df8f420", "lib/libpdfium.dylib"), + "android-arm64": ("37686e64fa005484d619550a78805500de4c5a6f4df7aa06d8576145bfbee98f", "lib/libpdfium.so"), + "android-x64": ("e7e3072b8ad6f36ef58db999c2d91a22fa165a904d69385f13db4537c233d736", "lib/libpdfium.so"), + "ios-device-arm64": ("52be735d07b6c498ed8ce68b7f0cb5a209bbe61593c7a86c556756b80abbb938", "lib/libpdfium.dylib"), + "ios-simulator-arm64": ("544537fce5257146e0df501c632d490d1aa95768737770f1912c8e23b1dd238d", "lib/libpdfium.dylib"), + "ios-simulator-x64": ("8d4e33e415e29894aa98481bc9a9162a42039a1672796db14c9fed9cd545c881", "lib/libpdfium.dylib"), + "win-x64": ("78a17d9a5f14467631c26a3ac8741b27a0471ecc05bd6a119b523598160a0537", "bin/pdfium.dll"), +} + + +def stage(target, destination, cache=None): + checksum, library = ASSETS[target] + url = f"https://github.com/bblanchon/pdfium-binaries/releases/download/{RELEASE}/pdfium-{target}.tgz" + cached = cache / f"{checksum}.tgz" if cache else None + if cached and cached.is_file(): + data = cached.read_bytes() + else: + with urllib.request.urlopen(url, timeout=60) as response: + data = response.read(64 * 1024 * 1024 + 1) + if hashlib.sha256(data).hexdigest() != checksum: + raise ValueError("PDFium archive checksum mismatch") + if cached and not cached.is_file(): + cache.mkdir(parents=True, exist_ok=True) + # Concurrent Xcode architecture builds must never observe a partial cache. + import tempfile + with tempfile.NamedTemporaryFile(dir=cache, delete=False) as temp: + temp.write(data) + temporary = pathlib.Path(temp.name) + temporary.replace(cached) + destination.mkdir(parents=True, exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive: + members = {member.name.removeprefix("./"): member for member in archive.getmembers()} + for name, member in members.items(): + if name != library and not name.startswith("licenses/") and name != "LICENSE": + continue + if not member.isfile() or ".." in pathlib.PurePosixPath(name).parts: + raise ValueError("Invalid PDFium archive member") + relative = pathlib.Path(library).name if name == library else pathlib.Path("pdfium-licenses") / name + output = destination / relative + output.parent.mkdir(parents=True, exist_ok=True) + with archive.extractfile(member) as source, output.open("wb") as sink: + shutil.copyfileobj(source, sink) + if not (destination / pathlib.Path(library).name).is_file(): + raise ValueError("PDFium runtime missing from archive") + (destination / "pdfium-licenses" / "build.json").write_text(json.dumps({"release": RELEASE, "sha256": checksum, "url": url}, indent=2) + "\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("target", choices=ASSETS) + parser.add_argument("destination", type=pathlib.Path) + parser.add_argument("--cache", type=pathlib.Path, help="Verified archive cache for repeated packaging builds") + args = parser.parse_args() + stage(args.target, args.destination, args.cache) diff --git a/scripts/test-pdf-packaging.py b/scripts/test-pdf-packaging.py new file mode 100644 index 0000000..fb7f82c --- /dev/null +++ b/scripts/test-pdf-packaging.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Offline regressions for PDFium staging and native package validation.""" +import hashlib +import importlib.util +import io +import pathlib +import plistlib +import struct +import sys +import tarfile +import tempfile +import unittest +import zipfile +from unittest.mock import patch + +sys.dont_write_bytecode = True +SCRIPTS = pathlib.Path(__file__).resolve().parent + + +def module(name): + spec = importlib.util.spec_from_file_location(name, SCRIPTS / (name + ".py")) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +stage = module("stage-pdfium") +native = module("verify-android-native") +framework = module("write-pdfium-framework-plist") + + +def archive(files): + result = io.BytesIO() + with tarfile.open(fileobj=result, mode="w:gz") as output: + for name, data in files.items(): + entry = tarfile.TarInfo(name) + entry.size = len(data) + output.addfile(entry, io.BytesIO(data)) + return result.getvalue() + + +def elf(alignment=16384): + data = bytearray(120) + data[:6] = b"\x7fELF\x02\x01" + struct.pack_into(">", b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"] + for content in (6, 7): + objects.append(f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents {content} 0 R >>".encode()) + objects.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + objects += [b"<< /Length " + str(len(s)).encode() + b" >>\nstream\n" + s + b"\nendstream" for s in streams] + data = b"%PDF-1.4\n"; offsets = [0] + for i, obj in enumerate(objects, 1): + offsets.append(len(data)); data += f"{i} 0 obj\n".encode() + obj + b"\nendobj\n" + xref = len(data) + data += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode() + data += b"".join(f"{offset:010} 00000 n \n".encode() for offset in offsets[1:]) + return data + f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() + + +def png(path, width, height, pixels): + def chunk(kind, value): + return struct.pack(">I", len(value)) + kind + value + struct.pack(">I", zlib.crc32(kind + value)) + raw = b"".join(b"\0" + pixels[y * width * 4:(y + 1) * width * 4] for y in range(height)) + pathlib.Path(path).write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) + + +def check(binary, output=None): + data = fixture() + for index, zoom in [(0, 100), (1, 100), (0, 200)]: + start = time.monotonic() + result = subprocess.run([binary, "--render-pdf-page"], input=struct.pack(" 2 else None) diff --git a/scripts/test-release.py b/scripts/test-release.py index 34bbcb2..f03413c 100644 --- a/scripts/test-release.py +++ b/scripts/test-release.py @@ -8,6 +8,7 @@ import subprocess import sys import tempfile +import tomllib import unittest import xml.etree.ElementTree as ET from pathlib import Path @@ -34,6 +35,72 @@ def load_script(name): class ReleaseTests(unittest.TestCase): + def test_flatpak_pdfium_archive_keeps_expected_layout(self): + manifest = ( + SCRIPTS.parent / "packaging/flatpak/com.flectar.mail.yml" + ).read_text() + self.assertIn( + "install -Dm0755 pdfium-runtime/lib/libpdfium.so " + "/app/lib/flectar-mail/libpdfium.so", + manifest, + ) + self.assertRegex( + manifest, + r"(?s)url: .*pdfium-linux-x64\.tgz\n" + r"\s+sha256: [0-9a-f]{64}\n" + r"\s+dest: pdfium-runtime\n" + r"\s+strip-components: 0\n", + ) + + def test_flatpak_cargo_sources_match_lockfile(self): + project = SCRIPTS.parent + packages = tomllib.loads((project / "Cargo.lock").read_text())["package"] + expected = { + f"cargo/vendor/{package['name']}-{package['version']}": package + for package in packages + if package.get("source", "").startswith("registry+") + } + sources = json.loads( + (project / "packaging/flatpak/cargo-sources.json").read_text() + ) + self.assertEqual(len(sources), len(expected) * 2 + 1) + + archives = {source["dest"]: source for source in sources if source["type"] == "archive"} + checksums = { + source["dest"]: json.loads(source["contents"])["package"] + for source in sources + if source["type"] == "inline" and source.get("dest-filename") == ".cargo-checksum.json" + } + self.assertEqual(set(archives), set(expected)) + self.assertEqual( + checksums, + {destination: package["checksum"] for destination, package in expected.items()}, + ) + for destination, package in expected.items(): + name = package["name"] + version = package["version"] + source = archives[destination] + self.assertEqual(source["archive-type"], "tar-gzip") + self.assertEqual(source["sha256"], package["checksum"]) + self.assertEqual( + source["url"], + f"https://static.crates.io/crates/{name}/{name}-{version}.crate", + ) + self.assertEqual( + sources[-1], + { + "type": "inline", + "contents": ( + '[source.vendored-sources]\n' + 'directory = "cargo/vendor"\n\n' + '[source.crates-io]\n' + 'replace-with = "vendored-sources"\n' + ), + "dest": "cargo", + "dest-filename": "config", + }, + ) + def test_android_ci_output_and_oauth_fingerprints(self): signing = (SCRIPTS / "fixtures/apksigner-v2.txt").read_text() expected = "6f269ab047ca5d5eae7317758a265251e3d5645c5307633a5b92d192b8646280" @@ -118,6 +185,11 @@ def test_linux_metadata_versions(self): for path in destination.rglob("*"): if path.is_file(): self.assertEqual(path.read_bytes(), (repeated / path.relative_to(destination)).read_bytes()) + flatpak = root / f"{version}-flatpak" + with patch.dict(os.environ, {"SOURCE_DATE_EPOCH": "1767225600"}): + stage_linux_metadata(root, flatpak, flatpak=True) + desktop = (flatpak / "usr/share/applications/com.flectar.mail.desktop").read_text() + self.assertNotIn("X-AppImage-", desktop) for name in ("com.flectar.mail.desktop", "com.flectar.mail.metainfo.xml"): self.assertEqual((root / "resources" / name).read_bytes(), (SCRIPTS.parent / "resources" / name).read_bytes()) @@ -145,6 +217,19 @@ def test_linux_package_metadata(self): (root / "Cargo.toml").write_text('[package]\nversion = "0.1.0-alpha.1"\n') (root / "target/release").mkdir(parents=True) shutil.copyfile("/bin/true", root / "target/release/flectar-mail") + # This test checks package metadata using fixture ELFs. Stub only + # the download/renderer helpers in the disposable checkout; actual + # native PDF rendering is exercised by the dedicated smoke tests. + (root / "scripts/stage-pdfium.py").write_text( + "import pathlib, shutil, sys\n" + "destination = pathlib.Path(sys.argv[2])\n" + "destination.mkdir(parents=True, exist_ok=True)\n" + "shutil.copyfile('/bin/true', destination / 'libpdfium.so')\n" + ) + (root / "scripts/test-pdf-preview.py").write_text( + "import pathlib, sys\n" + "assert pathlib.Path(sys.argv[1]).is_file()\n" + ) (root / "bin").mkdir() cargo = root / "bin/cargo" cargo.write_text("#!/bin/sh\nexit 0\n") @@ -224,6 +309,8 @@ def test_staging_and_checksums(self): names = ( "linux/appimage/flectar-mail.AppImage", "linux/deb/flectar-mail_0.1.0~beta.1_amd64.deb", + "linux/rpm/flectar-mail.rpm", + "linux/flatpak/flectar-mail.flatpak", "windows/flectar-mail-windows-x64.zip", "windows/flectar-mail-windows-x64-setup.exe", "macos/flectar-mail-macos-arm64.zip", @@ -237,7 +324,7 @@ def test_staging_and_checksums(self): (source / "benchmark.json").write_text("{}") dist = root / "dist" prepare(source, dist, "0.1.0-beta.1") - self.assertEqual(len(list(dist.iterdir())), 8) + self.assertEqual(len(list(dist.iterdir())), 10) self.assertTrue((dist / "flectar-mail-0.1.0-beta.1-android-arm64-test.apk").is_file()) # GitHub must not rewrite a download name after we checksum it. deb = dist / "flectar-mail_0.1.0.beta.1_amd64.deb" @@ -262,20 +349,22 @@ def test_staging_and_checksums(self): with self.assertRaises(ValueError): prepare(source, root / "duplicate", "0.1.0-beta.1") - # Stable releases keep the six desktop downloads, even if the + # Stable releases keep the eight desktop downloads, even if the # input folder happens to contain test APKs from another step. (source / "linux/deb/flectar-mail_0.1.0~beta.1_amd64.deb").rename( source / "linux/deb/flectar-mail_0.1.0_amd64.deb" ) stable_dist = root / "stable" prepare(source, stable_dist, "0.1.0") - self.assertEqual(len(list(stable_dist.iterdir())), 7) + self.assertEqual(len(list(stable_dist.iterdir())), 9) self.assertTrue((stable_dist / "flectar-mail_0.1.0_amd64.deb").is_file()) self.assertFalse(list(stable_dist.glob("*.apk"))) def test_notes_distinguish_android_preview(self): self.assertIn("test-signed", release_assets.release_notes("0.1.0-beta.1")) self.assertIn("prereleases only", release_assets.release_notes("0.1.0")) + self.assertIn("does not receive Flathub updates", release_assets.release_notes("0.1.0-beta.1")) + self.assertIn("build-provenance attestations", release_assets.release_notes("0.1.0-beta.1")) def test_metadata_rejects_stale_android_lockfile(self): with tempfile.TemporaryDirectory() as directory: diff --git a/scripts/verify-android-apk.sh b/scripts/verify-android-apk.sh index 39b4a1d..e59d26b 100755 --- a/scripts/verify-android-apk.sh +++ b/scripts/verify-android-apk.sh @@ -71,6 +71,8 @@ if [[ "$archive_listing" != *'classes.dex'* ]]; then exit 1 fi +python3 "$project_dir/scripts/verify-android-native.py" "$apk" + signing="$($apksigner verify --verbose --print-certs "$apk")" printf '%s\n' "$signing" signing_args=("$signing_mode") diff --git a/scripts/verify-android-native.py b/scripts/verify-android-native.py new file mode 100644 index 0000000..29885b6 --- /dev/null +++ b/scripts/verify-android-native.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Verify packaged native ABI completeness and 16 KiB ELF load alignment.""" +import argparse +import struct +import zipfile + + +def verify_elf(data, name): + if data[:6] != b"\x7fELF\x02\x01": + raise ValueError(f"{name}: expected little-endian ELF64") + offset = struct.unpack_from(" len(data): + raise ValueError(f"{name}: invalid ELF program headers") + loads = 0 + for i in range(count): + entry = offset + i * entry_size + if struct.unpack_from("().invoke_context_changed(); + app.global::().invoke_context_changed(); } pub(super) fn update_connected_accounts( diff --git a/src/account_mail_preferences.rs b/src/account_mail_preferences.rs new file mode 100644 index 0000000..982292d --- /dev/null +++ b/src/account_mail_preferences.rs @@ -0,0 +1,800 @@ +//! Native account signature and OpenPGP controls. +use super::*; +use flectar_mail_core::{ + Core, + mail_security::{MailSecurity, OpenedMessage}, + models::{Settings, Signature, SignatureDefaults}, +}; +use slint::SharedString; +use std::{cell::Cell, future::Future}; + +/// Async work is owned by a profile/account generation. A removed account or +/// replaced core must never receive a late settings or decrypted-content result. +#[derive(Clone)] +struct UiWork { + runtime: Rc, + generation: Rc>, +} +impl UiWork { + fn spawn( + &self, + app: &AppWindow, + busy: bool, + work: impl Future> + Send + 'static, + apply: impl FnOnce(AppWindow, Result) + 'static, + ) { + if busy { + app.global::().set_busy(true); + } + let weak = app.as_weak(); + let generation = self.generation.clone(); + let expected = generation.get(); + let task = self.runtime.spawn(work); + let abort = task.abort_handle(); + if let Err(error) = slint::spawn_local(async move { + let result = task.await.unwrap_or_else(|e| Err(e.to_string())); + if generation.get() != expected { + return; + } + if let Some(app) = weak.upgrade() { + if busy { + app.global::().set_busy(false); + } + apply(app, result); + } + }) { + abort.abort(); + let ui = app.global::(); + if busy { + ui.set_busy(false); + } + ui.set_status(error.to_string().into()); + } + } +} + +async fn preferences(core: &Core) -> Result<(Settings, Vec), String> { + let settings = core.get_settings().await.map_err(|e| e.to_string())?; + let configs = core.list_account_configs().await.map_err(|e| e.to_string())?; + Ok((settings, configs)) +} + +fn core(state: &Rc>) -> Option> { + state + .borrow() + .core + .as_ref() + .map(|s| s.account_preferences_core()) +} +fn status(app: &AppWindow, result: Result<(), String>, success: &str) { + app.global::() + .set_status(match result { + Ok(()) => success.into(), + Err(e) => e.into(), + }); +} +fn choices(settings: &Settings, account_id: i64) -> Vec { + settings + .signature_list + .iter() + .filter(|s| s.account_id == account_id) + .cloned() + .collect() +} +fn project(app: &AppWindow, settings: &Settings, account_id: i64) { + let ui = app.global::(); + let signatures = choices(settings, account_id); + ui.set_signatures(ModelRc::new(VecModel::from( + signatures + .iter() + .map(|s| SignatureChoice { + id: s.id.clone().into(), + name: s.name.clone().into(), + text: flectar_mail_core::signatures::plain_text(s).into(), + }) + .collect::>(), + ))); + ui.set_signature_names(ModelRc::new(VecModel::from( + signatures + .iter() + .map(|s| SharedString::from(&s.name)) + .collect::>(), + ))); + let mut names = vec![SharedString::from("No signature")]; + names.extend(signatures.iter().map(|s| SharedString::from(&s.name))); + ui.set_default_names(ModelRc::new(VecModel::from(names))); + let defaults = settings + .signature_defaults + .get(&account_id.to_string()) + .cloned() + .unwrap_or_default(); + let index = |id: Option| { + signatures + .iter() + .position(|s| Some(&s.id) == id.as_ref()) + .map_or(0, |i| i as i32 + 1) + }; + ui.set_new_index(index(defaults.new_id)); + ui.set_reply_index(index(defaults.reply_id)); +} + +pub(super) fn register( + app: &AppWindow, + state: &Rc>, + runtime: &Rc, + document: &Rc>, + editor: &Rc>, +) { + let ui = app.global::(); + ui.set_desktop_supported(!cfg!(any(target_os = "android", target_os = "ios"))); + let tasks = UiWork { runtime: runtime.clone(), generation: Rc::new(Cell::new(0)) }; + let context_state = state.clone(); + let context_generation = tasks.generation.clone(); + let context = Rc::new(RefCell::new((core(state), state.borrow().connected_accounts.iter().map(|a| a.id).collect::>()))); + let weak = app.as_weak(); + ui.on_context_changed(move || { + let current_core = core(&context_state); + let accounts = context_state.borrow().connected_accounts.iter().map(|a| a.id).collect::>(); + let mut previous = context.borrow_mut(); + let same_core = match (&previous.0, ¤t_core) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + }; + if same_core && previous.1 == accounts { return; } + *previous = (current_core, accounts); + context_generation.set(context_generation.get().wrapping_add(1)); + let Some(app) = weak.upgrade() else { return; }; + let ui = app.global::(); + ui.set_busy(false); + ui.set_account_id(-1); + ui.set_account_label("".into()); + ui.set_signatures(ModelRc::default()); + ui.set_signature_names(ModelRc::default()); + ui.set_default_names(ModelRc::default()); + ui.invoke_edit_signature(-1); + ui.set_fingerprint("".into()); + ui.set_recipient_keys("".into()); + ui.set_key_inventory("".into()); + ui.set_status("".into()); + ui.invoke_close_reader(); + ui.invoke_composer_reset(); + }); + let weak = app.as_weak(); + let state_load = state.clone(); + let work = tasks.clone(); + ui.on_load(move |id| { + let Some(app) = weak.upgrade() else { return }; + let Some(core) = core(&state_load) else { + return; + }; + let ui = app.global::(); + if ui.get_busy() { + return; + } + work.spawn(&app, true, async move { preferences(&core).await }, move |app, result| { + let ui = app.global::(); + match result { + Ok((settings, configs)) => { + if let Some(config) = configs.iter().find(|c| c.id == i64::from(id)) { + ui.set_account_id(id); + ui.set_account_label(config.email.clone().into()); + ui.set_status("".into()); + ui.set_key_inventory("".into()); + project(&app, &settings, config.id); + ui.invoke_edit_signature(-1); + let p = &config.settings.security; + ui.set_fingerprint(p.signing_fingerprint.clone().into()); + ui.set_sign(p.sign_by_default); + ui.set_encrypt(p.require_encryption); + ui.set_recipient_keys( + p.recipient_keys + .iter() + .map(|(e, k)| format!("{e}={k}")) + .collect::>() + .join("\n") + .into(), + ); + } + } + Err(e) => ui.set_status(e.into()), + } + }); + }); + let weak = app.as_weak(); + ui.on_edit_signature(move |index| { + let Some(app) = weak.upgrade() else { return }; + let ui = app.global::(); + let signature = usize::try_from(index) + .ok() + .and_then(|i| ui.get_signatures().row_data(i)); + ui.set_editing_index(index); + ui.set_editing_id(signature.as_ref().map(|s| s.id.clone()).unwrap_or_default()); + ui.set_signature_name( + signature + .as_ref() + .map(|s| s.name.clone()) + .unwrap_or_default(), + ); + ui.set_signature_text(signature.map(|s| s.text).unwrap_or_default()); + }); + let weak = app.as_weak(); + let state_save = state.clone(); + let work = tasks.clone(); + ui.on_save_signature(move || { + let Some(app) = weak.upgrade() else { return }; + let Some(core) = core(&state_save) else { + return; + }; + let ui = app.global::(); + if ui.get_busy() { return; } + let signature = Signature { + id: ui.get_editing_id().to_string(), + account_id: i64::from(ui.get_account_id()), + name: ui.get_signature_name().to_string(), + html: flectar_mail_core::signatures::text_html(ui.get_signature_text().as_str()), + }; + work.spawn(&app, true, async move { + let saved = core.save_signature(signature).await.map_err(|e| e.to_string())?; + let settings = core.get_settings().await.map_err(|e| e.to_string())?; + Ok((saved, settings)) + }, |app, result| { + let ui = app.global::(); + match result { + Ok((saved, settings)) => { + project(&app, &settings, saved.account_id); + let index = choices(&settings, saved.account_id).iter().position(|s| s.id == saved.id).unwrap_or(0); + ui.invoke_edit_signature(index as i32); + ui.set_status("Signature saved.".into()); + } + Err(e) => ui.set_status(e.into()), + } + }); + }); + let weak = app.as_weak(); + let state_delete = state.clone(); + let work = tasks.clone(); + ui.on_delete_signature(move || { + let Some(app) = weak.upgrade() else { return }; + let Some(core) = core(&state_delete) else { + return; + }; + let ui = app.global::(); + if ui.get_busy() { return; } + let id = i64::from(ui.get_account_id()); + let signature = ui.get_editing_id().to_string(); + work.spawn(&app, true, async move { + core.delete_signature(id, signature).await.map_err(|e| e.to_string())?; + core.get_settings().await.map_err(|e| e.to_string()) + }, move |app, result| { + match result { + Ok(settings) => { + project(&app, &settings, id); + app.global::().invoke_edit_signature(-1); + status(&app, Ok(()), "Signature deleted; affected defaults cleared."); + } + Err(e) => status(&app, Err(e), ""), + } + }); + }); + let weak = app.as_weak(); + let state_defaults = state.clone(); + let work = tasks.clone(); + ui.on_save_defaults(move || { + let Some(app) = weak.upgrade() else { return }; + let Some(core) = core(&state_defaults) else { + return; + }; + let ui = app.global::(); + if ui.get_busy() { return; } + let id = |index: i32| { + usize::try_from(index - 1) + .ok() + .and_then(|i| ui.get_signatures().row_data(i)) + .map(|s| s.id.to_string()) + }; + let account = i64::from(ui.get_account_id()); + let defaults = SignatureDefaults { new_id: id(ui.get_new_index()), reply_id: id(ui.get_reply_index()) }; + work.spawn(&app, true, async move { + core.set_signature_defaults(account, defaults).await.map_err(|e| e.to_string()) + }, |app, result| status(&app, result, "Signature defaults saved.")); + }); + let weak = app.as_weak(); + let state_security = state.clone(); + let work = tasks.clone(); + ui.on_save_security(move || { + let Some(app) = weak.upgrade() else { return }; + let Some(core) = core(&state_security) else { + return; + }; + let ui = app.global::(); + if ui.get_busy() { + return; + } + let mut policy = MailSecurity { + signing_fingerprint: ui.get_fingerprint().to_string(), + sign_by_default: ui.get_sign(), + require_encryption: ui.get_encrypt(), + ..Default::default() + }; + for line in ui + .get_recipient_keys() + .lines() + .filter(|l| !l.trim().is_empty()) + { + let Some((email, key)) = line.split_once('=') else { + ui.set_status("Use one email=full-fingerprint per line.".into()); + return; + }; + if policy + .recipient_keys + .insert(email.trim().to_lowercase(), key.trim().to_owned()) + .is_some() + { + ui.set_status("Remove duplicate recipient addresses.".into()); + return; + } + } + let id = i64::from(ui.get_account_id()); + work.spawn(&app, true, async move { + core.set_mail_security(id, policy).await.map_err(|e| e.to_string()) + }, |app, result| status(&app, result, "OpenPGP settings saved.")); + }); + register_keys_and_reader(app, state, &tasks); + register_composer(app, state, &tasks, document, editor); +} + +fn show_message(app: &AppWindow, message: OpenedMessage, memory: &Arc>>) { + *memory.lock().unwrap() = message.mime; + let ui = app.global::(); + ui.set_reader_text(message.text.into()); + ui.set_reader_status(message.status.into()); + ui.set_reader_open(true); +} +fn register_keys_and_reader( + app: &AppWindow, + state: &Rc>, + tasks: &UiWork, +) { + let memory = Arc::new(std::sync::Mutex::new(Vec::::new())); + let reader_generation = Rc::new(Cell::new(0_u64)); + let ui = app.global::(); + let weak = app.as_weak(); + let work = tasks.clone(); + let opened = memory.clone(); + let generation = reader_generation.clone(); + ui.on_key_action(move |action| { + let Some(app) = weak.upgrade() else { return }; + let ui = app.global::(); + if ui.get_busy() || ui.get_account_id() < 0 { return; } + let key = ui.get_fingerprint().to_string(); + let email = ui.get_account_label().to_string(); + let opened = opened.clone(); + let generation = generation.clone(); + let expected = generation.get(); + work.spawn(&app, true, async move { key_action(action.as_str(), &key, &email).await }, move |app, result| { + let ui = app.global::(); + match result { + Ok(KeyResult::Generated(key)) => { ui.set_fingerprint(key.into()); ui.set_status("Key created. Back up your private key and revocation certificate in GnuPG, then save OpenPGP settings.".into()); } + Ok(KeyResult::Inventory(text)) => { + ui.set_key_inventory(text.into()); + ui.set_status("Keys loaded. Verify fingerprints before trusting recipient keys.".into()); + } + Ok(KeyResult::Message(message)) if generation.get() == expected => show_message(&app, message, &opened), + Ok(KeyResult::Message(_)) => {}, + Ok(KeyResult::Done(text)) => ui.set_status(text.into()), + Err(e) => ui.set_status(e.into()), + } + }); + }); + let weak = app.as_weak(); + let work = tasks.clone(); + let state = state.clone(); + let opened = memory.clone(); + let generation = reader_generation.clone(); + ui.on_read_message(move || { + let Some(app) = weak.upgrade() else { return }; + let Some(core) = core(&state) else { return }; + let Some((selected_id, thread_id)) = ({ + let state = state.borrow(); + state.selected_id.and_then(|id| state.messages.iter().find(|m| m.id == id)) + .and_then(|m| m.thread_id.map(|thread| (m.id, thread))) + }) else { return; }; + let ui = app.global::(); + if ui.get_busy() { return; } + ui.set_reader_text("".into()); + ui.set_reader_status("Opening OpenPGP message…".into()); + ui.set_reader_open(true); + *opened.lock().unwrap() = Vec::new(); + let opened = opened.clone(); + let state = state.clone(); + let generation = generation.clone(); + let expected = generation.get(); + work.spawn(&app, true, async move { + let detail = core.get_latest_thread_body(thread_id).await.map_err(|e| e.to_string())?; + core.open_openpgp_message(detail.id).await.map_err(|e| e.to_string()) + }, move |app, result| { + if generation.get() != expected || state.borrow().selected_id != Some(selected_id) { + return; + } + match result { + Ok(message) => show_message(&app, message, &opened), + Err(e) => app.global::().set_reader_status(e.into()), + } + }); + }); + let memory_close = memory.clone(); + let weak = app.as_weak(); + let generation = reader_generation.clone(); + ui.on_close_reader(move || { + generation.set(generation.get().wrapping_add(1)); + *memory_close.lock().unwrap() = Vec::new(); + if let Some(app) = weak.upgrade() { + let ui = app.global::(); + ui.set_reader_open(false); + ui.set_reader_text("".into()); + ui.set_reader_status("".into()); + } + }); + let weak = app.as_weak(); + let work = tasks.clone(); + ui.on_export_message(move || { + let Some(app) = weak.upgrade() else { return }; + if app.global::().get_busy() { return; } + let data = memory.lock().unwrap().clone(); + if data.is_empty() { return; } + let generation = reader_generation.clone(); + let expected = generation.get(); + work.spawn(&app, true, export_message(data), move |app, result| { + if generation.get() == expected && let Err(e) = result { + app.global::().set_reader_status(e.into()); + } + }); + }); +} + +// Mobile keeps the shared UI result interface, but key actions return an +// unsupported-platform error until a native OpenPGP backend is available. +#[cfg_attr(any(target_os = "android", target_os = "ios"), allow(dead_code))] +enum KeyResult { + Generated(String), + Inventory(String), + Message(OpenedMessage), + Done(String), +} +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn key_action(action: &str, fingerprint: &str, email: &str) -> Result { + let gpg = flectar_mail_core::mail_security::Gpg::default(); + match action { + "generate" => gpg + .generate_key(email) + .await + .map(KeyResult::Generated) + .map_err(|e| e.to_string()), + "list" => { + let secret = gpg.list_keys(true).await.map_err(|e| e.to_string())?; + let public = gpg.list_keys(false).await.map_err(|e| e.to_string())?; + let display = |listing: &str| { + listing + .lines() + .filter_map(|line| { + let f: Vec<_> = line.split(':').collect(); + match f.first().copied()? { + "pub" => Some("Public key".to_owned()), + "sec" => Some("Private key".to_owned()), + "fpr" => f.get(9).map(|s| format!(" {s}")), + "uid" => f.get(9).map(|s| { + format!(" {}", flectar_mail_core::mail_security::decode_colon(s)) + }), + _ => None, + } + }) + .collect::>() + .join("\n") + }; + Ok(KeyResult::Inventory(format!( + "{}\n\n{}", + display(&secret), + display(&public) + ))) + } + "import" | "open" => { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title(if action == "import" { + "Import OpenPGP key" + } else { + "Open OpenPGP message" + }) + .pick_file() + .await + else { + return Ok(KeyResult::Done("Cancelled.".into())); + }; + let limit = if action == "import" { + 1024 * 1024 + } else { + 100 * 1024 * 1024 + }; + let input = tokio::fs::File::open(file.path()) + .await + .map_err(|e| e.to_string())?; + use tokio::io::AsyncReadExt; + let mut data = Vec::new(); + input + .take(limit + 1) + .read_to_end(&mut data) + .await + .map_err(|e| e.to_string())?; + if data.len() as u64 > limit { + return Err("File exceeds the size limit.".into()); + } + if action == "import" { + gpg.import_key(&data).await.map_err(|e| e.to_string())?; + Ok(KeyResult::Done( + "Key imported. Verify its fingerprint before trusting it.".into(), + )) + } else { + gpg.open_message(&data, None) + .await + .map(KeyResult::Message) + .map_err(|e| e.to_string()) + } + } + "export" => { + let data = gpg + .export_public_key(fingerprint) + .await + .map_err(|e| e.to_string())?; + if let Some(file) = rfd::AsyncFileDialog::new() + .set_file_name("public-key.asc") + .save_file() + .await + { + file.write(&data).await.map_err(|e| e.to_string())?; + Ok(KeyResult::Done("Public key exported.".into())) + } else { + Ok(KeyResult::Done("Cancelled.".into())) + } + } + _ => Err("Unknown key action.".into()), + } +} +#[cfg(any(target_os = "android", target_os = "ios"))] +async fn key_action(_: &str, _: &str, _: &str) -> Result { + Err("OpenPGP requires desktop GnuPG.".into()) +} +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn export_message(data: Vec) -> Result<(), String> { + if let Some(file) = rfd::AsyncFileDialog::new() + .set_title("Save decrypted message (contains private content)") + .set_file_name("decrypted-message.eml") + .save_file() + .await + { + file.write(&data).await.map_err(|e| e.to_string())?; + } + Ok(()) +} +#[cfg(any(target_os = "android", target_os = "ios"))] +async fn export_message(_: Vec) -> Result<(), String> { + Err("Message export is unavailable on this platform.".into()) +} + +fn register_composer( + app: &AppWindow, + state: &Rc>, + tasks: &UiWork, + document: &Rc>, + editor: &Rc>, +) { + // Only replace the exact unedited block we inserted. User edits are never + // discarded when switching identity or selecting a different signature. + let inserted = Rc::new(RefCell::new(None::)); + let available = Rc::new(RefCell::new(Vec::::new())); + let ui = app.global::(); + let reset = inserted.clone(); + let generation = Rc::new(Cell::new(0_u64)); + let reset_generation = generation.clone(); + let reset_list = available.clone(); + let weak = app.as_weak(); + ui.on_composer_reset(move || { + reset_generation.set(reset_generation.get().wrapping_add(1)); + *reset.borrow_mut() = None; + reset_list.borrow_mut().clear(); + if let Some(app) = weak.upgrade() { + let ui = app.global::(); + ui.set_composer_signature_names(ModelRc::default()); + ui.set_composer_signature_index(0); + ui.set_composer_security("".into()); + ui.set_composer_preferences_ready(false); + } + }); + let weak = app.as_weak(); + let state = state.clone(); + let work = tasks.clone(); + let list = available.clone(); + let managed = inserted.clone(); + ui.on_composer_account_changed(move |account_id| { + let Some(app) = weak.upgrade() else { return }; + let ui = app.global::(); + ui.set_composer_preferences_ready(false); + ui.set_composer_signature_names(ModelRc::default()); + ui.set_composer_security("".into()); + let Some(core) = core(&state) else { return }; + generation.set(generation.get().wrapping_add(1)); + let generation = generation.clone(); + let expected = generation.get(); + let list = list.clone(); + let managed = managed.clone(); + work.spawn(&app, false, async move { preferences(&core).await }, move |app, result| { + if generation.get() != expected || app.get_compose_account_id() != account_id { return; } + let ui = app.global::(); + match result { + Ok((settings, configs)) => { + if !configs.iter().any(|c| c.id == i64::from(account_id)) { return; } + ui.set_composer_preferences_ready(true); + let signatures = choices(&settings, i64::from(account_id)); + let mut names = vec![SharedString::from("No signature")]; + names.extend(signatures.iter().map(|s| SharedString::from(&s.name))); + ui.set_composer_signature_names(ModelRc::new(VecModel::from(names))); + *list.borrow_mut() = signatures.clone(); + if let Some(config) = configs.iter().find(|c| c.id == i64::from(account_id)) { + let p = &config.settings.security; + ui.set_composer_security( + if p.require_encryption { + if p.sign_by_default { + "OpenPGP: encryption required · digitally signed" + } else { + "OpenPGP: encryption required" + } + } else if p.sign_by_default { + "OpenPGP: digitally signed · not encrypted" + } else { + "Message is not end-to-end encrypted" + } + .into(), + ); + } + if app.get_compose_mode() == "draft" { + *managed.borrow_mut() = None; + ui.set_composer_signature_index(0); + return; + } + // A cleared composer starts a fresh insertion lifecycle. + if app.get_compose_body().is_empty() { + *managed.borrow_mut() = None; + } + let defaults = settings + .signature_defaults + .get(&account_id.to_string()) + .cloned() + .unwrap_or_default(); + let id = if app.get_compose_mode() == "new" { + defaults.new_id + } else { + defaults.reply_id + }; + let index = signatures + .iter() + .position(|s| Some(&s.id) == id.as_ref()) + .map_or(0, |i| i as i32 + 1); + ui.invoke_composer_signature(index); + } + Err(e) => { + app.set_compose_notice(UiMessage::detail( + "Could not load account preferences: {}", + e, + )); + app.set_compose_notice_is_error(true); + } + } + }); + }); + let weak = app.as_weak(); + let document = document.clone(); + let editor = editor.clone(); + ui.on_composer_signature(move |index| { + let Some(app) = weak.upgrade() else { return }; let mut document = document.borrow_mut(); + let current = document.text().to_owned(); + let old = inserted.borrow().clone(); + let body = match remove_managed_signature(¤t, old.as_deref()) { + Ok(body) => body, + Err(()) if index == 0 => { + *inserted.borrow_mut() = None; + app.global::().set_composer_signature_index(0); + return; + } + Err(()) => { + app.set_compose_notice(UiMessage::plain("Your edited signature was preserved. Choose No signature to keep it as message text before inserting another.")); + app.set_compose_notice_is_error(true); return; + } + }; + let signature = usize::try_from(index - 1).ok().and_then(|i| available.borrow().get(i).cloned()); + let text = signature.as_ref().map(flectar_mail_core::signatures::plain_text).unwrap_or_default(); + let new_body = flectar_mail_core::signatures::insert(&body, &text); + *inserted.borrow_mut() = if text.is_empty() { None } else { Some(format!("\n\n-- \n{}", text.trim())) }; + let selection = document.synchronize(&new_body, 0, 0); + apply_rich_compose(&app, &document, selection, &mut editor.borrow_mut()); + app.global::().set_composer_signature_index(index); + }); +} + +fn remove_managed_signature(body: &str, managed: Option<&str>) -> Result { + match managed { + None => Ok(body.to_owned()), + Some(block) if body.matches(block).count() == 1 => Ok(body.replacen(block, "", 1)), + Some(_) if body.is_empty() => Ok(String::new()), + Some(_) => Err(()), + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn asynchronous_results_are_discarded_after_account_context_changes() { + use slint::platform::{EventLoopProxy, Platform, WindowAdapter}; + use std::sync::mpsc; + struct Proxy(mpsc::Sender>); + impl EventLoopProxy for Proxy { + fn quit_event_loop(&self) -> Result<(), slint::EventLoopError> { Ok(()) } + fn invoke_from_event_loop(&self, event: Box) -> Result<(), slint::EventLoopError> { + self.0.send(event).map_err(|_| slint::EventLoopError::EventLoopTerminated) + } + } + struct Headless(mpsc::Sender>); + impl Platform for Headless { + fn create_window_adapter(&self) -> Result, slint::PlatformError> { + Ok(slint::platform::software_renderer::MinimalSoftwareWindow::new( + slint::platform::software_renderer::RepaintBufferType::ReusedBuffer)) + } + fn new_event_loop_proxy(&self) -> Option> { Some(Box::new(Proxy(self.0.clone()))) } + } + struct Delivered(Arc); + impl Drop for Delivered { + fn drop(&mut self) { self.0.store(true, std::sync::atomic::Ordering::SeqCst); } + } + let (events, receiver) = mpsc::channel(); + slint::platform::set_platform(Box::new(Headless(events))).unwrap(); + let app = AppWindow::new().unwrap(); + let tasks = UiWork { + runtime: Rc::new(tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap()), + generation: Rc::new(Cell::new(0)), + }; + let (release, waiting) = tokio::sync::oneshot::channel(); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let payload = Delivered(dropped.clone()); + tasks.spawn(&app, true, async move { + waiting.await.unwrap(); + Ok(payload) + }, |_, _| panic!("removed account received private content")); + assert!(app.global::().get_busy()); + tasks.generation.set(1); + release.send(()).unwrap(); + while !dropped.load(std::sync::atomic::Ordering::SeqCst) { + receiver.recv_timeout(std::time::Duration::from_secs(5)).unwrap()(); + } + // A stale result must not release a newer operation's busy indicator. + assert!(app.global::().get_busy()); + let applied = Rc::new(Cell::new(false)); + let applied2 = applied.clone(); + tasks.spawn(&app, true, async { Ok(()) }, move |app, result| { + result.unwrap(); + assert!(!app.global::().get_busy()); + applied2.set(true); + }); + while !applied.get() { + receiver.recv_timeout(std::time::Duration::from_secs(5)).unwrap()(); + } + } + + #[test] + fn switching_signature_preserves_edits_and_rejects_ambiguous_replacement() { + let block = "\n\n-- \nZoë"; + assert_eq!( + remove_managed_signature(&format!("Reply{block}\n\n> Quote"), Some(block)), + Ok("Reply\n\n> Quote".into()) + ); + assert!(remove_managed_signature("Reply\n\n-- \nEdited", Some(block)).is_err()); + assert!(remove_managed_signature(&format!("{block}{block}"), Some(block)).is_err()); + } +} diff --git a/src/attachment_controller.rs b/src/attachment_controller.rs new file mode 100644 index 0000000..942e80d --- /dev/null +++ b/src/attachment_controller.rs @@ -0,0 +1,415 @@ +//! Mail attachment metadata and an independent dialog session. Files and Mail +//! share bounded decoders/PDF workers; opening Mail never changes Files state. +use crate::document_preview::{Preview, preview}; +use crate::{ + AppWindow, EmailReader, InboxState, MailAttachment, MailAttachments, mail::MailMessage, +}; +use slint::{ComponentHandle, ModelRc, Rgba8Pixel, SharedPixelBuffer, VecModel}; +use std::{ + cell::RefCell, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +#[cfg(any(target_os = "android", target_os = "ios"))] +static SUSPEND: std::sync::OnceLock> = std::sync::OnceLock::new(); +#[cfg(any(target_os = "android", target_os = "ios"))] +pub(crate) fn suspend() { + if let Some(suspend) = SUSPEND.get() { + suspend(); + } +} + +pub(crate) fn clear(app: &AppWindow) { + let ui = app.global::(); + ui.invoke_command("close".into(), "".into()); + ui.set_rows(ModelRc::default()); +} +pub(crate) fn project(app: &AppWindow, email: &MailMessage, same: bool) { + if !same { + clear(app); + } + let rows = attachment_rows(&email.attachments); + app.global::() + .set_rows(ModelRc::new(VecModel::from(rows))); +} +fn attachment_rows( + attachments: &[flectar_mail_core::models::AttachmentMeta], +) -> Vec { + attachments + .iter() + .filter(|a| { + !a.is_inline + || a.filename + .as_ref() + .is_some_and(|name| !name.trim().is_empty()) + }) + .map(|a| { + let name = a + .filename + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "attachment".into()); + let media = a + .mime_type + .as_deref() + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + MailAttachment { + id: a.id.to_string().into(), + name: name.clone().into(), + detail: media.clone().into(), + previewable: media == "application/pdf" + || media.starts_with("image/") + || media.starts_with("text/") + || matches!(media.as_str(), "application/json" | "application/xml") + || name.to_ascii_lowercase().ends_with(".pdf"), + } + }) + .collect::>() +} + +struct Session { + task: Option, + document: Option>>, + selected: Option, +} +impl Session { + fn stop(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); + } + self.document = None; + self.selected = None; + } +} +impl Drop for Session { + fn drop(&mut self) { + self.stop(); + } +} + +fn close(ui: &MailAttachments<'_>, generation: &AtomicU64, session: &RefCell) { + generation.fetch_add(1, Ordering::SeqCst); + session.borrow_mut().stop(); + ui.set_open(false); + ui.set_busy(false); + ui.set_image(Default::default()); + ui.set_text("".into()); + ui.set_status("".into()); + ui.set_pages(0); + ui.set_is_image(false); +} + +pub(crate) fn register( + app: &AppWindow, + inbox: &Rc>, + runtime: &Rc, + documents: Arc, +) { + let session = Rc::new(RefCell::new(Session { + task: None, + document: None, + selected: None, + })); + let generation = Arc::new(AtomicU64::new(0)); + let weak = app.as_weak(); + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let weak = weak.clone(); + let _ = SUSPEND.set(Box::new(move || { + let _ = weak.upgrade_in_event_loop(|app| { + app.global::() + .invoke_command("close".into(), "".into()) + }); + })); + } + // Profile IDs are not globally unique: compare Core identity as well as + // message identity before allowing an old session to remain actionable. + let context = Rc::new(RefCell::new(None::<(Arc, i32)>)); + let timer = slint::Timer::default(); + let state = inbox.clone(); + let observed = context.clone(); + let window = weak.clone(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(100), + move || { + let Some(app) = window.upgrade() else { + return; + }; + let current = state.borrow().core.as_ref().map(|core| { + ( + core.file_core(), + app.global::().get_message_id(), + ) + }); + let changed = match (&*observed.borrow(), ¤t) { + (Some((a, id)), Some((b, next))) => !Arc::ptr_eq(a, b) || id != next, + (None, None) => false, + _ => true, + }; + if changed { + app.global::() + .invoke_command("close".into(), "".into()); + *observed.borrow_mut() = current; + } + }, + ); + let (sender, mut receiver) = tokio::sync::mpsc::channel::<( + u64, + i32, + Arc, + Result, String>, + )>(2); + let state = inbox.clone(); + let session_for_delivery = session.clone(); + let current = generation.clone(); + let window = weak.clone(); + app.global::().on_deliver(move || { + let Some(app) = window.upgrade() else { + return; + }; + let session = &session_for_delivery; + while let Ok((revision, message, core, result)) = receiver.try_recv() { + if current.load(Ordering::SeqCst) != revision + || app.global::().get_message_id() != message + { + continue; + } + + if !state + .borrow() + .core + .as_ref() + .is_some_and(|c| Arc::ptr_eq(&core, &c.file_core())) + { + continue; + } + + let ui = app.global::(); + ui.set_busy(false); + match result { + Ok(Some(Preview::Pdf(data, page))) => { + session.borrow_mut().document = Some(data); + ui.set_page(page.index as i32); + ui.set_pages(page.count as i32); + ui.set_image(slint::Image::from_rgba8( + SharedPixelBuffer::::clone_from_slice( + &page.pixels, + page.width, + page.height, + ), + )); + ui.set_is_image(true); + } + Ok(Some(Preview::Image(data, w, h))) => { + ui.set_image(slint::Image::from_rgba8( + SharedPixelBuffer::::clone_from_slice(&data, w, h), + )); + ui.set_is_image(true); + } + Ok(Some(Preview::Text(text))) => ui.set_text(text.into()), + Ok(None) => close(&ui, ¤t, session), + Err(error) => ui.set_status(error.into()), + } + } + }); + let state = inbox.clone(); + let handle = runtime.handle().clone(); + app.global::() + .on_command(move |action, value| { + let _keep_timer = &timer; + let Some(app) = weak.upgrade() else { + return; + }; + let ui = app.global::(); + if action == "close" { + close(&ui, &generation, &session); + return; + } + if ui.get_busy() { + return; + } + let Some(core) = state.borrow().core.as_ref().map(|core| core.file_core()) else { + return; + }; + let message = app.global::().get_message_id(); + // A profile switch may happen before the lifecycle timer's next + // tick. Never use a retained attachment ID with a different Core. + if (action == "page" || action == "download-current") + && !context + .borrow() + .as_ref() + .is_some_and(|(owner, id)| Arc::ptr_eq(owner, &core) && *id == message) + { + close(&ui, &generation, &session); + return; + } + let selected = if action == "page" || action == "download-current" { + session.borrow().selected.clone() + } else { + value.parse::().ok().and_then(|id| { + state + .borrow() + .messages + .iter() + .find(|m| m.id == message) + .and_then(|m| m.attachments.iter().find(|a| a.id == id)) + .cloned() + }) + }; + let Some(selected) = selected else { + return; + }; + let page = value.parse::().unwrap_or(0); + if action == "page" && page >= ui.get_pages().max(0) as u32 { + return; + } + if !matches!( + action.as_str(), + "preview" | "download" | "download-current" | "page" + ) { + return; + } + let revision = generation.fetch_add(1, Ordering::SeqCst) + 1; + let data = session.borrow().document.clone(); + session.borrow_mut().selected = Some(selected.clone()); + *context.borrow_mut() = Some((core.clone(), message)); + if action != "page" { + ui.set_name( + selected + .filename + .clone() + .unwrap_or_else(|| "attachment".into()) + .into(), + ); + ui.set_pages(0); + ui.set_image(Default::default()); + ui.set_text("".into()); + ui.set_is_image(false); + ui.set_revision(ui.get_revision().wrapping_add(1)); + session.borrow_mut().document = None; + } + ui.set_open(true); + ui.set_busy(true); + ui.set_status("".into()); + let weak = weak.clone(); + let sender = sender.clone(); + let documents = documents.clone(); + let task = handle.spawn(async move { + let result: Result, String> = async { + if action == "page" { + let data = data.ok_or("PDF preview has been closed")?; + return crate::pdf_preview::render(data.clone(), page, 200) + .await + .map(|page| Some(Preview::Pdf(data, page))); + } + let download = action == "download" || action == "download-current"; + if !download && selected.size.is_some_and(|size| size > 16 * 1024 * 1024) { + return Err( + "This attachment is too large to preview. Download it to view locally." + .into(), + ); + } + let path = core + .get_attachment(selected.id) + .await + .map_err(|e| e.to_string())?; + if download { + documents + .export( + path.into(), + crate::documents::safe_name( + selected.filename.as_deref().unwrap_or("attachment"), + ), + ) + .await?; + return Ok(None); + } + use tokio::io::AsyncReadExt; + let file = tokio::fs::File::open(path) + .await + .map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + file.take(16 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .await + .map_err(|e| e.to_string())?; + if bytes.len() > 16 * 1024 * 1024 { + return Err( + "This attachment is too large to preview. Download it to view locally." + .into(), + ); + } + preview( + bytes, + selected + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"), + ) + .await + .map(Some) + } + .await; + if sender.send((revision, message, core, result)).await.is_ok() { + let _ = weak.upgrade_in_event_loop(|app| { + app.global::().invoke_deliver() + }); + } + }); + session.borrow_mut().task = Some(task.abort_handle()); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn attachment_rows_preserve_large_ids_and_offer_safe_previews() { + use flectar_mail_core::models::AttachmentMeta; + let files = vec![ + AttachmentMeta { + id: i64::MAX, + filename: Some("Report.PDF".into()), + mime_type: None, + size: Some(1024), + is_inline: false, + }, + AttachmentMeta { + id: 2, + filename: Some("photo.png".into()), + mime_type: Some("image/png".into()), + size: Some(42), + is_inline: false, + }, + AttachmentMeta { + id: 3, + filename: None, + mime_type: Some("image/png".into()), + size: Some(42), + is_inline: true, + }, + AttachmentMeta { + id: 4, + filename: Some("archive.zip".into()), + mime_type: Some("application/zip".into()), + size: Some(42), + is_inline: false, + }, + ]; + let rows = attachment_rows(&files); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].id.as_str(), i64::MAX.to_string()); + assert!(rows[0].previewable && rows[1].previewable); + assert!(!rows[2].previewable); + } +} diff --git a/src/browser.rs b/src/browser.rs new file mode 100644 index 0000000..e5c02e5 --- /dev/null +++ b/src/browser.rs @@ -0,0 +1,144 @@ +//! Open the system browser without leaking AppImage libraries into host programs. +#[cfg(target_os = "linux")] +use std::process::{Command, Stdio}; + +#[cfg(target_os = "linux")] +fn host_command(program: &str) -> Command { + let mut command = Command::new(program); + restore_library_path( + &mut command, + std::env::var_os("FLECTAR_MAIL_BUNDLED").is_some(), + std::env::var_os("FLECTAR_MAIL_HOST_LD_LIBRARY_PATH"), + ); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command +} + +#[cfg(target_os = "linux")] +fn restore_library_path(command: &mut Command, bundled: bool, path: Option) { + if !bundled { + return; + } + match path.filter(|p| !p.is_empty()) { + Some(path) => { + command.env("LD_LIBRARY_PATH", path); + } + None => { + command.env_remove("LD_LIBRARY_PATH"); + } + } +} + +#[cfg(target_os = "linux")] +fn launch(mut command: Command) -> Result<(), String> { + let mut child = command.spawn().map_err(|e| e.to_string())?; + // xdg-open may exit before the browser starts, or live as long as the browser. + // Detect immediate failures, but never block authorization on a browser exit. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => return Err(format!("browser launcher exited with {status}")), + Err(error) => return Err(error.to_string()), + Ok(None) if std::time::Instant::now() >= deadline => { + std::thread::spawn(move || { + let _ = child.wait(); + }); + return Ok(()); + } + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(25)), + } + } +} + +#[cfg(target_os = "linux")] +fn open_with_desktop_portal(address: &url::Url) -> Result<(), String> { + let address = address.clone(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + runtime.block_on(async move { + tokio::time::timeout(std::time::Duration::from_secs(5), async move { + let request = ashpd::desktop::open_uri::OpenFileRequest::default() + .send_uri(&address) + .await + .map_err(|error| error.to_string())?; + request.response().map_err(|error| error.to_string()) + }) + .await + .map_err(|_| "desktop portal did not respond".to_owned())? + }) +} + +pub(super) fn open(url: &str) -> Result<(), String> { + // This entry point is only for provider authorization, never a shell command. + let address = url::Url::parse(url).map_err(|_| "Sign-in address is invalid".to_owned())?; + if address.scheme() != "https" || address.host_str().is_none() { + return Err("Sign-in requires an HTTPS address".into()); + } + let url = address.as_str(); + #[cfg(target_os = "linux")] + { + // The portal is the reliable route from AppImages and desktop + // containers to the user's host browser. Keep command-based fallbacks + // for minimal desktops that do not run xdg-desktop-portal. + if open_with_desktop_portal(&address).is_ok() { + return Ok(()); + } + let mut xdg = host_command("xdg-open"); + xdg.arg(url); + if launch(xdg).is_ok() { + return Ok(()); + } + let mut gio = host_command("gio"); + gio.args(["open", url]); + launch(gio).map_err(|_| "Could not open the default browser".into()) + } + #[cfg(not(target_os = "linux"))] + webbrowser::open(url).map_err(|e| e.to_string()) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + #[test] + fn detects_launcher_failure() { + let mut command = Command::new("sh"); + command.args(["-c", "exit 3"]); + assert!(launch(command).is_err()); + } + #[test] + fn detects_launcher_success() { + let mut command = Command::new("sh"); + command.args(["-c", "exit 0"]); + assert!(launch(command).is_ok()); + } + #[test] + fn rejects_non_https_authorization() { + assert!(open("file:///tmp/test").is_err()); + assert!(open("--help").is_err()); + assert!(open("https://").is_err()); + } + #[test] + fn restores_host_libraries_only_for_bundled_builds() { + let mut command = Command::new("xdg-open"); + restore_library_path(&mut command, false, None); + assert_eq!(command.get_envs().count(), 0); + restore_library_path(&mut command, true, Some("/host/lib".into())); + assert_eq!(command.get_envs().next().unwrap().1.unwrap(), "/host/lib"); + restore_library_path(&mut command, true, Some("".into())); + assert_eq!(command.get_envs().next().unwrap().1, None); + } + #[test] + fn foreground_browser_does_not_hold_up_authorization() { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 2"]); + let start = std::time::Instant::now(); + assert!(launch(command).is_ok()); + assert!(start.elapsed() < std::time::Duration::from_millis(1800)); + } +} diff --git a/src/compose_controller.rs b/src/compose_controller.rs index 329f17f..6f90003 100644 --- a/src/compose_controller.rs +++ b/src/compose_controller.rs @@ -421,6 +421,7 @@ pub(super) fn clear_compose( contacts: &Rc>>, ) { files.borrow_mut().clear(); + app.global::().invoke_composer_reset(); document.borrow_mut().reset(); editor.borrow_mut().reset(); contacts.borrow_mut().clear(); diff --git a/src/data_controller.rs b/src/data_controller.rs index 437f6d9..c09997e 100644 --- a/src/data_controller.rs +++ b/src/data_controller.rs @@ -212,9 +212,7 @@ pub(super) fn register_data_management_callbacks( let display_path = path.display().to_string(); let message = match core.create_database_snapshot(path).await { Ok(_) => UiMessage::detail("Database snapshot exported to {}.", display_path), - Err(error) => { - UiMessage::detail("Could not export database snapshot: {}", error) - } + Err(error) => UiMessage::detail("Could not export database snapshot: {}", error), }; let _ = updates .send(UiTaskUpdate { @@ -223,6 +221,7 @@ pub(super) fn register_data_management_callbacks( calendar_connections: None, calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: false, close_to_tray: None, }) @@ -415,9 +414,9 @@ pub(super) fn register_data_management_callbacks( app.set_remote_images_enabled( settings.load_remote_images && cfg!(feature = "remote-content"), ); - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(any(target_os = "android", target_os = "ios", feature = "flatpak")))] app.set_close_to_tray(settings.close_to_tray); - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(any(target_os = "android", target_os = "ios", feature = "flatpak"))] app.set_close_to_tray(false); app.set_sync_status(if imported == 0 { UiMessage::detail( @@ -496,9 +495,7 @@ pub(super) fn register_data_management_callbacks( data_reset_contacts_loaded.set(true); data_reset_contacts_loading.set(false); app.set_contact_loading_more(false); - app.set_contact_list_revision( - app.get_contact_list_revision().wrapping_add(1), - ); + app.set_contact_list_revision(app.get_contact_list_revision().wrapping_add(1)); apply_contact_directory(&app, &data_reset_contacts); let today = Local::now().date_naive(); { @@ -523,12 +520,10 @@ pub(super) fn register_data_management_callbacks( "All local Flectar Mail data was deleted.", )); } - Err(error) => { - app.set_sync_status(UiMessage::detail( - "Could not delete all local data: {}", - error, - )) - } + Err(error) => app.set_sync_status(UiMessage::detail( + "Could not delete all local data: {}", + error, + )), } } }); @@ -546,9 +541,7 @@ pub(super) fn register_data_management_callbacks( )); return; }; - app.set_sync_status(UiMessage::plain( - "Deleting local accounts and cached data…", - )); + app.set_sync_status(UiMessage::plain("Deleting local accounts and cached data…")); let updates = data_reset_tx.clone(); runtime_for_data_reset.spawn(async move { let result = core.delete_all_local_data().await; diff --git a/src/desktop.rs b/src/desktop.rs index a9c710f..ad269d5 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -1,5 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() -> Result<(), Box> { + #[cfg(not(target_os = "ios"))] + if let Some(code) = flectar_mail::pdf_preview::run_worker_if_requested() { + std::process::exit(code); + } flectar_mail::run(flectar_mail::PlatformContext::desktop()?) } diff --git a/src/document_preview.rs b/src/document_preview.rs new file mode 100644 index 0000000..e3488de --- /dev/null +++ b/src/document_preview.rs @@ -0,0 +1,102 @@ +//! Shared bounded file decoding used by Mail attachment dialogs and Files. +use std::sync::Arc; +// Cancelling spawn_blocking does not stop an active decoder. Keep admission +// inside the worker until decoding really ends, and never queue more images. +static IMAGE_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); +type Result = std::result::Result; +pub(crate) enum Preview { + Text(String), + Image(Vec, u32, u32), + Pdf(Arc>, crate::pdf_preview::Page), +} +pub(crate) async fn preview(data: Vec, media: &str) -> Result { + let normalized = media + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + let media = normalized.as_str(); + if media + .split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("application/pdf") + || data.starts_with(b"%PDF-") + { + let data = Arc::new(data); + return crate::pdf_preview::render(data.clone(), 0, 200) + .await + .map(|page| Preview::Pdf(data, page)); + } + if media.starts_with("image/") && media != "image/svg+xml" { + if data.len() > 16 * 1024 * 1024 { + return Ok(Preview::Text( + "This image is too large to preview. Download it to view locally.".into(), + )); + } + return decode_image(move || { + let mut reader = image::ImageReader::new(std::io::Cursor::new(data)) + .with_guessed_format() + .map_err(|e| e.to_string())?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let image = reader + .decode() + .map_err(|_| { + "Could not decode this image safely. Download it to view locally.".to_string() + })? + .thumbnail(1800, 1400) + .into_rgba8(); + let (w, h) = image.dimensions(); + Ok(Preview::Image(image.into_raw(), w, h)) + }) + .await; + } + if (media.starts_with("text/") || matches!(media, "application/json" | "application/xml")) + && data.len() <= 1024 * 1024 + { + return Ok(Preview::Text(String::from_utf8_lossy(&data).into_owned())); + } + Ok(Preview::Text("Preview is available for PDF, images and text files. Download this file to view it in a local application.".into())) +} + +async fn decode_image(work: impl FnOnce() -> Result + Send + 'static) -> Result { + let permit = IMAGE_SLOT.try_acquire().map_err(|_| { + "Another image is still being decoded. Try again shortly.".to_string() + })?; + tokio::task::spawn_blocking(move || { + let _permit = permit; + work() + }).await.map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cancelling_a_preview_does_not_admit_another_decoder_until_the_worker_exits() { + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap(); + runtime.block_on(async { + let (started, wait_started) = tokio::sync::oneshot::channel(); + let (release, wait_release) = std::sync::mpsc::channel(); + let job = tokio::spawn(decode_image(move || { + started.send(()).unwrap(); + wait_release.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + Ok(Preview::Text("finished".into())) + })); + wait_started.await.unwrap(); + job.abort(); + assert!(job.await.err().unwrap().is_cancelled()); + assert!(decode_image(|| panic!("second decoder started")).await.is_err()); + release.send(()).unwrap(); + let permit = tokio::time::timeout(std::time::Duration::from_secs(5), IMAGE_SLOT.acquire()).await.unwrap().unwrap(); + drop(permit); + assert!(decode_image(|| Ok(Preview::Text("next".into()))).await.is_ok()); + }); + } +} diff --git a/src/documents.rs b/src/documents.rs new file mode 100644 index 0000000..22032fd --- /dev/null +++ b/src/documents.rs @@ -0,0 +1,181 @@ +//! Native document access boundary. Providers import into private staging and +//! export completed files; content-provider URIs never reach filesystem APIs. +use std::{future::Future, path::PathBuf, pin::Pin, sync::Arc}; +pub type DocumentFuture = Pin> + Send>>; +/// Owns a native import copy until Core has staged it. Dropping a cancelled +/// request or an unread channel result must release the copy as well. +pub struct ImportedDocument { + path: PathBuf, + cleanup: Option>, +} +impl ImportedDocument { + pub fn source(path: PathBuf) -> Self { + Self { + path, + cleanup: None, + } + } + pub fn temporary(path: PathBuf, cleanup: impl FnOnce(PathBuf) + Send + 'static) -> Self { + Self { + path, + cleanup: Some(Box::new(cleanup)), + } + } + pub fn path(&self) -> &std::path::Path { + &self.path + } +} +impl Drop for ImportedDocument { + fn drop(&mut self) { + if let Some(cleanup) = self.cleanup.take() { + cleanup(self.path.clone()); + } + } +} +/// Runs cancellation even when a task is aborted while awaiting a picker. +pub struct DocumentRequestGuard(pub Box); +impl Drop for DocumentRequestGuard { + fn drop(&mut self) { + (std::mem::replace(&mut self.0, Box::new(|| {})))(); + } +} +pub trait DocumentProvider: Send + Sync { + fn import(&self) -> DocumentFuture>; + fn export(&self, path: PathBuf, name: String) -> DocumentFuture; +} +pub fn default_provider() -> Arc { + #[cfg(target_os = "ios")] + { + Arc::new(crate::ios_documents::IosDocuments) + } + #[cfg(not(target_os = "ios"))] + { + Arc::new(DesktopDocuments) + } +} +pub(crate) fn safe_name(name: &str) -> String { + if flectar_mail_core::files::validate_name(name).is_ok() { + name.into() + } else { + "download".into() + } +} + +struct DesktopDocuments; +impl DocumentProvider for DesktopDocuments { + fn import(&self) -> DocumentFuture> { + Box::pin(async { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + Ok(rfd::AsyncFileDialog::new() + .set_title("Upload file") + .pick_file() + .await + .map(|f| ImportedDocument::source(f.path().to_owned()))) + } + #[cfg(any(target_os = "android", target_os = "ios"))] + { + Err("The native document provider is not initialized.".into()) + } + }) + } + fn export(&self, path: PathBuf, name: String) -> DocumentFuture { + Box::pin(async move { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title("Download file") + .set_file_name(name) + .save_file() + .await + else { + return Ok(false); + }; + flectar_mail_core::files::save_cached_file(&path, file.path()) + .await + .map_err(|e| e.to_string())?; + Ok(true) + } + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = (path, name); + Err("The native document provider is not initialized.".into()) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn imported(path: PathBuf, drops: Arc) -> ImportedDocument { + ImportedDocument::temporary(path, move |path| { + std::fs::remove_file(path).unwrap(); + drops.fetch_add(1, Ordering::SeqCst); + }) + } + + #[tokio::test] + async fn cancellation_releases_delivered_but_unread_import() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("copy"); + std::fs::write(&path, "private attachment").unwrap(); + let drops = Arc::new(AtomicUsize::new(0)); + let (sender, receiver) = tokio::sync::oneshot::channel(); + assert!(sender.send(imported(path.clone(), drops.clone())).is_ok()); + drop(receiver); + assert!(!path.exists()); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn late_result_releases_import_after_cancellation() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("copy"); + std::fs::write(&path, "private attachment").unwrap(); + let drops = Arc::new(AtomicUsize::new(0)); + let (sender, receiver) = tokio::sync::oneshot::channel(); + drop(receiver); + drop(sender.send(imported(path.clone(), drops.clone()))); + assert!(!path.exists()); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn aborting_task_cancels_request_and_releases_staging_source() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("copy"); + std::fs::write(&path, "private attachment").unwrap(); + let drops = Arc::new(AtomicUsize::new(0)); + let document = imported(path.clone(), drops.clone()); + let cancelled = Arc::new(AtomicUsize::new(0)); + let cancellation = cancelled.clone(); + let guard = DocumentRequestGuard(Box::new(move || { + cancellation.fetch_add(1, Ordering::SeqCst); + })); + let (ready, started) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let _document = document; + let _guard = guard; + ready.send(()).unwrap(); + std::future::pending::<()>().await; + }); + started.await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert!(!path.exists()); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(cancelled.load(Ordering::SeqCst), 1); + } + + #[test] + fn desktop_source_survives_document_drop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("user-original"); + std::fs::write(&path, "original").unwrap(); + drop(ImportedDocument::source(path.clone())); + assert_eq!(std::fs::read_to_string(path).unwrap(), "original"); + } +} diff --git a/src/files_controller.rs b/src/files_controller.rs new file mode 100644 index 0000000..84251b9 --- /dev/null +++ b/src/files_controller.rs @@ -0,0 +1,654 @@ +//! Files owns one serialized async session. UI callbacks only capture input and +//! schedule work; protocol, database, dialogs and disk I/O never block Slint. +use crate::document_preview::{Preview, preview}; +use crate::documents::safe_name; +use crate::{AppWindow, FileRow, FilesUi, InboxState, OperationRow}; +use flectar_mail_core::files::{ + self, FileClient, + service::{ + Entry, FilesService as Directory, Output as ServiceOutput, Preview as ServicePreview, + }, +}; +use slint::{ComponentHandle, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, VecModel}; +use std::{cell::RefCell, rc::Rc, sync::Arc}; +use tokio::sync::Mutex; + +struct SyncWorker(tokio::task::JoinHandle<()>); +impl Drop for SyncWorker { + fn drop(&mut self) { + self.0.abort(); + } +} +#[cfg(any(target_os = "android", target_os = "ios"))] +static SUSPEND: std::sync::OnceLock> = std::sync::OnceLock::new(); + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub(crate) fn suspend() { + crate::pdf_preview::cancel_active(); + if let Some(suspend) = SUSPEND.get() { + suspend(); + } +} + +pub(crate) fn register( + app: &AppWindow, + state: &Rc>, + runtime: &Rc, + documents: Arc, + scratch_root: std::path::PathBuf, +) { + app.global::().set_pdf_supported(true); + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let weak = app.as_weak(); + let _ = SUSPEND.set(Box::new(move || { + let _ = weak.upgrade_in_event_loop(|app| { + let ui = app.global::(); + ui.invoke_command("release-preview".into(), "".into(), "".into()); + ui.set_preview_open(false); + }); + })); + #[cfg(target_os = "ios")] + unsafe { + install_pdf_lifecycle_observers(); + } + } + let directory = Arc::new(Mutex::new(( + (0usize, 0u64), + Directory { + attachments: true, + ..Default::default() + }, + ))); + let pdf_document = Arc::new(std::sync::Mutex::new(None::>>)); + let profile_pdf = pdf_document.clone(); + let weak = app.as_weak(); + let inbox = Rc::clone(state); + let handle = runtime.handle().clone(); + let worker = Rc::new(RefCell::new( + None::<(Arc, SyncWorker)>, + )); + let preview_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let pending = Rc::new(RefCell::new(None::)); + let generation = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let account_epoch = Rc::new(std::cell::Cell::new(0_u64)); + let account_state = state.clone(); + let account_pending = pending.clone(); + let account_generation = generation.clone(); + let account_pdf = pdf_document.clone(); + let epoch = account_epoch.clone(); + let previous_accounts = Rc::new(RefCell::new(Vec::::new())); + let window = app.as_weak(); + app.global::().on_context_changed(move || { + let ids = account_state.borrow().connected_accounts.iter().map(|a| a.id).collect::>(); + if *previous_accounts.borrow() == ids { return; } + *previous_accounts.borrow_mut() = ids; + epoch.set(epoch.get().wrapping_add(1)); + account_generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some(task) = account_pending.borrow_mut().take() { task.abort(); } + *account_pdf.lock().unwrap() = None; + if let Some(app) = window.upgrade() { reset_profile(&app.global::()); } + }); + let profile_pending = pending.clone(); + let profile_generation = generation.clone(); + let profile_ui = weak.clone(); + let worker_timer = slint::Timer::default(); + let worker_state = inbox.clone(); + let worker_slot = worker.clone(); + let worker_runtime = handle.clone(); + worker_timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(500), + move || { + let core = worker_state.borrow().core.as_ref().map(|c| c.file_core()); + if let Some(core) = core { + if worker_slot + .borrow() + .as_ref() + .is_none_or(|(prior, _)| !Arc::ptr_eq(prior, &core)) + { + profile_generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some(task) = profile_pending.borrow_mut().take() { + task.abort(); + } + if let Some(app) = profile_ui.upgrade() { + reset_profile(&app.global::()); + *profile_pdf.lock().unwrap() = None; + } + *worker_slot.borrow_mut() = Some(( + core.clone(), + SyncWorker(worker_runtime.spawn(files::sync::run((*core).clone()))), + )); + } + } else if worker_slot.borrow_mut().take().is_some() { + profile_generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some(task) = profile_pending.borrow_mut().take() { + task.abort(); + } + if let Some(app) = profile_ui.upgrade() { + reset_profile(&app.global::()); + *profile_pdf.lock().unwrap() = None; + } + } + }, + ); + let progress = Arc::new(files::progress::Progress::default()); + let progress_timer = slint::Timer::default(); + let observed = progress.clone(); + let progress_ui = weak.clone(); + progress_timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(100), + move || { + if let Some(app) = progress_ui.upgrade() { + let ui = app.global::(); + if ui.get_busy() { + let p = observed.snapshot(); + let phase = match p.phase { + 1 => "Uploading", + 2 => "Downloading", + 3 => "Copying", + _ => "Working", + }; + ui.set_transfer_status( + if p.phase == 0 { + String::new() + } else if p.total > 0 { + format!("{phase} · {} of {}", size(p.done), size(p.total)) + } else { + format!("{phase} · {}", size(p.done)) + } + .into(), + ); + } + } + }, + ); + + app.global::().on_command(move |action, a, b| { + let _keep_worker_timer_alive = (&worker_timer,&progress_timer); + let Some(app) = weak.upgrade() else { + return; + }; + let ui = app.global::(); + if action.as_str() == "release-preview" { + if preview_pending.swap(false, std::sync::atomic::Ordering::SeqCst) { + generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some(task) = pending.borrow_mut().take() { task.abort(); } + ui.set_busy(false); + } + *pdf_document.lock().unwrap() = None; + ui.set_pdf_pages(0); + ui.set_preview_image(slint::Image::default()); + ui.set_preview_text("".into()); + return; + } + if action.as_str() == "cancel" { + generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some(task) = pending.borrow_mut().take() { + task.abort(); + } + ui.set_busy(false); + if preview_pending.swap(false, std::sync::atomic::Ordering::SeqCst) { + ui.set_status("".into()); + return; + } + ui.set_selected(-1); + ui.set_can_create(false); + ui.set_can_more(false); + ui.set_status( + "Cancelled. Refresh storage before retrying; a server change may have completed." + .into(), + ); + return; + } + if ui.get_busy() { + return; + } + let Some(core) = inbox.borrow().core.as_ref().map(|c| c.file_core()) else { + ui.set_status("Mail is still starting. Try again shortly.".into()); + return; + }; + let identity = (Arc::as_ptr(&core) as usize, account_epoch.get()); + if worker + .borrow() + .as_ref() + .is_none_or(|(prior, _)| !Arc::ptr_eq(prior, &core)) + { + *worker.borrow_mut() = Some(( + core.clone(), + SyncWorker(handle.spawn(files::sync::run((*core).clone()))), + )); + } + if matches!(action.as_str(), "scope" | "account" | "account-id" | "space" | "load" | "search" | "filter" | "refresh" | "enter" | "up" | "attachment-source") { + ui.set_dialog("".into()); + ui.set_preview_open(false); + ui.set_preview_image(slint::Image::default()); + ui.set_pdf_pages(0); + *pdf_document.lock().unwrap() = None; + } + if matches!(action.as_str(), "preview" | "details" | "notifications" | "clear-notifications") { + ui.set_preview_image(slint::Image::default()); + ui.set_preview_text("".into()); + ui.set_preview_is_image(false); + ui.set_activity_open(matches!(action.as_str(), "notifications" | "clear-notifications")); + ui.set_preview_open(true); + } + if matches!(action.as_str(), "preview" | "details" | "notifications" | "clear-notifications") { + ui.set_pdf_pages(0); + ui.set_pdf_zoom(100); + *pdf_document.lock().unwrap() = None; + } + let pdf_source = pdf_document.lock().unwrap().clone(); + let pdf_document = pdf_document.clone(); + let selected = ui.get_selected(); + let collision = ui.get_collision(); + let case_insensitive = ui.get_case_insensitive(); + let action = action.to_string(); + let a = if action.as_str()=="filter" { + let size = |s:slint::SharedString|->Option {s.parse().ok()}; + let min=ui.get_min_size();let max=ui.get_max_size(); + if (!min.is_empty()&&size(min.clone()).is_none())||(!max.is_empty()&&size(max.clone()).is_none()) { + ui.set_status("Enter file sizes as whole bytes between 0 and 4294967295.".into());return; + } + serde_json::json!({"nodeType":match ui.get_file_kind(){1=>Some("file"),2=>Some("directory"),_=>None},"minSize":size(min),"maxSize":size(max),"sort":match ui.get_file_sort(){1=>"size",2=>"nodeType",_=>"name"},"descending":ui.get_descending()}).to_string() + } else {a.to_string()}; + let b = b.to_string(); + let documents = documents.clone(); + let scratch_root = scratch_root.clone(); + let directory = Arc::clone(&directory); + let weak = weak.clone(); + progress.reset(); + ui.set_transfer_status("".into()); + let progress=progress.clone(); + preview_pending.store(matches!(action.as_str(), "preview" | "pdf-page" | "details" | "notifications" | "clear-notifications"), std::sync::atomic::Ordering::SeqCst); + let preview_pending = preview_pending.clone(); + ui.set_busy(true); + ui.set_status("".into()); + let current_generation = generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + let generation = Arc::clone(&generation); + let task = handle.spawn(async move { + let mut workspace = directory.lock().await; + if workspace.0!=identity {workspace.0=identity;workspace.1=Directory{attachments:true,..Default::default()};} + let dir=&mut workspace.1; + dir.collision = match collision { + 1 => files::CollisionPolicy::Rename, + 2 => files::CollisionPolicy::Replace, + 3 => files::CollisionPolicy::Newest, + _ => files::CollisionPolicy::Reject, + }; + dir.case_insensitive = case_insensitive; + let mut scratch = None; + let selection = if action == "download" { + let _ = tokio::fs::create_dir_all(&scratch_root).await; + match tempfile::tempdir_in(&scratch_root) { + Ok(dir) => { + let path = dir.path().join("download"); + scratch = Some(dir); + Ok(Some(crate::documents::ImportedDocument::source(path))) + } + Err(e) => Err(e.to_string()), + } + } else if matches!(action.as_str(), "upload" | "replace") { + documents.import().await + } else { + Ok(None) + }; + let export_path = selection.as_ref().ok().and_then(|v| v.as_ref()).map(|v| v.path().to_owned()); + let mut result = match selection { + Err(error) => Err(error), + Ok(None) if matches!(action.as_str(), "upload" | "replace") => { + Ok(ServiceOutput::Status("File selection cancelled.".into())) + } + Ok(_) if action == "pdf-page" => Ok(ServiceOutput::Status(String::new())), + Ok(path) => files::progress::track(progress,dir.execute(&core, &action, &a, &b, selected, path.as_ref().map(|v| v.path().to_owned()))).await, + }; + if action == "download" && result.is_ok() + && let Some(path) = export_path { + let name = dir + .selected(selected) + .map(|e| match e { + Entry::Attachment(a) => a.filename, + Entry::Remote(n) => n.name, + }) + .unwrap_or_else(|_| "download".into()); + result = documents.export(path, safe_name(&name)).await.map(|saved| { + ServiceOutput::Status( + if saved { + "File downloaded." + } else { + "Export cancelled; the cached file is still available." + } + .into(), + ) + }); + } + drop(scratch); + let result = if action == "pdf-page" { + match (pdf_source, a.parse::(), b.parse::()) { + (Some(data), Ok(index), Ok(zoom)) => crate::pdf_preview::render(data.clone(), index, zoom).await + .map(|page| Output::Preview(Preview::Pdf(data, page))), + _ => Err("Select a PDF to preview.".into()), + } + } else { match result { + Ok(ServiceOutput::Operations(rows)) => Ok(Output::Operations(rows)), + Ok(ServiceOutput::Status(s)) => Ok(Output::Status(s)), + Ok(ServiceOutput::Preview(ServicePreview::Text(s))) => { + Ok(Output::Preview(Preview::Text(s))) + } + Ok(ServiceOutput::Preview(ServicePreview::Data(data, media))) => { + preview(data, &media).await.map(Output::Preview) + } + Err(e) => Err(e), + }}; + let snapshot = Snapshot::new(dir); + let reset_selection = !matches!( + action.as_str(), + "preview" + | "pdf-page" + | "details" + | "download" + | "more" + | "notifications" + | "clear-notifications" + ); + let _ = weak.upgrade_in_event_loop(move |app| { + if generation.load(std::sync::atomic::Ordering::SeqCst) != current_generation { + return; + } + let ui = app.global::(); + snapshot.apply(&ui); + if action == "connect" && result.is_ok() { ui.set_connection_revision(ui.get_connection_revision().wrapping_add(1)); } + if action == "more" || result.is_ok() { + ui.set_pagination_failed(action == "more" && result.is_err()); + } + if result.is_ok() && !matches!(action.as_str(), "preview" | "pdf-page" | "details" | "download" | "notifications" | "clear-notifications" | "transfers") { + ui.set_content_revision(ui.get_content_revision().wrapping_add(1)); + } + if reset_selection { + ui.set_selected(-1); + } + match result { + Ok(Output::Operations(rows)) => { + ui.set_operations(ModelRc::new(VecModel::from( + rows.into_iter() + .map(|r| OperationRow { + id: r.id.to_string().into(), + action: format!("#{} · {} · {}",r.id,r.action,r.description).into(), + state: r.state.into(), + error: r.error.into(), + bytes: if r.bytes>0 {size(r.bytes).into()}else{slint::SharedString::default()}, + }) + .collect::>(), + ))); + ui.set_transfers_open(true); + } + Ok(Output::Preview(preview)) => { + ui.set_activity_open(matches!( + action.as_str(), + "notifications" | "clear-notifications" + )); + match preview { + Preview::Text(text) => { + ui.set_preview_text(text.into()); + ui.set_preview_is_image(false); + } + Preview::Pdf(data, page) => { + *pdf_document.lock().unwrap() = Some(data); + ui.set_pdf_page(page.index as i32); + ui.set_pdf_pages(page.count as i32); + let buffer = SharedPixelBuffer::::clone_from_slice(&page.pixels, page.width, page.height); + ui.set_preview_image(slint::Image::from_rgba8(buffer)); + ui.set_preview_is_image(true); + } + Preview::Image(data, w, h) => { + let buffer = + SharedPixelBuffer::::clone_from_slice(&data, w, h); + ui.set_preview_image(slint::Image::from_rgba8(buffer)); + ui.set_preview_is_image(true); + } + } + ui.set_preview_open(true); + } + Ok(Output::Status(message)) => ui.set_status(message.into()), + Err(error) => ui.set_status(error.into()), + } + preview_pending.store(false, std::sync::atomic::Ordering::SeqCst); + ui.set_busy(false); + }); + }); + *pending.borrow_mut() = Some(task.abort_handle()); + }); +} +enum Output { + Operations(Vec), + Status(String), + Preview(Preview), +} + +fn reset_profile(ui: &FilesUi) { + ui.set_rows(ModelRc::new(VecModel::from(Vec::::new()))); + ui.set_operations(ModelRc::new(VecModel::from(Vec::::new()))); + ui.set_accounts(ModelRc::new(VecModel::from(vec![ + slint::SharedString::from("All accounts"), + ]))); + ui.set_storage_accounts(ModelRc::new(VecModel::from( + Vec::::new(), + ))); + ui.set_account(0); + ui.set_selected_account_id(-1); + ui.set_storage_account(0); + ui.set_selected(-1); + ui.set_pdf_pages(0); + ui.set_pdf_zoom(100); + ui.set_pagination_failed(false); + ui.set_busy(false); + ui.set_connected(false); + ui.set_can_create(false); + ui.set_can_more(false); + ui.set_can_up(false); + ui.set_preview_open(false); + ui.set_preview_text("".into()); + ui.set_preview_image(slint::Image::default()); + ui.set_dialog("".into()); + ui.set_transfers_open(false); + ui.set_attachments(true); + ui.set_server_attachments(false); + ui.set_query("".into()); + ui.set_endpoint("".into()); + ui.set_quota("".into()); + ui.set_status("".into()); +} +fn size(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.) + } else { + format!("{:.1} MB", bytes as f64 / (1024. * 1024.)) + } +} + +struct Snapshot { + rows: Vec, + accounts: Vec, + account: i32, + account_id: i32, + spaces: Vec, + space: i32, + attachments: bool, + connected: bool, + endpoint: String, + webdav: bool, + can_create: bool, + can_lock: bool, + can_more: bool, + can_up: bool, + path: String, + query: String, + quota: String, +} +impl Snapshot { + fn new(dir: &Directory) -> Self { + let rows = dir + .entries + .iter() + .map(|entry| match entry { + Entry::Attachment(a) => FileRow { + identity: match &a.remote { + Some(remote) => format!( + "mail:{}:{}:{}:{}", + a.account_id, remote.account, remote.email, remote.blob + ), + None => format!("cached:{}:{}", a.account_id, a.id), + } + .into(), + name: a.filename.clone().into(), + detail: format!("{} · {}", a.sender, a.subject).into(), + size: size(a.size).into(), + date: chrono::DateTime::from_timestamp_millis(a.date) + .map(|d| d.format("%b %d, %Y").to_string()) + .unwrap_or_default() + .into(), + attachment: true, + readable: true, + ..Default::default() + }, + Entry::Remote(n) => FileRow { + identity: format!("storage:{}:{}:{}", dir.account, dir.settings.endpoint, n.id) + .into(), + created: n.created.clone().unwrap_or_default().into(), + modified: n.modified.clone().unwrap_or_default().into(), + accessed: n.accessed.clone().unwrap_or_default().into(), + executable: n.executable, + subscribed: n.is_subscribed.unwrap_or(true), + name: n.name.clone().into(), + detail: if n.is_directory() { + "Folder".into() + } else { + n.media_type.clone().unwrap_or_default().into() + }, + size: n.size.map(size).unwrap_or_default().into(), + date: n + .modified + .as_deref() + .unwrap_or("") + .chars() + .take(10) + .collect::() + .into(), + directory: n.is_directory(), + readable: n.my_rights.may_read, + writable: n.my_rights.modify(), + renameable: n.my_rights.rename(), + deletable: n.my_rights.delete(), + shareable: n.my_rights.may_share, + locked: n.locked, + owns_lock: matches!(&dir.client,Some(FileClient::Dav(c)) if c.owns_lock(&n.id)), + ..Default::default() + }, + }) + .collect(); + let (spaces, space) = if dir.offline { + ( + dir.cached_spaces + .iter() + .map(|(_, name, _)| name.clone()) + .collect(), + dir.cached_spaces + .iter() + .position(|(id, _, _)| Some(*id) == dir.cached_space) + .unwrap_or(0) as i32, + ) + } else { + match &dir.client { + Some(FileClient::Jmap(c)) => ( + c.accounts.iter().map(|a| a.name.clone()).collect(), + c.accounts + .iter() + .position(|a| a.id == c.account_id) + .unwrap_or(0) as i32, + ), + _ => (Vec::new(), 0), + } + }; + Self { + rows, + accounts: std::iter::once("All accounts".into()) + .chain(dir.accounts.iter().map(|a| a.1.clone())) + .collect(), + account: dir.account as i32, + account_id: dir + .account_id() + .and_then(|id| i32::try_from(id).ok()) + .unwrap_or(-1), + spaces, + space, + attachments: dir.attachments, + connected: dir.client.is_some() || dir.cached_space.is_some(), + endpoint: dir.settings.endpoint.clone(), + webdav: dir.settings.webdav, + can_create: dir.can_create, + can_lock: matches!(&dir.client,Some(FileClient::Dav(c)) if c.supports_locks), + can_more: dir.next.is_some(), + can_up: !dir.history.is_empty(), + path: if dir.attachments { + "Files from your email, in one place".into() + } else { + format!( + "/{}", + dir.history + .iter() + .map(|n| n.name.as_str()) + .collect::>() + .join(" / ") + ) + }, + query: dir.query.clone(), + quota: dir.quota.clone(), + } + } + fn apply(self, ui: &FilesUi) { + // Reuse Mail's reconciliation so appending pages retains the viewport + // and existing row delegates instead of rebuilding the entire list. + let retained = ui.get_rows(); + if let Some(model) = retained.as_any().downcast_ref::>() { + crate::reconcile_model_rows_by( + model, + self.rows, + |row| row.identity.clone(), + PartialEq::eq, + ); + } else { + ui.set_rows(ModelRc::new(VecModel::from(self.rows))); + } + ui.set_accounts(ModelRc::new(VecModel::from( + self.accounts + .into_iter() + .map(Into::into) + .collect::>(), + ))); + ui.set_account(self.account); + ui.set_selected_account_id(self.account_id); + ui.set_storage_accounts(ModelRc::new(VecModel::from( + self.spaces.into_iter().map(Into::into).collect::>(), + ))); + ui.set_storage_account(self.space); + ui.set_attachments(self.attachments); + ui.set_connected(self.connected); + ui.set_endpoint(self.endpoint.into()); + ui.set_webdav(self.webdav); + ui.set_can_create(self.can_create); + ui.set_can_lock(self.can_lock); + ui.set_can_more(self.can_more); + ui.set_can_up(self.can_up); + ui.set_path(self.path.into()); + ui.set_query(self.query.into()); + ui.set_quota(self.quota.into()); + } +} + +#[cfg(target_os = "ios")] +unsafe extern "C" { + fn install_pdf_lifecycle_observers(); +} diff --git a/src/ios_documents.rs b/src/ios_documents.rs new file mode 100644 index 0000000..fafbe69 --- /dev/null +++ b/src/ios_documents.rs @@ -0,0 +1,129 @@ +use crate::documents::{DocumentFuture, DocumentProvider, DocumentRequestGuard, ImportedDocument}; +use std::{ + collections::HashMap, + ffi::{CStr, CString, c_char}, + path::PathBuf, + sync::{ + Mutex, OnceLock, + atomic::{AtomicI64, Ordering}, + }, +}; +use tokio::sync::oneshot; +type Reply = oneshot::Sender, String>>; +static PENDING: OnceLock>> = OnceLock::new(); +static NEXT: AtomicI64 = AtomicI64::new(1); +unsafe extern "C" { + fn flectar_cancel_document(id: i64); + fn flectar_choose_document( + id: i64, + path: *const c_char, + callback: extern "C" fn(i64, *const c_char, *const c_char), + ); +} +extern "C" fn result(id: i64, path: *const c_char, error: *const c_char) { + // The native bridge keeps these UTF-8 strings alive for this callback. + let string = |ptr: *const c_char| { + if ptr.is_null() { + String::new() + } else { + unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned() + } + }; + let path = string(path); + let error = string(error); + let pending = PENDING + .get_or_init(Default::default) + .lock() + .unwrap() + .remove(&id); + let importing = pending.as_ref().is_none_or(|(_, importing)| *importing); + let response = if error.is_empty() { + Ok((!path.is_empty()).then(|| { + if importing { + ImportedDocument::temporary(PathBuf::from(path), release_import) + } else { + ImportedDocument::source(PathBuf::from(path)) + } + })) + } else { + Err(error) + }; + if let Some((sender, _)) = pending { + let _ = sender.send(response); + } +} +fn release_import(path: PathBuf) { + let Some(home) = std::env::var_os("HOME") else { + return; + }; + let Ok(root) = std::fs::canonicalize(PathBuf::from(home).join("Library/Caches/file-imports")) + else { + return; + }; + let Ok(file) = std::fs::canonicalize(path) else { + return; + }; + if file.parent().and_then(|p| p.parent()) == Some(root.as_path()) && file.is_file() { + // Remove only this copy and its now-empty unique directory. + if std::fs::remove_file(&file).is_ok() { + let _ = std::fs::remove_dir(file.parent().unwrap()); + } + } +} + +pub struct IosDocuments; +fn request(path: PathBuf) -> DocumentFuture> { + let id = NEXT.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + let value = CString::new(path.to_string_lossy().as_bytes()); + if let Ok(value) = value { + PENDING + .get_or_init(Default::default) + .lock() + .unwrap() + .insert(id, (sender, path.as_os_str().is_empty())); + unsafe { + flectar_choose_document(id, value.as_ptr(), result); + } + } + let guard = DocumentRequestGuard(Box::new(move || { + if PENDING + .get_or_init(Default::default) + .lock() + .unwrap() + .remove(&id) + .is_some() + { + unsafe { + flectar_cancel_document(id); + } + } + })); + Box::pin(async move { + let _guard = guard; + tokio::time::timeout(std::time::Duration::from_secs(600), receiver) + .await + .map_err(|_| "Document selection timed out.".to_string()) + .and_then(|r| r.map_err(|_| "Document selection ended.".to_string())) + .and_then(|r| r) + }) +} +impl DocumentProvider for IosDocuments { + fn import(&self) -> DocumentFuture> { + request(PathBuf::new()) + } + fn export(&self, path: PathBuf, name: String) -> DocumentFuture { + Box::pin(async move { + flectar_mail_core::files::validate_name(&name).map_err(|e| e.to_string())?; + let folder = tempfile::tempdir_in(path.parent().ok_or("Invalid export directory.")?) + .map_err(|e| e.to_string())?; + let destination = folder.path().join(name); + flectar_mail_core::files::save_cached_file(&path, &destination) + .await + .map_err(|e| e.to_string())?; + Ok(request(destination).await?.is_some()) + }) + } +} diff --git a/src/mail.rs b/src/mail.rs index c88d227..cafc505 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -56,6 +56,7 @@ pub struct MailMessage { pub unread: bool, pub starred: bool, pub has_attachments: bool, + pub attachments: Vec, pub has_replied: bool, pub labels: Vec, pub html: Option, @@ -89,6 +90,7 @@ impl MailMessage { unread: email.unread, starred: false, has_attachments: false, + attachments: Vec::new(), has_replied: false, labels: Vec::new(), html: Some(email.html.to_owned()), @@ -199,6 +201,13 @@ pub struct CoreMailSource { } impl CoreMailSource { + pub(crate) fn account_preferences_core(&self) -> Arc { + Arc::clone(&self.core) + } + + pub(crate) fn file_core(&self) -> Arc { + Arc::clone(&self.core) + } pub async fn start( paths: Paths, credentials: flectar_mail_core::accounts::credentials::CredentialStoreHandle, @@ -1583,6 +1592,7 @@ fn summary_to_message( unread: thread.unread_count > 0, starred: thread.is_starred, has_attachments: thread.has_attachments, + attachments: Vec::new(), has_replied: thread.has_replied, labels: thread.labels, html: None, @@ -1635,6 +1645,7 @@ fn detail_to_message(row: &MailMessage, message: &MessageDetail) -> MailMessage unread: !message.is_read, starred: row.starred, has_attachments: !message.attachments.is_empty(), + attachments: message.attachments.clone(), has_replied: row.has_replied, labels: row.labels.clone(), html, diff --git a/src/mail_render_projection.rs b/src/mail_render_projection.rs index abc61e7..fa76105 100644 --- a/src/mail_render_projection.rs +++ b/src/mail_render_projection.rs @@ -142,6 +142,7 @@ pub(super) fn email_viewport_size(app: &AppWindow) -> (u32, u32) { /// Remove every actionable part of the previous body on empty/error transitions. /// Source and fallback text are owned by the caller and remain readable. pub(super) fn clear_reader_projection(app: &AppWindow) { + app.global::().invoke_close_reader(); let reader = app.global::(); reader.set_available(false); reader.set_body_pending(false); @@ -184,12 +185,16 @@ pub(super) fn apply_email( reader.set_body_pending(email.body_pending); reader.set_authored_text(email.text.clone().unwrap_or_default().into()); let same_message = reader.get_message_id() == email.id; + crate::attachment_controller::project(app, &email, same_message); let mut scroll = if same_message { app.get_email_scroll_y() } else { 0.0 }; if !same_message { + app.global::().invoke_close_reader(); + reader.set_auto_fit(true); + email_renderer.borrow_mut().set_auto_fit(true); reader.set_use_authored_text(false); reader.set_message_id(email.id); reader.set_query("".into()); @@ -279,6 +284,7 @@ pub(super) fn apply_email( return Ok(()); } }; + reader.set_zoom(renderer.zoom); reader.set_width_ratio((renderer.layout_width / width.max(1) as f32).max(1.0)); reader.set_items(ModelRc::new(VecModel::from(renderer.reader_items()))); reader.set_images(ModelRc::new(VecModel::from(renderer.image_placeholders()))); @@ -384,6 +390,8 @@ pub(super) fn open_email_link(url: &str) -> Result<(), String> { pub(super) fn sync_reader_metadata(app: &AppWindow, renderer: &Rc>) { let renderer = renderer.borrow(); let reader = app.global::(); + reader.set_zoom(renderer.zoom); + reader.set_auto_fit(renderer.auto_fit); reader.set_available(renderer.has_document()); if !renderer.has_document() { clear_reader_projection(app); diff --git a/src/mail_setup.rs b/src/mail_setup.rs new file mode 100644 index 0000000..c634649 --- /dev/null +++ b/src/mail_setup.rs @@ -0,0 +1,73 @@ +//! Certificate import for manual mail accounts. Certificates are public trust +//! material; private keys must never enter account settings or backups. +use super::*; + +pub(super) fn register(app: &AppWindow, _runtime: &Rc) { + let weak = app.as_weak(); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + let runtime = Rc::clone(_runtime); + app.on_import_mail_certificate(move || { + let Some(app) = weak.upgrade() else { + return; + }; + if app.get_account_setup_in_progress() { + return; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let weak = app.as_weak(); + runtime.spawn(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title("Import server certificate") + .add_filter("PEM certificate", &["pem", "crt", "cer"]) + .pick_file() + .await + else { + return; + }; + let result = read_certificate(file.path()).await; + let _ = weak.upgrade_in_event_loop(move |app| { + if app.get_account_setup_in_progress() { + return; + } + match result { + Ok(pem) => { + app.set_trusted_certificate_pem(pem.into()); + app.set_sync_status(UiMessage::plain( + "Certificate imported. Connect to verify the server.", + )); + } + Err(error) => app.set_sync_status(UiMessage::detail( + "Certificate import failed: {}", + error, + )), + } + }); + }); + } + #[cfg(any(target_os = "android", target_os = "ios"))] + app.set_sync_status(UiMessage::plain( + "Paste the PEM certificate into the certificate field.", + )); + }); +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn read_certificate(path: &std::path::Path) -> Result { + use tokio::io::AsyncReadExt; + let file = tokio::fs::File::open(path) + .await + .map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + file.take(256 * 1024 + 1) + .read_to_end(&mut bytes) + .await + .map_err(|e| e.to_string())?; + let pem = + String::from_utf8(bytes).map_err(|_| "Choose a PEM-encoded certificate".to_string())?; + if pem.trim().is_empty() { + return Err("The certificate file is empty".into()); + } + flectar_mail_core::imap::trusted_certificates(&pem).map_err(|e| e.to_string())?; + Ok(pem) +} diff --git a/src/mail_view_model.rs b/src/mail_view_model.rs index dde5c1f..e6b494a 100644 --- a/src/mail_view_model.rs +++ b/src/mail_view_model.rs @@ -99,6 +99,7 @@ pub(super) fn apply_background_mail_page( summary.html = selected_detail.html; summary.text = selected_detail.text; summary.to = selected_detail.to; + summary.attachments = selected_detail.attachments; summary.body_pending = false; } @@ -395,6 +396,7 @@ pub(super) fn render_current( app.set_email_content_aspect(900.0 / 520.0); app.set_email_links(ModelRc::new(VecModel::default())); clear_reader_projection(app); + crate::attachment_controller::clear(app); app.global::().set_message_id(-1); app.global::().set_authored_text("".into()); app.global::().set_notice("".into()); @@ -1276,6 +1278,7 @@ mod tests { labels: Vec::new(), html: None, text: None, + attachments: Vec::new(), body_pending: true, sender_verification: String::new(), } diff --git a/src/main.rs b/src/main.rs index 7058a1a..bd3c576 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,28 @@ mod account_controller; +mod account_mail_preferences; +mod attachment_controller; +mod browser; mod calendar; mod compose_controller; mod compose_editor; mod contacts; mod data_controller; +mod document_preview; +pub mod documents; mod email_document; pub mod favicon; +mod files_controller; +#[cfg(target_os = "ios")] +mod ios_documents; mod latest_load; mod mail; mod mail_render_projection; +mod mail_setup; mod mail_view_model; mod mail_work; +mod oauth_browser; +pub mod pdf_preview; +mod preview_controls; mod reader_clipboard; #[cfg(test)] mod reader_validation; @@ -191,10 +203,10 @@ where reconcile_model_rows_by(model, rows, key, PartialEq::eq); } -fn reconcile_model_rows_by( +fn reconcile_model_rows_by( model: &VecModel, rows: Vec, - key: impl Fn(&T) -> i32, + key: impl Fn(&T) -> K, same: impl Fn(&T, &T) -> bool, ) { let mut current = model.iter().collect::>(); @@ -390,6 +402,7 @@ struct UiTaskUpdate { calendar_connections: Option>, calendar_error: Option<(i64, Option)>, clear_account_form: bool, + finishes_account_setup: bool, finishes_oauth: bool, close_to_tray: Option, } @@ -843,16 +856,22 @@ impl InboxState { #[derive(Clone)] pub struct PlatformContext { pub paths: Paths, + pub documents: Arc, pub credentials: flectar_mail_core::accounts::credentials::CredentialStoreHandle, pub oauth_redirects: flectar_mail_core::oauth::redirect::OAuthRedirectBrokerHandle, } impl PlatformContext { pub fn desktop() -> Result { + let paths = Paths::default_dirs()?; + let credentials = desktop_credential_store(&paths); Ok(Self { - paths: Paths::default_dirs()?, - credentials: Arc::new(flectar_mail_core::accounts::credentials::SystemCredentialStore), - oauth_redirects: Arc::new(flectar_mail_core::oauth::redirect::LoopbackRedirectBroker), + paths, + documents: documents::default_provider(), + credentials, + oauth_redirects: Arc::new( + flectar_mail_core::oauth::redirect::LoopbackRedirectBroker::default(), + ), }) } @@ -864,12 +883,70 @@ impl PlatformContext { ) -> Self { Self { paths: Paths::new(data_root.join("data"), cache_root.join("cache")), + documents: documents::default_provider(), credentials, oauth_redirects, } } } +fn desktop_credential_store( + paths: &Paths, +) -> flectar_mail_core::accounts::credentials::CredentialStoreHandle { + #[cfg(all(target_os = "linux", debug_assertions))] + if isolated_container_without_secret_service() { + let path = paths.data_dir.join("credentials-v1.json"); + tracing::warn!( + path = %path.display(), + "credential: no D-Bus session in development container; using debug-only plaintext storage" + ); + return Arc::new( + flectar_mail_core::accounts::credentials::DevelopmentFileCredentialStore::new(path), + ); + } + #[cfg(not(all(target_os = "linux", debug_assertions)))] + let _ = paths; + Arc::new(flectar_mail_core::accounts::credentials::SystemCredentialStore) +} + +#[cfg(all(target_os = "linux", debug_assertions))] +fn isolated_container_without_secret_service() -> bool { + let container = std::env::var("container") + .unwrap_or_default() + .to_ascii_lowercase(); + isolated_container_without_secret_service_values( + &container, + std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some_and(|value| !value.is_empty()), + ) +} + +#[cfg(all(target_os = "linux", debug_assertions))] +fn isolated_container_without_secret_service_values(container: &str, has_session_bus: bool) -> bool { + matches!(container, "podman" | "docker") && !has_session_bus +} + +#[cfg(all(test, target_os = "linux"))] +mod desktop_credential_store_tests { + use super::isolated_container_without_secret_service_values; + + #[test] + fn debug_file_store_is_limited_to_isolated_desktop_containers() { + assert!(isolated_container_without_secret_service_values( + "podman", false + )); + assert!(isolated_container_without_secret_service_values( + "docker", false + )); + assert!(!isolated_container_without_secret_service_values( + "podman", true + )); + assert!(!isolated_container_without_secret_service_values("", false)); + assert!(!isolated_container_without_secret_service_values( + "flatpak", false + )); + } +} + fn startup_diagnostic_id(error: &str) -> String { use sha2::Digest; let digest = sha2::Sha256::digest(error.as_bytes()); @@ -1066,6 +1143,10 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> // Until startup completes it paints the inert mailbox shell; mapping now // avoids making callback wiring part of first-window latency. let app = AppWindow::new()?; + app.global::() + .on_resolve(|action, current, minimum, maximum| { + crate::preview_controls::zoom(&action, current, minimum, maximum) + }); theme::register_theme_utilities(&app); app.set_print_supported(!cfg!(any(target_os = "android", target_os = "ios"))); app.set_document_apis_supported(!cfg!(any(target_os = "android", target_os = "ios"))); @@ -1220,6 +1301,15 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> app.set_sidebar_rows(Rc::clone(&initial_state.sidebar_rows).into()); let state = Rc::new(RefCell::new(initial_state)); mail_work::register(&app, &state, &runtime); + files_controller::register( + &app, + &state, + &runtime, + platform.documents.clone(), + platform.paths.temp_dir(), + ); + + attachment_controller::register(&app, &state, &runtime, platform.documents.clone()); let state_for_mail_drag = Rc::clone(&state); app.global::() @@ -3102,7 +3192,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> if let Some(settings) = settings.as_ref() { apply_settings(&app, settings); - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(any(target_os = "android", target_os = "ios", feature = "flatpak"))] app.set_close_to_tray(false); if let Some(tray) = startup_tray.as_ref().and_then(|tray| tray.upgrade()) @@ -3243,8 +3333,13 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> let Some(app) = ui_task_app.upgrade() else { return; }; + if update.finishes_account_setup { + app.set_account_setup_in_progress(false); + } if update.finishes_oauth { app.set_oauth_in_progress(false); + app.set_oauth_authorization_url("".into()); + app.set_oauth_browser_error(false); } if let Some(enabled) = update.close_to_tray { app.set_close_to_tray(enabled); @@ -3283,6 +3378,10 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> refresh_connected_accounts(&app, &ui_task_state); } if update.clear_account_form { + app.set_account_form_open(false); + app.set_imap_security("auto".into()); + app.set_smtp_security("auto".into()); + app.set_trusted_certificate_pem("".into()); app.set_account_email("".into()); app.set_account_username("".into()); app.set_account_password("".into()); @@ -3351,6 +3450,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: None, calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: false, close_to_tray: None, }) @@ -3397,6 +3497,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: None, calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: false, close_to_tray: None, }) @@ -3911,6 +4012,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> Vec::::new(), )); let compose_intent = Rc::new(RefCell::new(ComposeIntent::default())); + account_mail_preferences::register(&app, &state, &runtime, &compose_document, &compose_editor); apply_compose_files(&app, &compose_files.borrow()); { let document = compose_document.borrow(); @@ -3923,8 +4025,13 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> let intent_for_open = Rc::clone(&compose_intent); app.on_open_compose(move || { if let Some(app) = app_weak.upgrade() { + if app.get_compose_open() { return; } *intent_for_open.borrow_mut() = ComposeIntent::default(); app.set_compose_mode("new".into()); + app.global::() + .invoke_composer_reset(); + app.global::() + .invoke_composer_account_changed(app.get_compose_account_id()); app.set_compose_notice(UiMessage::EMPTY); app.set_compose_notice_is_error(false); app.set_compose_open(true); @@ -4052,6 +4159,10 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> draft_id: draft.draft_id, }; app.set_compose_mode("draft".into()); + app.global::() + .invoke_composer_reset(); + app.global::() + .invoke_composer_account_changed(account_id); app.set_compose_open(true); return; } @@ -4124,6 +4235,10 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> } *intent_for_message_compose.borrow_mut() = prepared.intent; app.set_compose_mode(action); + app.global::() + .invoke_composer_reset(); + app.global::() + .invoke_composer_account_changed(account_id); app.set_compose_open(true); }); @@ -4480,6 +4595,9 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> let Some(app) = app_weak.upgrade() else { return; }; + if send && !app.global::().get_composer_preferences_ready() { + return; + } if account_id <= 0 { app.set_compose_notice(UiMessage::plain( "Choose the account this message should be sent from.", @@ -4572,6 +4690,8 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> let state_for_account = Rc::clone(&state); let runtime_for_account = Rc::clone(&runtime); let ui_task_tx_for_account = ui_task_tx.clone(); + oauth_browser::register(&app, platform.oauth_redirects.clone()); + mail_setup::register(&app, &runtime); app.on_add_password_account( move |protocol, email, @@ -4585,14 +4705,19 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> let Some(app) = app_weak.upgrade() else { return; }; + if app.get_account_setup_in_progress() { + return; + } let mail_protocol = MailProtocol::from_storage(protocol.as_str()); let imap_port = if mail_protocol == MailProtocol::Jmap { 993 } else { match imap_port.trim().parse::() { - Ok(port) => port, - Err(_) => { - app.set_sync_status(UiMessage::plain("IMAP port must be a number.")); + Ok(port) if port > 0 => port, + _ => { + app.set_sync_status(UiMessage::plain( + "IMAP port must be between 1 and 65535.", + )); return; } } @@ -4601,9 +4726,11 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> 465 } else { match smtp_port.trim().parse::() { - Ok(port) => port, - Err(_) => { - app.set_sync_status(UiMessage::plain("SMTP port must be a number.")); + Ok(port) if port > 0 => port, + _ => { + app.set_sync_status(UiMessage::plain( + "SMTP port must be between 1 and 65535.", + )); return; } } @@ -4617,7 +4744,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> app.set_sync_status(if mail_protocol == MailProtocol::Jmap { UiMessage::plain("Discovering JMAP and checking capabilities…") } else { - UiMessage::plain("Testing IMAP connection…") + UiMessage::plain("Testing IMAP and SMTP connections…") }); let args = AddPasswordAccountArgs { email: email.to_string(), @@ -4630,7 +4757,19 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> imap_port, smtp_host: smtp_host.to_string(), smtp_port, + connection: flectar_mail_core::models::MailConnectionSettings { + imap_security: flectar_mail_core::models::ConnectionSecurity::parse( + app.get_imap_security().as_str(), + ) + .unwrap_or_default(), + smtp_security: flectar_mail_core::models::ConnectionSecurity::parse( + app.get_smtp_security().as_str(), + ) + .unwrap_or_default(), + trusted_certificate_pem: app.get_trusted_certificate_pem().to_string(), + }, }; + app.set_account_setup_in_progress(true); let updates = ui_task_tx_for_account.clone(); runtime_for_account.spawn(async move { let update = match core.add_password_account(args).await { @@ -4654,6 +4793,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: None, calendar_error: None, clear_account_form: true, + finishes_account_setup: true, finishes_oauth: false, close_to_tray: None, } @@ -4664,6 +4804,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: None, calendar_error: None, clear_account_form: false, + finishes_account_setup: true, finishes_oauth: false, close_to_tray: None, }, @@ -4708,16 +4849,19 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> )); return; } + app.set_oauth_authorization_url("".into()); + app.set_oauth_browser_error(false); app.set_oauth_in_progress(true); let connect_calendar = app.get_connect_calendar_on_add(); app.set_sync_status(UiMessage::plain( "Complete sign-in in your browser, or choose Cancel here.", )); let updates = ui_task_tx_for_oauth.clone(); + let browser_app = app.as_weak(); runtime_for_oauth.spawn(async move { let result = core - .start_oauth_with_calendar(provider, connect_calendar, |url| { - webbrowser::open(&url).map_err(|error| error.to_string()) + .start_oauth_with_calendar(provider, connect_calendar, move |url| { + oauth_browser::begin(browser_app.clone(), url) }) .await; let update = match result { @@ -4738,6 +4882,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: core.load_calendar_connections().await.ok(), calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: true, close_to_tray: None, } @@ -4748,6 +4893,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: None, calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: true, close_to_tray: None, }, @@ -4779,15 +4925,18 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> )); return; }; + app.set_oauth_authorization_url("".into()); + app.set_oauth_browser_error(false); app.set_oauth_in_progress(true); app.set_sync_status(UiMessage::plain( "Complete sign-in in your browser, or choose Cancel here.", )); let updates = ui_task_tx_for_reauth.clone(); + let browser_app = app.as_weak(); runtime_for_reauth.spawn(async move { let result = core - .reauth_account(i64::from(account_id), |url| { - webbrowser::open(&url).map_err(|error| error.to_string()) + .reauth_account(i64::from(account_id), move |url| { + oauth_browser::begin(browser_app.clone(), url) }) .await; let update = match result { @@ -4808,6 +4957,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: core.load_calendar_connections().await.ok(), calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: true, close_to_tray: None, } @@ -4818,6 +4968,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: None, calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: true, close_to_tray: None, }, @@ -4872,15 +5023,18 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> }; (core, provider) }; + app.set_oauth_authorization_url("".into()); + app.set_oauth_browser_error(false); app.set_oauth_in_progress(true); app.set_sync_status(UiMessage::plain( "Authorize calendar access in your browser.", )); let updates = updates_for_calendar_connect.clone(); + let browser_app = app.as_weak(); runtime_for_calendar_connect.spawn(async move { let result = core - .connect_provider_calendar(i64::from(account_id), provider, |url| { - webbrowser::open(&url).map_err(|error| error.to_string()) + .connect_provider_calendar(i64::from(account_id), provider, move |url| { + oauth_browser::begin(browser_app.clone(), url) }) .await; let connections = core.load_calendar_connections().await.ok(); @@ -4896,6 +5050,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: connections, calendar_error, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: true, close_to_tray: None, }) @@ -4933,6 +5088,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: connections, calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: false, close_to_tray: None, }) @@ -4969,6 +5125,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: connections, calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: false, close_to_tray: None, }) @@ -5488,3 +5645,17 @@ mod tests { assert!(icons.len() + missing.len() <= 2); } } + +/// Mobile hosts release preview bytes/bitmaps and request cooperative native +/// cancellation when backgrounded or under memory pressure. +#[cfg(any(target_os = "android", target_os = "ios"))] +pub fn suspend_file_preview() { + files_controller::suspend(); + attachment_controller::suspend(); +} + +#[cfg(target_os = "ios")] +#[unsafe(no_mangle)] +extern "C" fn flectar_suspend_pdf_preview() { + suspend_file_preview(); +} diff --git a/src/oauth_browser.rs b/src/oauth_browser.rs new file mode 100644 index 0000000..00b2b3a --- /dev/null +++ b/src/oauth_browser.rs @@ -0,0 +1,117 @@ +//! Keep the redirect listener alive when a desktop browser cannot be launched. +use super::*; + +pub(super) fn begin(app: slint::Weak, url: String) -> Result<(), String> { + let link = url.clone(); + app.upgrade_in_event_loop(move |app| { + if app.get_oauth_in_progress() { + app.set_oauth_authorization_url(link.into()); + app.set_oauth_browser_error(false); + } + }) + .map_err(|e| e.to_string())?; + launch(app, url); + Ok(()) +} + +fn launch(app: slint::Weak, url: String) { + std::thread::spawn(move || { + let failed = browser::open(&url).is_err(); + let _ = app.upgrade_in_event_loop(move |app| { + if app.get_oauth_in_progress() && app.get_oauth_authorization_url().as_str() == url { + app.set_oauth_browser_error(failed); + } + }); + }); +} + +pub(super) fn register( + app: &AppWindow, + redirects: flectar_mail_core::oauth::redirect::OAuthRedirectBrokerHandle, +) { + let weak = app.as_weak(); + app.on_retry_oauth_browser(move || { + let Some(app) = weak.upgrade() else { + return; + }; + let url = app.get_oauth_authorization_url().to_string(); + if app.get_oauth_in_progress() && !url.is_empty() { + launch(app.as_weak(), url); + } + }); + let weak = app.as_weak(); + app.on_copy_oauth_link(move || { + let Some(app) = weak.upgrade() else { + return; + }; + if app.get_oauth_in_progress() && !app.get_oauth_authorization_url().is_empty() { + app.invoke_copy_oauth_authorization_url(); + } + }); + let weak = app.as_weak(); + app.on_submit_oauth_redirect(move |uri| { + let Some(app) = weak.upgrade() else { + return; + }; + if !app.get_oauth_in_progress() { + return; + } + match redirects.submit_redirect(uri.as_str()) { + Ok(()) => app.set_sync_status(UiMessage::plain("Finishing browser sign-in…")), + Err(error) => app.set_sync_status(UiMessage::detail( + "Could not use that browser address: {}", + error, + )), + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use slint::platform::{ + Platform, WindowAdapter, + software_renderer::{MinimalSoftwareWindow, RepaintBufferType}, + }; + struct Headless(Rc, Rc>); + impl Platform for Headless { + fn create_window_adapter(&self) -> Result, slint::PlatformError> { + Ok(self.0.clone()) + } + fn set_clipboard_text(&self, text: &str, _: slint::platform::Clipboard) { + *self.1.borrow_mut() = text.to_owned(); + } + } + #[test] + fn onboarding_copies_active_link_without_a_mailbox_and_ignores_finished_sign_in() { + let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer); + let clipboard = Rc::new(RefCell::new(String::new())); + slint::platform::set_platform(Box::new(Headless(window.clone(), clipboard.clone()))) + .unwrap(); + let app = AppWindow::new().unwrap(); + register( + &app, + Arc::new( + flectar_mail_core::oauth::redirect::LoopbackRedirectBroker::default(), + ), + ); + app.set_startup_ready(true); + app.set_oauth_in_progress(true); + app.set_oauth_authorization_url( + "https://accounts.google.com/o/oauth2/v2/auth?state=test".into(), + ); + app.window().set_size(slint::PhysicalSize::new(390, 640)); + app.show().unwrap(); + slint::platform::update_timers_and_animations(); + app.invoke_copy_oauth_link(); + assert_eq!( + *clipboard.borrow(), + app.get_oauth_authorization_url().as_str() + ); + app.set_oauth_in_progress(false); + app.set_oauth_authorization_url("".into()); + *clipboard.borrow_mut() = "keep clipboard".into(); + app.invoke_copy_oauth_link(); + assert_eq!(*clipboard.borrow(), "keep clipboard"); + } +} diff --git a/src/pdf_preview.rs b/src/pdf_preview.rs new file mode 100644 index 0000000..dcb813b --- /dev/null +++ b/src/pdf_preview.rs @@ -0,0 +1,364 @@ +//! Desktop uses a short-lived renderer process; mobile uses one bounded native +//! worker with cooperative cancellation. Both load only the packaged PDFium +//! library on first preview and return one bounded RGBA page. +//! No PDF scripts, links, forms actions, or document attachments are executed. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use std::sync::Arc; + +pub(crate) const MAX_DOCUMENT_BYTES: usize = 16 * 1024 * 1024; +const MAX_EDGE: u32 = 2880; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +const MAGIC: &[u8; 4] = b"FPD1"; + +#[derive(Debug)] +pub(crate) struct Page { + pub pixels: Vec, + pub width: u32, + pub height: u32, + pub count: u32, + pub index: u32, +} + +fn dimensions(width: u32, height: u32) -> Result { + if width == 0 || height == 0 || width > MAX_EDGE || height > MAX_EDGE { + return Err("PDF page exceeds the preview pixel limit.".into()); + } + Ok(width as usize * height as usize * 4) +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) async fn render(data: Arc>, index: u32, zoom: u32) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + if data.len() > MAX_DOCUMENT_BYTES || !(50..=200).contains(&zoom) { + return Err("This PDF exceeds the preview limit. Download it to view locally.".into()); + } + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let library = library_path(&executable)?; + if !library.is_file() { + return Err("The PDF preview component is missing from this installation. Download the file to view it locally.".into()); + } + let mut command = tokio::process::Command::new(executable); + command + .arg("--render-pdf-page") + .env_clear() + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + // Windows needs SystemRoot to resolve native system libraries. Do not pass + // credentials, profile paths, PATH or dynamic-loader overrides to PDFium. + #[cfg(target_os = "windows")] + { + if let Some(root) = std::env::var_os("SystemRoot") { + command.env("SystemRoot", root); + } + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + let mut child = command.spawn().map_err(|e| e.to_string())?; + #[cfg(target_os = "windows")] + let _job = WindowsJob::constrain(child.id().ok_or("PDF worker has no process ID")?)?; + let work = async { + let mut input = child.stdin.take().ok_or("Missing PDF input pipe")?; + input + .write_all(&index.to_le_bytes()) + .await + .map_err(|e| e.to_string())?; + input + .write_all(&zoom.to_le_bytes()) + .await + .map_err(|e| e.to_string())?; + input + .write_all(&(data.len() as u32).to_le_bytes()) + .await + .map_err(|e| e.to_string())?; + input.write_all(&data).await.map_err(|e| e.to_string())?; + drop(input); + let mut output = child.stdout.take().ok_or("Missing PDF output pipe")?; + let mut header = [0u8; 16]; + output.read_exact(&mut header).await.map_err(|_| { + "Unable to preview this PDF safely on this device. Download it to view locally." + .to_string() + })?; + let number = |start| u32::from_le_bytes(header[start..start + 4].try_into().unwrap()); + let (count, width, height) = (number(4), number(8), number(12)); + if &header[..4] != MAGIC || count == 0 || index >= count { + return Err("Invalid response from the PDF renderer.".into()); + } + let mut pixels = vec![0u8; dimensions(width, height)?]; + output + .read_exact(&mut pixels) + .await + .map_err(|e| e.to_string())?; + if !child.wait().await.map_err(|e| e.to_string())?.success() { + return Err("The PDF renderer could not finish this page.".into()); + } + Ok(Page { + pixels, + width, + height, + count, + index, + }) + }; + tokio::time::timeout(std::time::Duration::from_secs(20), work) + .await + .map_err(|_| { + "This PDF page took too long to render. Download it to view locally.".to_string() + })? +} + +#[cfg(any(test, target_os = "android", target_os = "ios"))] +#[path = "pdf_preview_mobile.rs"] +mod mobile; +#[cfg(any(target_os = "android", target_os = "ios"))] +pub use mobile::cancel_active; +#[cfg(target_os = "android")] +pub use mobile::configure_android; +#[cfg(any(target_os = "android", target_os = "ios"))] +pub(crate) use mobile::render; + +/// Called by the thin desktop launcher BEFORE PlatformContext or the UI exists. +/// The reserved argument alone enables the worker; documents arrive only via stdin. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn run_worker_if_requested() -> Option { + if std::env::args_os().nth(1).as_deref() != Some(std::ffi::OsStr::new("--render-pdf-page")) { + return None; + } + Some(if worker().is_ok() { 0 } else { 1 }) +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn worker() -> Result<(), Box> { + use pdfium_render::prelude::*; + use std::io::{Read, Write}; + // Resource limits affect this child only. The parent's wall-clock deadline + // additionally covers native code that stalls without consuming CPU time. + #[cfg(unix)] + unsafe { + let cpu = libc::rlimit { + rlim_cur: 15, + rlim_max: 15, + }; + let core = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if libc::setrlimit(libc::RLIMIT_CPU, &cpu) != 0 + || libc::setrlimit(libc::RLIMIT_CORE, &core) != 0 + { + return Err("Could not constrain PDF renderer".into()); + } + #[cfg(target_os = "linux")] + { + let memory = libc::rlimit { + rlim_cur: 768 * 1024 * 1024, + rlim_max: 768 * 1024 * 1024, + }; + if libc::setrlimit(libc::RLIMIT_AS, &memory) != 0 { + return Err("Could not limit PDF memory".into()); + } + libc::setpriority(libc::PRIO_PROCESS, 0, 10); + } + } + let mut input = std::io::stdin().lock(); + let mut header = [0u8; 12]; + input.read_exact(&mut header)?; + let number = |start| u32::from_le_bytes(header[start..start + 4].try_into().unwrap()); + let (index, zoom, length) = (number(0), number(4), number(8) as usize); + if length > MAX_DOCUMENT_BYTES || !(50..=200).contains(&zoom) { + return Err("Invalid PDF request".into()); + } + let mut data = vec![0; length]; + input.read_exact(&mut data)?; + let executable = std::env::current_exe()?; + let library = library_path(&executable)?; + let pdfium = Pdfium::new(Pdfium::bind_to_library(library)?); + #[cfg(target_os = "linux")] + sandbox()?; + let document = pdfium.load_pdf_from_byte_slice(&data, None)?; + let count = u32::try_from(document.pages().len())?; + if index >= count { + return Err("PDF page out of range".into()); + } + let page = document.pages().get(index.try_into()?)?; + let config = PdfRenderConfig::new() + .set_target_width((1440 * zoom / 100) as i32) + .set_maximum_width(MAX_EDGE as i32) + .set_maximum_height(MAX_EDGE as i32); + let bitmap = page.render_with_config(&config)?; + let (width, height) = (bitmap.width() as u32, bitmap.height() as u32); + let expected = dimensions(width, height)?; + let pixels = bitmap.as_rgba_bytes(); + if pixels.len() != expected { + return Err("Invalid PDF bitmap".into()); + } + let mut output = std::io::stdout().lock(); + output.write_all(MAGIC)?; + for value in [count, width, height] { + output.write_all(&value.to_le_bytes())?; + } + output.write_all(&pixels)?; + output.flush()?; + Ok(()) +} + +// Files arrive through stdin; only system font directories remain readable. +// Fail closed if the kernel cannot enforce the sandbox. Seccomp complements +// Landlock by denying networking, process creation, ptrace and signal delivery. +#[cfg(target_os = "linux")] +fn sandbox() -> Result<(), Box> { + use landlock::{ + ABI, Access, AccessFs, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus, + path_beneath_rules, + }; + use seccompiler::{BpfProgram, SeccompAction, SeccompFilter}; + let status = Ruleset::default() + .handle_access(AccessFs::from_all(ABI::V1))? + .create()? + .add_rules(path_beneath_rules( + [ + "/usr/share/fonts", + "/usr/local/share/fonts", + "/etc/fonts", + "/var/cache/fontconfig", + ], + AccessFs::from_read(ABI::V1), + ))? + .restrict_self()?; + if status.ruleset != RulesetStatus::FullyEnforced || !status.no_new_privs { + return Err("PDF sandbox requires Linux Landlock support".into()); + } + let mut calls = vec![ + libc::SYS_read, + libc::SYS_write, + libc::SYS_close, + libc::SYS_readv, + libc::SYS_writev, + libc::SYS_pread64, + libc::SYS_lseek, + libc::SYS_fstat, + libc::SYS_newfstatat, + libc::SYS_statx, + libc::SYS_openat, + libc::SYS_faccessat, + libc::SYS_faccessat2, + libc::SYS_fcntl, + libc::SYS_flock, + libc::SYS_getdents64, + libc::SYS_readlinkat, + libc::SYS_brk, + libc::SYS_mmap, + libc::SYS_mprotect, + libc::SYS_munmap, + libc::SYS_mremap, + libc::SYS_madvise, + libc::SYS_futex, + libc::SYS_clock_gettime, + libc::SYS_gettimeofday, + libc::SYS_getpid, + libc::SYS_gettid, + libc::SYS_getuid, + libc::SYS_geteuid, + libc::SYS_getgid, + libc::SYS_getegid, + libc::SYS_rt_sigreturn, + libc::SYS_rt_sigprocmask, + libc::SYS_rt_sigaction, + libc::SYS_sigaltstack, + libc::SYS_getrandom, + libc::SYS_sched_yield, + libc::SYS_exit, + libc::SYS_exit_group, + ]; + #[cfg(target_arch = "x86_64")] + calls.extend([ + libc::SYS_open, + libc::SYS_access, + libc::SYS_stat, + libc::SYS_lstat, + libc::SYS_readlink, + ]); + let filter: BpfProgram = SeccompFilter::new( + calls.into_iter().map(|number| (number, vec![])).collect(), + SeccompAction::Errno(libc::EPERM as u32), + SeccompAction::Allow, + std::env::consts::ARCH.try_into()?, + )? + .try_into()?; + seccompiler::apply_filter(&filter)?; + Ok(()) +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn library_path(executable: &std::path::Path) -> Result { + let directory = executable.parent().ok_or("Missing application directory")?; + let name = pdfium_render::prelude::Pdfium::pdfium_platform_library_name(); + #[cfg(target_os = "linux")] + { + // Linux packages keep private libraries out of the executable directory. + for libdir in ["../lib64/flectar-mail", "../lib/flectar-mail"] { + let packaged = directory.join(libdir).join(&name); + if packaged.is_file() { + return Ok(packaged); + } + } + } + Ok(directory.join(name)) +} + +#[cfg(target_os = "windows")] +struct WindowsJob(std::os::windows::io::OwnedHandle); +#[cfg(target_os = "windows")] +impl WindowsJob { + fn constrain(pid: u32) -> Result { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::System::{JobObjects::*, Threading::*}; + // Both handles are immediately owned, including every error path. + // Assignment happens before sending any document bytes to the child. + unsafe { + let raw = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if raw.is_null() { + return Err(std::io::Error::last_os_error().to_string()); + } + let job = Self(OwnedHandle::from_raw_handle(raw)); + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | JOB_OBJECT_LIMIT_PROCESS_MEMORY + | JOB_OBJECT_LIMIT_PROCESS_TIME; + limits.BasicLimitInformation.ActiveProcessLimit = 1; + limits.BasicLimitInformation.PerProcessUserTimeLimit = 15 * 10_000_000; + limits.ProcessMemoryLimit = 768 * 1024 * 1024; + if SetInformationJobObject( + job.0.as_raw_handle(), + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of_val(&limits) as u32, + ) == 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + let raw = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid); + if raw.is_null() { + return Err(std::io::Error::last_os_error().to_string()); + } + let process = OwnedHandle::from_raw_handle(raw); + if AssignProcessToJobObject(job.0.as_raw_handle(), process.as_raw_handle()) == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(job) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn reject_unbounded_renderer_dimensions() { + assert!(dimensions(0, 10).is_err()); + assert!(dimensions(u32::MAX, u32::MAX).is_err()); + assert!(dimensions(MAX_EDGE + 1, 1).is_err()); + assert_eq!(dimensions(1440, 1920).unwrap(), 11_059_200); + } +} diff --git a/src/pdf_preview_mobile.rs b/src/pdf_preview_mobile.rs new file mode 100644 index 0000000..ec2f7bf --- /dev/null +++ b/src/pdf_preview_mobile.rs @@ -0,0 +1,439 @@ +//! Mobile cannot spawn the desktop helper. Admit one job at a time, without a +//! queue, and keep its permit until native code actually exits (not just until +//! its caller times out). Cancellation is cooperative: PDFium's document/page +//! parsing and individual render operations are not forcibly interruptible. +//! The engine stays loaded after first use; documents, bitmaps and input bytes +//! are dropped after each job. No form environment, JavaScript or XFA is started. +use super::{MAX_DOCUMENT_BYTES, Page, dimensions}; +use pdfium_render::prelude::*; +use std::{ + path::PathBuf, + sync::{ + Arc, OnceLock, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +const MOBILE_EDGE: u32 = 2048; +const DEADLINE: Duration = Duration::from_secs(15); +static EPOCH: AtomicU64 = AtomicU64::new(0); +static SLOT: OnceLock> = OnceLock::new(); +// Bindings own the dynamic library. They are initialized once, exclusively by +// the admitted worker, and remain alive for the process lifetime. We use the +// public raw bindings because the high-level bitmap API has no pause callback. +static ENGINE: OnceLock, String>> = OnceLock::new(); +#[cfg(target_os = "android")] +static ANDROID_LIBRARY: OnceLock = OnceLock::new(); + +/// Supply ApplicationInfo.nativeLibraryDir, never a document-controlled path. +/// The APK uses extracted native libraries so this is a private installed file. +#[cfg(target_os = "android")] +pub fn configure_android(directory: PathBuf) -> Result<(), String> { + if !directory.is_absolute() { + return Err("Android did not provide an absolute native library directory.".into()); + } + let path = directory.join("libpdfium.so"); + if let Some(existing) = ANDROID_LIBRARY.get() { + return if existing == &path { + Ok(()) + } else { + Err("PDF library directory changed.".into()) + }; + } + ANDROID_LIBRARY + .set(path) + .map_err(|_| "PDF library already configured.".into()) +} + +/// Called on backgrounding and memory pressure, as well as by UI cancellation. +/// Does not kill a thread or pretend that a native parser has already stopped. +pub fn cancel_active() { + EPOCH.fetch_add(1, Ordering::AcqRel); +} + +fn library_path() -> Result { + #[cfg(target_os = "android")] + return ANDROID_LIBRARY + .get() + .cloned() + .ok_or_else(|| "PDF preview is not configured.".into()); + #[cfg(target_os = "ios")] + return std::env::current_exe() + .map_err(|e| e.to_string())? + .parent() + .map(|p| p.join("Frameworks/PDFium.framework/PDFium")) + .ok_or_else(|| "Missing application bundle.".into()); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + std::env::var_os("FLECTAR_TEST_PDFIUM") + .map(PathBuf::from) + .ok_or_else(|| "Set FLECTAR_TEST_PDFIUM for the native integration test.".into()) +} + +struct Cancellation { + cancelled: std::sync::atomic::AtomicBool, + epoch: u64, + deadline: Instant, +} +impl Cancellation { + fn new() -> Self { + Self { + cancelled: false.into(), + epoch: EPOCH.load(Ordering::Acquire), + deadline: Instant::now() + DEADLINE, + } + } + fn stopped(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + || self.epoch != EPOCH.load(Ordering::Acquire) + || Instant::now() >= self.deadline + } + fn check(&self) -> Result<(), String> { + if self.stopped() { + Err("PDF preview was cancelled or took too long. Download it to view locally.".into()) + } else { + Ok(()) + } + } +} +struct CancelOnDrop(Arc); +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.cancelled.store(true, Ordering::Release); + } +} + +pub(crate) async fn render(data: Arc>, index: u32, zoom: u32) -> Result { + if data.is_empty() || data.len() > MAX_DOCUMENT_BYTES || !(50..=200).contains(&zoom) { + return Err("This PDF exceeds the preview limit. Download it to view locally.".into()); + } + run_job(move |cancellation| render_page(&data, index, zoom, cancellation)).await +} + +async fn run_job( + work: impl FnOnce(&Cancellation) -> Result + Send + 'static, +) -> Result { + let permit = SLOT + .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(1))) + .clone() + .try_acquire_owned() + .map_err(|_| { + "The previous PDF preview is still stopping. Try again shortly.".to_string() + })?; + let cancellation = Arc::new(Cancellation::new()); + let guard = CancelOnDrop(cancellation.clone()); + let (sender, receiver) = tokio::sync::oneshot::channel(); + // A dedicated, low-priority thread avoids blocking either Slint or Tokio's + // pool. There is never a second parser or a backlog of retained documents. + std::thread::Builder::new() + .name("pdf-preview".into()) + .stack_size(4 * 1024 * 1024) + .spawn(move || { + #[cfg(target_os = "android")] + unsafe { + libc::nice(10); + } + #[cfg(target_os = "ios")] + unsafe { + libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_UTILITY, 0); + } + let result = work(&cancellation); + drop(permit); + let _ = sender.send(result); + }) + .map_err(|e| format!("Could not start PDF preview: {e}"))?; + let result = tokio::time::timeout(DEADLINE, receiver) + .await + .map_err(|_| { + "This PDF page took too long to render. Download it to view locally.".to_string() + })? + .map_err(|_| "The PDF renderer could not finish this page.".to_string())?; + drop(guard); + result +} + +fn page_dimensions(width: f32, height: f32, zoom: u32) -> Result<(u32, u32), String> { + if !width.is_finite() + || !height.is_finite() + || width <= 0.0 + || height <= 0.0 + || !(50..=200).contains(&zoom) + { + return Err("Invalid PDF page dimensions.".into()); + } + let width = f64::from(width); + let height = f64::from(height); + let scale = (f64::from(1024 * zoom / 100) / width) + .min(f64::from(MOBILE_EDGE) / height) + .min(f64::from(MOBILE_EDGE) / width); + let w = (width * scale).round().clamp(1.0, f64::from(MOBILE_EDGE)) as u32; + let h = (height * scale).round().clamp(1.0, f64::from(MOBILE_EDGE)) as u32; + Ok((w, h)) +} + +// The callback borrows Cancellation for the synchronous Start/Continue call. +// It never calls PDFium again (the bindings hold a mutex), allocates, or panics. +unsafe extern "C" fn pause_now(pause: *mut IFSDK_PAUSE) -> FPDF_BOOL { + unsafe { i32::from((&*((*pause).user.cast::())).stopped()) } +} + +struct Document<'a>(FPDF_DOCUMENT, &'a dyn PdfiumLibraryBindings); +impl Drop for Document<'_> { + fn drop(&mut self) { + unsafe { + self.1.FPDF_CloseDocument(self.0); + } + } +} +struct NativePage<'a>(FPDF_PAGE, &'a dyn PdfiumLibraryBindings); +impl Drop for NativePage<'_> { + fn drop(&mut self) { + unsafe { + self.1.FPDF_ClosePage(self.0); + } + } +} +struct Bitmap<'a>(FPDF_BITMAP, &'a dyn PdfiumLibraryBindings); +impl Drop for Bitmap<'_> { + fn drop(&mut self) { + unsafe { + self.1.FPDFBitmap_Destroy(self.0); + } + } +} +struct Progress<'a>(FPDF_PAGE, &'a dyn PdfiumLibraryBindings); +impl Drop for Progress<'_> { + fn drop(&mut self) { + unsafe { + self.1.FPDF_RenderPage_Close(self.0); + } + } +} + +fn render_page( + data: &[u8], + index: u32, + zoom: u32, + cancellation: &Cancellation, +) -> Result { + cancellation.check()?; + let bindings = ENGINE + .get_or_init(|| { + let bindings = Pdfium::bind_to_library(library_path()?).map_err(|_| { + "The bundled PDF preview component could not be loaded.".to_string() + })?; + // SAFETY: only this OnceLock initializer initializes this engine. The + // owning bindings and loaded library outlive every native handle. + unsafe { + bindings.FPDF_InitLibrary(); + } + Ok(bindings) + }) + .as_ref() + .map_err(Clone::clone)? + .as_ref(); + cancellation.check()?; + // SAFETY: all handles are checked before use, exclusively accessed by the + // single admitted worker, and dropped in reverse dependency order. Input + // bytes and caller-owned bitmap storage outlive their native handles. + unsafe { + let raw = bindings.FPDF_LoadMemDocument64(data, None); + if raw.is_null() { + return Err("Unable to open this PDF. It may be damaged or password protected.".into()); + } + let document = Document(raw, bindings); + cancellation.check()?; + let count = bindings.FPDF_GetPageCount(document.0); + if count <= 0 || index >= count as u32 { + return Err("PDF page out of range.".into()); + } + let raw = bindings.FPDF_LoadPage(document.0, index as i32); + if raw.is_null() { + return Err("Unable to read this PDF page.".into()); + } + let page = NativePage(raw, bindings); + cancellation.check()?; + let (width, height) = page_dimensions( + bindings.FPDF_GetPageWidthF(page.0), + bindings.FPDF_GetPageHeightF(page.0), + zoom, + )?; + let mut pixels = vec![255u8; dimensions(width, height)?]; + // FPDFBitmap_BGRA = 4; stride is explicit and storage is at most 16 MiB. + let raw = bindings.FPDFBitmap_CreateEx( + width as i32, + height as i32, + 4, + pixels.as_mut_ptr().cast(), + (width * 4) as i32, + ); + if raw.is_null() { + return Err("Unable to allocate a PDF preview bitmap.".into()); + } + let bitmap = Bitmap(raw, bindings); + let mut pause = IFSDK_PAUSE { + version: 1, + NeedToPauseNow: Some(pause_now), + user: std::ptr::from_ref(cancellation).cast_mut().cast(), + }; + // FPDF_ANNOT | FPDF_RENDER_LIMITEDIMAGECACHE; no form fill environment + // or document actions. Close progressive state even on cancellation. + let progress = Progress(page.0, bindings); + let mut status = bindings.FPDF_RenderPageBitmap_Start( + bitmap.0, + page.0, + 0, + 0, + width as i32, + height as i32, + 0, + 1 | 0x200, + &mut pause, + ); + while status == 1 { + // FPDF_RENDER_TOBECONTINUED + cancellation.check()?; + status = bindings.FPDF_RenderPage_Continue(page.0, &mut pause); + } + cancellation.check()?; + if status != 2 { + return Err("Unable to finish rendering this PDF page.".into()); + } + // Native bitmap no longer borrows pixels before returning their owner. + drop(progress); + drop(bitmap); + for pixel in pixels.as_chunks_mut::<4>().0 { + pixel.swap(0, 2); + } + Ok(Page { + pixels, + width, + height, + count: count as u32, + index, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + static TEST_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] + fn mobile_pixels_are_bounded_for_extreme_aspect_ratios() { + for (w, h) in [ + (612.0, 792.0), + (f32::MAX, 1.0), + (1.0, f32::MAX), + (f32::MIN_POSITIVE, 1.0), + ] { + for zoom in [50, 100, 150, 200] { + let (w, h) = page_dimensions(w, h, zoom).unwrap(); + assert!(w > 0 && h > 0 && w <= MOBILE_EDGE && h <= MOBILE_EDGE); + assert!(dimensions(w, h).unwrap() <= 16 * 1024 * 1024); + } + } + for invalid in [0.0, -1.0, f32::NAN, f32::INFINITY] { + assert!(page_dimensions(invalid, 1.0, 100).is_err()); + } + } + #[test] + fn cancellation_and_background_epoch_stop_native_callback() { + let _test = TEST_GATE.lock().unwrap(); + let cancellation = Arc::new(Cancellation::new()); + assert!(!cancellation.stopped()); + drop(CancelOnDrop(cancellation.clone())); + assert!(cancellation.stopped()); + let next = Cancellation::new(); + cancel_active(); + assert!(next.stopped()); + } + #[test] + fn cancelled_caller_cannot_start_a_second_native_worker() { + let _test = TEST_GATE.lock().unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + runtime.block_on(async { + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, blocked) = std::sync::mpsc::channel(); + let (finished, stopped) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(run_job(move |cancellation| { + started.send(()).unwrap(); + // Model a non-interruptible native parse without hanging a test. + blocked.recv_timeout(Duration::from_secs(5)).unwrap(); + finished.send(cancellation.stopped()).unwrap(); + Err("native parser finished".into()) + })); + ready.await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + let refused = run_job(|_| panic!("a second worker must not be started")).await; + assert!(refused.unwrap_err().contains("still stopping")); + release.send(()).unwrap(); + assert!(stopped.await.unwrap()); + tokio::time::timeout(Duration::from_secs(2), async { + while SLOT.get().unwrap().available_permits() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + run_job(|_| Err("recovered".into())).await.unwrap_err(), + "recovered" + ); + }); + } + #[test] + #[ignore = "requires FLECTAR_TEST_PDFIUM and FLECTAR_TEST_PDF; runs the actual mobile renderer on the host"] + fn mobile_native_pages_and_rejection() { + let _test = TEST_GATE.lock().unwrap(); + let data = Arc::new(std::fs::read(std::env::var_os("FLECTAR_TEST_PDF").unwrap()).unwrap()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + runtime.block_on(async { + for (index, zoom) in [(0, 100), (1, 100), (0, 200)] { + let page = render(data.clone(), index, zoom).await.unwrap(); + assert_eq!(page.count, 2); + assert_eq!(page.index, index); + let center = + (page.height as usize / 2 * page.width as usize + page.width as usize / 2) * 4; + let expected = if index == 0 { + [26, 102, 204, 255] + } else { + [204, 51, 26, 255] + }; + for (a, b) in page.pixels[center..center + 4].iter().zip(expected) { + assert!(a.abs_diff(b) <= 1); + } + assert_eq!(page.width, 1024 * zoom / 100); + assert!( + page.pixels[..(page.width * page.height / 3 * 4) as usize] + .as_chunks::<4>() + .0 + .iter() + .filter(|pixel| pixel[..3].iter().any(|v| *v < 230)) + .count() + > 100, + "standard PDF font should render visible text" + ); + if index == 0 + && zoom == 100 + && let Some(path) = std::env::var_os("FLECTAR_TEST_PDF_RGBA") + { + std::fs::write(path, &page.pixels).unwrap(); + } + } + assert!(render(data.clone(), 9, 100).await.is_err()); + assert!(render(data, 0, 999).await.is_err()); + assert!(render(Arc::new(b"bad PDF".to_vec()), 0, 100).await.is_err()); + let cancelled = Cancellation::new(); + cancelled.cancelled.store(true, Ordering::Release); + assert!(render_page(b"bad PDF", 0, 100, &cancelled).is_err()); + }); + } +} diff --git a/src/preview_controls.rs b/src/preview_controls.rs new file mode 100644 index 0000000..15a52a1 --- /dev/null +++ b/src/preview_controls.rs @@ -0,0 +1,34 @@ +//! Shared zoom parsing; invalid/non-finite drafts preserve the applied value. +pub(crate) fn zoom(action: &str, current: f32, minimum: f32, maximum: f32) -> f32 { + let requested = match action { + "zoom-in" => current * 1.2, + "zoom-out" => current / 1.2, + "zoom-reset" => 1.0, + _ => action + .strip_prefix("zoom-set:") + .and_then(|value| { + value + .trim() + .trim_end_matches('%') + .trim() + .parse::() + .ok() + }) + .filter(|value| value.is_finite()) + .map(|value| value / 100.0) + .unwrap_or(current), + }; + requested.clamp(minimum, maximum) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn percentage_drafts_and_limits() { + assert_eq!(zoom("zoom-set: 125% ", 1.0, 0.1, 3.0), 1.25); + for invalid in ["NaN", "inf", "", "oops"] { + assert_eq!(zoom(&format!("zoom-set:{invalid}"), 1.25, 0.1, 3.0), 1.25); + } + assert_eq!(zoom("zoom-set:900", 1.0, 0.1, 3.0), 3.0); + } +} diff --git a/src/reader_validation.rs b/src/reader_validation.rs index 8f8c3ab..9e3fd7e 100644 --- a/src/reader_validation.rs +++ b/src/reader_validation.rs @@ -21,6 +21,10 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_ let clipboard = Rc::new(RefCell::new(String::new())); slint::platform::set_platform(Box::new(Headless(window.clone(), clipboard.clone()))).unwrap(); let app = AppWindow::new().unwrap(); + app.global::() + .on_resolve(|action, current, min, max| { + crate::preview_controls::zoom(&action, current, min, max) + }); app.set_startup_ready(true); app.set_startup_hydrated(true); app.set_connected_accounts(ModelRc::new(VecModel::from(vec![AccountRow { @@ -291,6 +295,17 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_ draw("reader-dark", 390, 844); app.invoke_select_email_text(); assert!(app.get_has_selection()); + // Hydrated attachments must survive a body-rendering failure and remain + // projected independently of the HTML/plain-text reader. + state.borrow_mut().messages[0].attachments = vec![flectar_mail_core::models::AttachmentMeta { + id: 42, + filename: Some("Project overview.pdf".into()), + mime_type: Some("application/pdf".into()), + size: Some(1024), + is_inline: false, + }]; + render_current(&app, &state, &runtime).unwrap(); + assert_eq!(app.global::().get_rows().row_count(), 1); // A structural rejection while the reader is open must never leave the // previous message's accessible content visible over the fallback. state.borrow_mut().messages[0].html = Some("x".repeat(16_000)); @@ -305,8 +320,105 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_ !app.get_selected_plain_text() .contains("Reader verification") ); + assert_eq!(app.global::().get_rows().row_count(), 1); + app.set_theme_mode("light".into()); + draw("mail-attachments-fallback", 1280, 900); + draw("mail-attachments-landscape", 844, 390); + assert!( + app.get_email_viewport_height() >= 40.0, + "Short windows must retain room for the body while attachments remain available in the toolbar" + ); + + // Exercise the same percentage editor in the attachment dialog. A wide + // bitmap verifies fit on resize and manual zoom preservation without a + // network dependency or loading a native PDF library in this UI test. + let attachments = app.global::(); + attachments.set_name("Project overview.pdf".into()); + let mut page_pixels = slint::SharedPixelBuffer::::new(2400, 1600); + for (index, pixel) in page_pixels.make_mut_slice().iter_mut().enumerate() { + *pixel = slint::Rgba8Pixel::new( + (index % 2400 / 10) as u8, + (index / 2400 / 7) as u8, + 128, + 255, + ); + } + attachments.set_image(slint::Image::from_rgba8(page_pixels)); + attachments.set_pages(2); + attachments.set_is_image(true); + attachments.set_open(true); + draw("mail-preview-dialog", 1280, 900); + let copy_preview_zoom = || { + pointer(280.0, 128.0); + shortcut("a"); + shortcut("c"); + clipboard.borrow().clone() + }; + let fitted = copy_preview_zoom(); + assert_ne!( + fitted, "100%", + "Wide pages should fit the dialog on opening" + ); + shortcut("a"); + key("150%".into(), true); + key("150%".into(), false); + key(slint::platform::Key::Return.into(), true); + key(slint::platform::Key::Return.into(), false); + draw("mail-preview-zoom", 1280, 900); + assert_eq!(copy_preview_zoom(), "150%"); + app.window() + .dispatch_event(slint::platform::WindowEvent::PointerPressed { + position: slint::LogicalPosition::new(900.0, 500.0), + button: slint::platform::PointerEventButton::Left, + }); + for (x, y) in [(700.0, 450.0), (600.0, 400.0)] { + app.window() + .dispatch_event(slint::platform::WindowEvent::PointerMoved { + position: slint::LogicalPosition::new(x, y), + }); + } + draw("mail-preview-pan", 1280, 900); + app.window() + .dispatch_event(slint::platform::WindowEvent::PointerReleased { + position: slint::LogicalPosition::new(600.0, 400.0), + button: slint::platform::PointerEventButton::Left, + }); + let before = image::open("tmp/blitz-fixes/mail-preview-zoom.png") + .unwrap() + .to_rgb8(); + let after = image::open("tmp/blitz-fixes/mail-preview-pan.png") + .unwrap() + .to_rgb8(); + assert_ne!( + before.get_pixel(700, 500), + after.get_pixel(700, 500), + "Dragging must pan an enlarged preview" + ); + draw("mail-preview-resize", 1200, 800); + draw("mail-preview-restored", 1280, 900); + assert_eq!( + copy_preview_zoom(), + "150%", + "Resizing must preserve manual zoom" + ); + pointer(378.0, 128.0); + draw("mail-preview-fit", 1280, 900); + assert_eq!(copy_preview_zoom(), fitted); + let weak = app.as_weak(); + attachments.on_command(move |action, _| { + if action == "close" { + weak.unwrap().global::().set_open(false); + } + }); + key(slint::platform::Key::Escape.into(), true); + key(slint::platform::Key::Escape.into(), false); + assert!( + !attachments.get_open(), + "Escape must close a preview while its zoom editor is focused" + ); state.borrow_mut().messages.clear(); render_current(&app, &state, &runtime).unwrap(); + assert_eq!(app.global::().get_rows().row_count(), 0); assert_eq!(app.global::().get_message_id(), -1); assert!(app.global::().get_notice().is_empty()); assert!(!app.global::().get_available()); diff --git a/src/renderer.rs b/src/renderer.rs index e44570c..e8791fd 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -132,6 +132,8 @@ pub struct GpuEmailRenderer { pub preparation_viewport: (u32, u32, f32), pub loaded_key: Option<(u64, bool)>, pub zoom: f32, + pub auto_fit: bool, + fit_viewport: Option<(u32, u32)>, pub layout_width: f32, pub notice: Option, pub metadata_revision: u64, @@ -181,6 +183,8 @@ impl Default for GpuEmailRenderer { preparation_viewport: (INITIAL_WIDTH, INITIAL_HEIGHT, 1.0), loaded_key: None, zoom: 1.0, + auto_fit: false, + fit_viewport: None, layout_width: 520.0, notice: None, metadata_revision: 0, @@ -326,6 +330,7 @@ impl GpuEmailRenderer { } pub fn set_email(&mut self, email: PreparedEmail) { + self.fit_viewport = None; self.resources .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -523,6 +528,43 @@ impl GpuEmailRenderer { /// Render the retained document through the compatibility CPU painter. /// This is used only when Slint could not create a WGPU device, but it /// shares the same DOM and selection state as the GPU path. + pub fn set_auto_fit(&mut self, enabled: bool) { + self.auto_fit = enabled; + self.fit_viewport = None; + self.dirty = true; + } + + fn fit_to_viewport(&mut self, width: u32, height: u32) { + let _runtime = self + .resource_runtime + .as_ref() + .map(tokio::runtime::Handle::enter); + if !self.auto_fit { + return; + } + let Some(email) = self.email.as_mut() else { + return; + }; + self.dirty |= email.document.drain_pending_messages(); + if !self.dirty && self.fit_viewport == Some((width, height)) { + return; + } + // Measure authored overflow at 100%, then lay out the final viewport + // at the chosen scale. Recompute when resized or resources change. + email.document.set_viewport(Viewport::new( + width.max(1), + height.max(1), + 1.0, + ColorScheme::Light, + )); + email.document.resolve(0.0); + let natural = content_surface_width(&email.document, width.max(1) as f32); + self.zoom = ((width.max(1) as f32) / natural.max(1.0)).clamp(0.1, 1.0); + self.fit_viewport = Some((width, height)); + self.last_size = None; + self.dirty = true; + } + pub fn render_cpu_if_needed( &mut self, width: u32, @@ -530,6 +572,7 @@ impl GpuEmailRenderer { scale: f32, ) -> Result, String> { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.fit_to_viewport(width, height); self.render_cpu_inner(width, height, scale) })) { Ok(result) => result, @@ -649,6 +692,7 @@ impl GpuEmailRenderer { scale: f32, ) -> Result, String> { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.fit_to_viewport(width.max(1.0) as u32, height.max(1.0) as u32); self.render_gpu_inner(device, queue, width, height, scale) })) { Ok(result) => result, diff --git a/src/renderer/regression.rs b/src/renderer/regression.rs index 48dddb5..0afa614 100644 --- a/src/renderer/regression.rs +++ b/src/renderer/regression.rs @@ -615,3 +615,37 @@ fn float_responsive_columns_and_auto_width() { ); } } + +#[test] +fn auto_fit_tracks_resize_and_preserves_explicit_zoom() { + let mut r = renderer( + "
Wide newsletter
", + ); + r.set_auto_fit(true); + r.render_cpu_if_needed(600, 400, 1.0).unwrap(); + assert!((r.zoom - 600.0 / 900.0).abs() < 0.02); + assert!(r.layout_width <= 601.0); + r.render_cpu_if_needed(800, 400, 1.0).unwrap(); + assert!((r.zoom - 800.0 / 900.0).abs() < 0.02); + r.render_cpu_if_needed(1100, 400, 1.0).unwrap(); + assert_eq!(r.zoom, 1.0); + assert!(r.render_cpu_if_needed(1100, 400, 1.0).unwrap().is_none()); + r.render_cpu_if_needed(300, 500, 1.0).unwrap(); + assert!((r.zoom - 300.0 / 900.0).abs() < 0.02); + assert!( + r.layout_width <= 301.0, + "Automatic fit must accommodate phone widths" + ); + r.set_auto_fit(false); + r.set_zoom(1.25); + r.render_cpu_if_needed(600, 400, 1.0).unwrap(); + assert_eq!(r.zoom, 1.25); + r.set_auto_fit(true); + r.render_cpu_if_needed(600, 400, 1.0).unwrap(); + assert!(r.zoom < 1.0); + r.set_email( + prepare_email_html("

Responsive message

").unwrap(), + ); + r.render_cpu_if_needed(600, 400, 1.0).unwrap(); + assert_eq!(r.zoom, 1.0); +} diff --git a/src/renderer_input_controller.rs b/src/renderer_input_controller.rs index ae94b6a..6db3501 100644 --- a/src/renderer_input_controller.rs +++ b/src/renderer_input_controller.rs @@ -204,32 +204,26 @@ pub(super) fn register_renderer_input_callbacks( return; }; let reader = app.global::(); - let requested_zoom = command.strip_prefix("zoom-set:").and_then(|value| { - value - .trim() - .trim_end_matches('%') - .trim() - .parse::() - .ok() - .filter(|value| value.is_finite()) - .map(|value| value / 100.0) - }); + let requested_zoom = crate::preview_controls::zoom(&command, r.borrow().zoom, 0.5, 3.0); let command = if command.starts_with("zoom-set:") { "zoom-set" } else { command.as_str() }; match command { + "viewport" => repaint_reader(&app, &r, gpu), "zoom-set" | "zoom-in" | "zoom-out" | "zoom-reset" | "fit" => { let old = r.borrow().zoom; let zoom = match command { - "zoom-set" => requested_zoom.unwrap_or(old), + "zoom-set" => requested_zoom, "zoom-in" => old * 1.2, "zoom-out" => old / 1.2, "fit" => old / reader.get_width_ratio().max(1.0), _ => 1.0, } .clamp(0.5, 3.0); + r.borrow_mut().set_auto_fit(command == "fit"); + reader.set_auto_fit(command == "fit"); r.borrow_mut().set_zoom(zoom); reader.set_zoom(zoom); reader.set_zoom_revision(reader.get_zoom_revision().wrapping_add(1)); diff --git a/src/rich_compose.rs b/src/rich_compose.rs index 6fe6592..ae8e361 100644 --- a/src/rich_compose.rs +++ b/src/rich_compose.rs @@ -270,7 +270,12 @@ impl RichComposeDocument { let end = byte_to_char(&self.text, end); let mut links = self.styles[start..end] .iter() - .zip(self.text.chars().skip(start).take(end.saturating_sub(start))) + .zip( + self.text + .chars() + .skip(start) + .take(end.saturating_sub(start)), + ) .filter(|(_, character)| !character.is_whitespace()) .map(|(style, _)| style.link.as_deref()); let first = links.next().flatten()?; @@ -524,7 +529,10 @@ impl RichComposeDocument { } let mut url_end = end; while url_end > start - && matches!(chars[url_end - 1], '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}') + && matches!( + chars[url_end - 1], + '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' + ) { url_end -= 1; } @@ -607,9 +615,7 @@ fn normalize_link(value: &str) -> Option { if value.is_empty() { return None; } - if value.starts_with("https://") - || value.starts_with("http://") - || value.starts_with("mailto:") + if value.starts_with("https://") || value.starts_with("http://") || value.starts_with("mailto:") { Some(value.to_owned()) } else { @@ -641,7 +647,9 @@ fn continue_list_edit(old_text: &str, new_text: &str) -> Option<(String, i32)> { .map_or(0, |index| index + 1); let line = &old[line_start..insertion]; let (kind, prefix_len) = block_prefix(line)?; - let content_is_empty = line[prefix_len..].iter().all(|character| character.is_whitespace()); + let content_is_empty = line[prefix_len..] + .iter() + .all(|character| character.is_whitespace()); if content_is_empty { new.drain(line_start..=insertion); let text = new.iter().collect::(); @@ -664,10 +672,7 @@ fn continue_list_edit(old_text: &str, new_text: &str) -> Option<(String, i32)> { let cursor_character = insertion + 1 + prefix.len(); new.splice(insertion + 1..insertion + 1, prefix); let text = new.iter().collect::(); - Some(( - text.clone(), - to_i32(char_to_byte(&text, cursor_character)), - )) + Some((text.clone(), to_i32(char_to_byte(&text, cursor_character)))) } fn block_prefix(chars: &[char]) -> Option<(BlockKind, usize)> { @@ -905,7 +910,9 @@ mod tests { document.synchronize(text, text.len() as i32, text.len() as i32); assert_eq!( document.body_html().as_deref(), - Some("
") + Some( + "" + ) ); } @@ -920,7 +927,9 @@ mod tests { ); assert_eq!( document.body_html().as_deref(), - Some("") + Some( + "" + ) ); } diff --git a/src/settings_controller.rs b/src/settings_controller.rs index 3f003a9..a3856a1 100644 --- a/src/settings_controller.rs +++ b/src/settings_controller.rs @@ -33,10 +33,7 @@ pub(super) fn register_settings_preference_callbacks( )) { Ok(()) => app.set_sync_status(UiMessage::plain("Notification preferences saved.")), Err(error) => { - app.set_sync_status(UiMessage::detail( - "Could not save notifications: {}", - error, - )) + app.set_sync_status(UiMessage::detail("Could not save notifications: {}", error)) } } }); @@ -56,17 +53,12 @@ pub(super) fn register_settings_preference_callbacks( }; match runtime_for_sync_interval.block_on(core.set_sync_interval_minutes(i64::from(minutes))) { - Ok(()) => { - app.set_sync_status(UiMessage::detail( - "Sync interval set to {} minutes.", - minutes, - )) - } + Ok(()) => app.set_sync_status(UiMessage::detail( + "Sync interval set to {} minutes.", + minutes, + )), Err(error) => { - app.set_sync_status(UiMessage::detail( - "Could not save sync interval: {}", - error, - )) + app.set_sync_status(UiMessage::detail("Could not save sync interval: {}", error)) } } }); @@ -86,9 +78,7 @@ pub(super) fn register_settings_preference_callbacks( return; }; match runtime_for_read_on_open.block_on(core.set_mark_read_on_open(enabled)) { - Ok(()) => app.set_sync_status(UiMessage::plain( - "Message-opening preference saved.", - )), + Ok(()) => app.set_sync_status(UiMessage::plain("Message-opening preference saved.")), Err(error) => app.set_sync_status(UiMessage::detail( "Could not save message-opening preference: {}", error, @@ -109,9 +99,7 @@ pub(super) fn register_settings_preference_callbacks( }; match runtime_for_theme.block_on(core.set_theme(&theme)) { Ok(()) => app.set_sync_status(UiMessage::plain("Theme preference saved.")), - Err(error) => { - app.set_sync_status(UiMessage::detail("Could not save theme: {}", error)) - } + Err(error) => app.set_sync_status(UiMessage::detail("Could not save theme: {}", error)), } }); @@ -128,10 +116,9 @@ pub(super) fn register_settings_preference_callbacks( }; match runtime_for_theme_preset.block_on(core.set_theme_preset(preset.as_str())) { Ok(()) => app.set_sync_status(UiMessage::plain("Theme palette saved.")), - Err(error) => app.set_sync_status(UiMessage::detail( - "Could not save theme palette: {}", - error, - )), + Err(error) => { + app.set_sync_status(UiMessage::detail("Could not save theme palette: {}", error)) + } } }); @@ -165,9 +152,7 @@ pub(super) fn register_settings_preference_callbacks( dark_border: theme::color_to_hex(dark_border), }; let Some(core) = state_for_custom_theme.borrow().core.clone() else { - app.set_sync_status(UiMessage::plain( - "Custom theme updated for this session.", - )); + app.set_sync_status(UiMessage::plain("Custom theme updated for this session.")); return false; }; match runtime_for_custom_theme.block_on(core.set_custom_theme(custom_theme)) { @@ -220,9 +205,7 @@ pub(super) fn register_settings_preference_callbacks( return; }; match runtime_for_sidebar_icons.block_on(core.set_monochrome_sidebar_icons(monochrome)) { - Ok(()) => app.set_sync_status(UiMessage::plain( - "Sidebar icon preference saved.", - )), + Ok(()) => app.set_sync_status(UiMessage::plain("Sidebar icon preference saved.")), Err(error) => app.set_sync_status(UiMessage::detail( "Could not save sidebar icon preference: {}", error, @@ -245,12 +228,10 @@ pub(super) fn register_settings_preference_callbacks( }; match runtime_for_avatars.block_on(core.set_show_avatars(enabled)) { Ok(()) => app.set_sync_status(UiMessage::plain("Avatar preference saved.")), - Err(error) => { - app.set_sync_status(UiMessage::detail( - "Could not save avatar preference: {}", - error, - )) - } + Err(error) => app.set_sync_status(UiMessage::detail( + "Could not save avatar preference: {}", + error, + )), } }); @@ -268,15 +249,11 @@ pub(super) fn register_settings_preference_callbacks( return; }; match runtime_for_workspace_layout.block_on(core.set_workspace_layout(layout.as_str())) { - Ok(()) => app.set_sync_status(UiMessage::plain( - "Workspace layout preference saved.", + Ok(()) => app.set_sync_status(UiMessage::plain("Workspace layout preference saved.")), + Err(error) => app.set_sync_status(UiMessage::detail( + "Could not save workspace layout: {}", + error, )), - Err(error) => { - app.set_sync_status(UiMessage::detail( - "Could not save workspace layout: {}", - error, - )) - } } }); } diff --git a/src/startup.rs b/src/startup.rs index f022ecf..7124609 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -108,6 +108,7 @@ impl From for mail::MailMessage { labels: message.labels, html: None, text: None, + attachments: Vec::new(), body_pending: true, sender_verification: message.sender_verification, } @@ -579,7 +580,7 @@ pub(crate) fn apply_settings(app: &AppWindow, settings: &Settings) { _ => 5, }); app.set_mark_read_on_open(settings.mark_read_on_open); - app.set_close_to_tray(settings.close_to_tray); + app.set_close_to_tray(settings.close_to_tray && !cfg!(feature = "flatpak")); app.set_monochrome_sidebar_icons(settings.monochrome_sidebar_icons); app.set_show_avatars(settings.show_avatars); app.set_workspace_layout( @@ -661,6 +662,7 @@ mod warm_start_tests { labels: Vec::new(), html: Some("

body must not enter warm cache

".into()), text: Some("Plain body must not enter warm cache".into()), + attachments: Vec::new(), body_pending: false, sender_verification: "domain".into(), } diff --git a/src/theme.rs b/src/theme.rs index c729ba5..73d1eae 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -7,13 +7,16 @@ pub(crate) fn register_theme_utilities(app: &AppWindow) { let utilities = app.global::(); utilities.on_color_to_hex(|color| color_to_hex(color).into()); utilities.on_valid_color(|value| parse_hex_color(value.as_str()).is_some()); - utilities.on_parse_color(|value, fallback| { - parse_hex_color(value.as_str()).unwrap_or(fallback) - }); + utilities.on_parse_color(|value, fallback| parse_hex_color(value.as_str()).unwrap_or(fallback)); } pub(crate) fn color_to_hex(color: Color) -> String { - format!("#{:02X}{:02X}{:02X}", color.red(), color.green(), color.blue()) + format!( + "#{:02X}{:02X}{:02X}", + color.red(), + color.green(), + color.blue() + ) } pub(crate) fn parse_hex_color(value: &str) -> Option { diff --git a/src/ui_message.rs b/src/ui_message.rs index 46531b5..0305f99 100644 --- a/src/ui_message.rs +++ b/src/ui_message.rs @@ -25,10 +25,7 @@ impl UiMessage { }; pub(crate) fn plain(key: &'static str) -> Self { - Self { - key, - ..Self::EMPTY - } + Self { key, ..Self::EMPTY } } pub(crate) fn detail(key: &'static str, argument: impl ToString) -> Self { diff --git a/src/window_controller.rs b/src/window_controller.rs index 5a25b3d..a83fc37 100644 --- a/src/window_controller.rs +++ b/src/window_controller.rs @@ -10,12 +10,12 @@ use crate::tray_ui::FlectarTray; pub(super) fn create_and_register_window_lifecycle( app: &AppWindow, ) -> Result, slint::PlatformError> { - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(any(target_os = "android", target_os = "ios", feature = "flatpak")))] let tray = Some(FlectarTray::new()?); - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(any(target_os = "android", target_os = "ios", feature = "flatpak"))] let tray: Option = None; - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(any(target_os = "android", target_os = "ios", feature = "flatpak"))] app.set_close_to_tray(false); if let Some(tray) = tray.as_ref() { @@ -79,10 +79,10 @@ pub(super) fn register_window_preference_callbacks( let Some(app) = app_weak.upgrade() else { return; }; - if cfg!(any(target_os = "android", target_os = "ios")) { + if cfg!(any(target_os = "android", target_os = "ios", feature = "flatpak")) { app.set_close_to_tray(false); app.set_sync_status(UiMessage::plain( - "System tray behavior is unavailable on mobile.", + "System tray behavior is unavailable in this package.", )); return; } @@ -117,6 +117,7 @@ pub(super) fn register_window_preference_callbacks( calendar_error: None, clear_account_form: false, finishes_oauth: false, + finishes_account_setup: false, close_to_tray: None, }, Err(error) => UiTaskUpdate { @@ -126,6 +127,7 @@ pub(super) fn register_window_preference_callbacks( calendar_error: None, clear_account_form: false, finishes_oauth: false, + finishes_account_setup: false, // Restore both the setting and tray visibility if the // preference could not be persisted. close_to_tray: Some(!enabled), diff --git a/tests/workspace_pagination.rs b/tests/workspace_pagination.rs new file mode 100644 index 0000000..7f46abe --- /dev/null +++ b/tests/workspace_pagination.rs @@ -0,0 +1,105 @@ +//! Exercise the actual shared Slint controller with deterministic time, without +//! a display server. No copies of the pagination algorithm live in these tests. +use slint::platform::{Platform, WindowAdapter}; +use std::{cell::Cell, rc::Rc, time::Duration}; + +slint::slint! { + import { InfiniteScrollController } from "../ui/components/infinite-scroll-controller.slint"; + export component PaginationHarness inherits Window { + in-out property active: true; + in-out property loading: false; + in-out property more: true; + in-out property rows: 100; + in-out property revision: 0; + in-out property content: 8200px; + in-out property position: 0px; + in-out property viewport: 600px; + callback requested; + public function scroll() { pagination.user-scrolled(); } + public function reset() { pagination.reset(); } + pagination := InfiniteScrollController { + active: root.active; loading: root.loading; can-load-more: root.more; + row-count: root.rows; revision: root.revision; + content-height: root.content; viewport-y: root.position; + visible-height: root.viewport; + load-more => { root.requested(); } + } + } +} +struct Clock(Rc>); +impl Platform for Clock { + fn create_window_adapter(&self) -> Result, slint::PlatformError> { + Ok( + slint::platform::software_renderer::MinimalSoftwareWindow::new( + slint::platform::software_renderer::RepaintBufferType::ReusedBuffer, + ), + ) + } + fn duration_since_start(&self) -> Duration { + self.0.get() + } +} + +#[test] +fn shared_workspace_pagination_handles_geometry_retries_and_inactive_views() { + let clock = Rc::new(Cell::new(Duration::ZERO)); + slint::platform::set_platform(Box::new(Clock(clock.clone()))).unwrap(); + let ui = PaginationHarness::new().unwrap(); + let requests = Rc::new(Cell::new(0)); + let counted = requests.clone(); + ui.on_requested(move || counted.set(counted.get() + 1)); + let settle = || { + for _ in 0..3 { + clock.set(clock.get() + Duration::from_millis(2)); + slint::platform::update_timers_and_animations(); + } + }; + settle(); + assert_eq!(requests.get(), 0, "do not load while far from the tail"); + ui.set_position(-7400.); + ui.invoke_scroll(); + assert_eq!(requests.get(), 1, "prefetch before reaching the bottom"); + ui.set_loading(true); + ui.invoke_scroll(); + settle(); + assert_eq!(requests.get(), 1, "never duplicate an in-flight request"); + // A successful page commits before its new ListView geometry settles. + ui.set_rows(200); + ui.set_revision(1); + ui.set_loading(false); + settle(); + assert_eq!(requests.get(), 1, "wait for the committed row geometry"); + ui.set_content(16400.); + settle(); + assert_eq!(requests.get(), 1); + ui.set_position(-15600.); + ui.invoke_scroll(); + assert_eq!(requests.get(), 2); + // An error does not increment revision. Time alone must not retry. + ui.set_loading(true); + settle(); + ui.set_loading(false); + settle(); + assert_eq!(requests.get(), 2); + ui.invoke_scroll(); + assert_eq!(requests.get(), 3, "a new gesture retries an error"); + ui.set_active(false); + ui.set_content(100.); + ui.set_rows(1); + ui.set_position(0.); + ui.invoke_reset(); + settle(); + assert_eq!(requests.get(), 3, "hidden workspaces do not page"); + ui.set_active(true); + settle(); + assert_eq!( + requests.get(), + 4, + "short pages automatically fill the viewport" + ); + ui.set_more(false); + ui.invoke_reset(); + ui.invoke_scroll(); + settle(); + assert_eq!(requests.get(), 4, "stop at the last page"); +} diff --git a/ui/app.slint b/ui/app.slint index c4d1bfa..e8c8a77 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1,3 +1,9 @@ +export { AccountMailPreferences, SignatureChoice } from "components/account-mail-preferences.slint"; +import { OpenPgpReader } from "components/account-mail-preferences.slint"; +import { MailAttachments } from "models.slint"; +import { AttachmentPreviewDialog } from "dialogs/attachment-preview-dialog.slint"; +export { MailAttachments, MailAttachment } from "models.slint"; +export { ZoomApi } from "components/zoom-controls.slint"; // Bundled once at compile time and used throughout the application chrome. // Rust registers the separate Noto Emoji face before constructing this window. import "../resources/fonts/google-sans-flex/GoogleSansFlex-VariableFont_GRAD,ROND,opsz,slnt,wdth,wght.ttf"; @@ -17,6 +23,8 @@ import { ComposeDialog } from "dialogs/compose-dialog.slint"; import { FolderDialog } from "dialogs/folder-dialog.slint"; import { SettingsDialog } from "dialogs/settings-dialog.slint"; import { MailView } from "views/mail-view.slint"; +export { FilesUi, FileRow, OperationRow } from "views/files-view.slint"; +import { FilesView, FilesUi } from "views/files-view.slint"; import { ContactsView } from "views/contacts-view.slint"; import { OnboardingView } from "views/onboarding-view.slint"; @@ -172,7 +180,7 @@ export global I18n { if (key == "Blitz software selection render failed: {}") { return @tr("Blitz software selection render failed: {}", argument); } if (key == "Blitz software tile render failed: {}") { return @tr("Blitz software tile render failed: {}", argument); } if (key == "Message ready.") { return @tr("Message ready."); } - if (key == "System tray behavior is unavailable on mobile.") { return @tr("System tray behavior is unavailable on mobile."); } + if (key == "System tray behavior is unavailable in this package.") { return @tr("System tray behavior is unavailable in this package."); } if (key == "Close-to-tray is active for this session.") { return @tr("Close-to-tray is active for this session."); } if (key == "Saving close behavior…") { return @tr("Saving close behavior…"); } if (key == "Closing the window will now keep Flectar Mail in the tray.") { return @tr("Closing the window will now keep Flectar Mail in the tray."); } @@ -363,11 +371,16 @@ export global I18n { if (key == "Message queued for delivery (action {}).") { return @tr("Message queued for delivery (action {}).", argument); } if (key == "Draft saved locally (#{}).") { return @tr("Draft saved locally (#{}).", argument); } if (key == "Compose failed: {}") { return @tr("Compose failed: {}", argument); } - if (key == "IMAP port must be a number.") { return @tr("IMAP port must be a number."); } - if (key == "SMTP port must be a number.") { return @tr("SMTP port must be a number."); } + if (key == "IMAP port must be between 1 and 65535.") { return @tr("IMAP port must be between 1 and 65535."); } + if (key == "SMTP port must be between 1 and 65535.") { return @tr("SMTP port must be between 1 and 65535."); } if (key == "Local mail data is unavailable. Retry startup.") { return @tr("Local mail data is unavailable. Retry startup."); } if (key == "Discovering JMAP and checking capabilities…") { return @tr("Discovering JMAP and checking capabilities…"); } - if (key == "Testing IMAP connection…") { return @tr("Testing IMAP connection…"); } + if (key == "Certificate imported. Connect to verify the server.") { return @tr("Certificate imported. Connect to verify the server."); } + if (key == "Could not load account preferences: {}") { return @tr("Could not load account preferences: {}", argument); } + if (key == "Your edited signature was preserved. Choose No signature to keep it as message text before inserting another.") { return @tr("Your edited signature was preserved. Choose No signature to keep it as message text before inserting another."); } + if (key == "Certificate import failed: {}") { return @tr("Certificate import failed: {}", argument); } + if (key == "Paste the PEM certificate into the certificate field.") { return @tr("Paste the PEM certificate into the certificate field."); } + if (key == "Testing IMAP and SMTP connections…") { return @tr("Testing IMAP and SMTP connections…"); } return ""; } @@ -377,6 +390,8 @@ export global I18n { if (key == "Choose Gmail or Outlook OAuth.") { return @tr("Choose Gmail or Outlook OAuth."); } if (key == "Complete sign-in in your browser, or choose Cancel here.") { return @tr("Complete sign-in in your browser, or choose Cancel here."); } if (key == "Cancelling browser sign-in…") { return @tr("Cancelling browser sign-in…"); } + if (key == "Finishing browser sign-in…") { return @tr("Finishing browser sign-in…"); } + if (key == "Could not use that browser address: {}") { return @tr("Could not use that browser address: {}", argument); } if (key == "Calendar core is unavailable.") { return @tr("Calendar core is unavailable."); } if (key == "Account no longer exists.") { return @tr("Account no longer exists."); } if (key == "Authorize calendar access in your browser.") { return @tr("Authorize calendar access in your browser."); } @@ -638,6 +653,7 @@ export component AppWindow inherits Window { in-out property email-scroll-y: 0px; in-out property settings_open: false; in-out property active_view: "mail"; + changed active_view => { if root.active_view != "mail" { MailAttachments.command("close", ""); } } in-out property settings_tab: "General"; in-out property theme_mode: "system"; in-out property show_avatars: true; @@ -650,6 +666,8 @@ export component AppWindow inherits Window { in-out property sync_interval_minutes: 5; in-out property mark_read_on_open: true; in-out property close_to_tray: false; + in-out property account_setup_in_progress: false; + in-out property account_form_open: false; in-out property oauth_in_progress: false; in property remote-images-available: true; in-out property remote-images-enabled: false; @@ -700,6 +718,29 @@ export component AppWindow inherits Window { in-out property caldav_url: ""; in-out property caldav_username: ""; in-out property caldav_password: ""; + in-out property imap_security: "auto"; + in-out property smtp_security: "auto"; + in-out property trusted_certificate_pem; + in property oauth_authorization_url; + in property oauth_browser_error; + callback retry_oauth_browser(); + callback copy_oauth_link(); + callback submit_oauth_redirect(string); + callback copy_oauth_authorization_url(); + copy_oauth_authorization_url => { + if root.oauth_in_progress && root.oauth_authorization_url != "" { + oauth-clipboard.text = root.oauth_authorization_url; + oauth-clipboard.select-all(); + oauth-clipboard.copy(); + } + } + oauth-clipboard := TextInput { + x: -10000px; y: -10000px; width: 1px; height: 1px; + visible: false; enabled: false; read-only: true; + accessible-role: none; + } + + callback import_mail_certificate(); in-out property imap_host: ""; in-out property imap_port: "993"; in-out property smtp_host: ""; @@ -752,7 +793,8 @@ export component AppWindow inherits Window { private property compact-primary-nav-visible: root.compact-layout && !root.compose_open && !root.calendar_editor_open && !(root.active_view == "mail" && mail-view.compact-page == "message") - && !(root.active_view == "contacts" && contacts-view.detail-page-visible); + && !(root.active_view == "contacts" && contacts-view.detail-page-visible) + && !(root.active_view == "files" && FilesUi.preview-open); private property compact-primary-nav-height: LayoutMetrics.compact-navigation-height; private property folder-dialog-open: false; private property folder-dialog-item-kind: "folder"; @@ -810,6 +852,12 @@ export component AppWindow inherits Window { } system-back-requested => { + if MailAttachments.open { MailAttachments.command("close", ""); return true; } + if root.active_view == "files" && FilesUi.transfers-open {FilesUi.transfers-open=false;return true;} + if root.active_view == "files" && FilesUi.dialog != "" { FilesUi.dialog = ""; return true; } + if root.active_view == "files" && files-view.close-navigation() { return true; } + if root.active_view == "files" && FilesUi.preview-open { FilesUi.preview-open = false; if FilesUi.busy { FilesUi.command("cancel", "", ""); } FilesUi.command("release-preview", "", ""); return true; } + if root.active_view == "files" && FilesUi.can-up && !FilesUi.busy { FilesUi.command("up", "", ""); return true; } if (root.folder-dialog-open) { root.folder-dialog-open = false; return true; @@ -881,6 +929,7 @@ export component AppWindow inherits Window { root.active_view = view; root.settings_open = false; if (view == "contacts") { root.load-contacts(); } + if (view == "files") { FilesUi.command("load", "", ""); } } } @@ -1147,6 +1196,15 @@ export component AppWindow inherits Window { } } + WorkspaceFrame { + visible: root.active_view == "files"; + compact: root.compact-layout; + reserve-compact-navigation: root.compact-primary-nav-visible; + available-width: parent.width; + available-height: parent.height; + files-view := FilesView { active: root.active_view == "files"; width: parent.width; height: parent.height; compact: root.compact-layout; layout-mode: root.workspace_layout; account-model: root.connected_accounts; } + } + CompactProductNav { visible: root.compact-primary-nav-visible; x: 0px; @@ -1165,6 +1223,7 @@ export component AppWindow inherits Window { root.active_view = view; root.settings_open = false; if (view == "contacts") { root.load-contacts(); } + if (view == "files") { FilesUi.command("load", "", ""); } } } @@ -1216,6 +1275,8 @@ export component AppWindow inherits Window { remote-images-available: root.remote-images-available; account-transfer-supported: root.document_apis_supported; sync_in_progress: root.sync_in_progress; + account_setup_in_progress: root.account_setup_in_progress; + imap_form_open <=> root.account_form_open; oauth_in_progress: root.oauth_in_progress; oauth_settings_saving: root.oauth_settings_saving; sync_status: root.sync_status; @@ -1256,6 +1317,15 @@ export component AppWindow inherits Window { caldav_url <=> root.caldav_url; caldav_username <=> root.caldav_username; caldav_password <=> root.caldav_password; + imap_security <=> root.imap_security; + smtp_security <=> root.smtp_security; + trusted_certificate_pem <=> root.trusted_certificate_pem; + oauth_authorization_url: root.oauth_authorization_url; + oauth_browser_error: root.oauth_browser_error; + retry_oauth_browser => { root.retry_oauth_browser(); } + copy_oauth_link => { root.copy_oauth_link(); } + submit_oauth_redirect(address) => { root.submit_oauth_redirect(address); } + import_mail_certificate => { root.import_mail_certificate(); } imap_host <=> root.imap_host; imap_port <=> root.imap_port; smtp_host <=> root.smtp_host; @@ -1469,6 +1539,7 @@ export component AppWindow inherits Window { width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom; startup-ready: root.startup_ready; + account-setup-in-progress: root.account_setup_in_progress; oauth-in-progress: root.oauth_in_progress; oauth-settings-saving: root.oauth_settings_saving; oauth-settings-error <=> root.oauth_settings_error; @@ -1495,6 +1566,16 @@ export component AppWindow inherits Window { account-password <=> root.account_password; account-protocol <=> root.account_protocol; jmap-url <=> root.jmap_url; + imap-security <=> root.imap_security; + smtp-security <=> root.smtp_security; + trusted-certificate-pem <=> root.trusted_certificate_pem; + account-username <=> root.account_username; + oauth-authorization-url: root.oauth_authorization_url; + oauth-browser-error: root.oauth_browser_error; + retry-oauth-browser => { root.retry_oauth_browser(); } + copy-oauth-link => { root.copy_oauth_link(); } + submit-oauth-redirect(address) => { root.submit_oauth_redirect(address); } + import-mail-certificate => { root.import_mail_certificate(); } imap-host <=> root.imap_host; imap-port <=> root.imap_port; smtp-host <=> root.smtp_host; @@ -1597,4 +1678,13 @@ export component AppWindow inherits Window { } } } + AttachmentPreviewDialog { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom; + compact: root.compact-layout; + } + + OpenPgpReader { width: parent.width; height: parent.height; } + } diff --git a/ui/components/account-mail-preferences.slint b/ui/components/account-mail-preferences.slint new file mode 100644 index 0000000..9358694 --- /dev/null +++ b/ui/components/account-mail-preferences.slint @@ -0,0 +1,166 @@ +import { VerticalBox, HorizontalBox, ScrollView, TextEdit } from "std-widgets.slint"; +import { AccountRow } from "../models.slint"; +import { AppTheme } from "../design-system.slint"; +import { TypeScale } from "../typography.slint"; +import { Button } from "button.slint"; +import { LineEdit } from "controls.slint"; +import { SettingsSelectRow, SettingsSwitchRow } from "settings-components.slint"; + +export struct SignatureChoice { id: string, name: string, text: string } +export global AccountMailPreferences { + in-out property account-id: -1; + in-out property account-label; + in-out property <[SignatureChoice]> signatures; + in-out property <[string]> signature-names; + in-out property <[string]> default-names; + in-out property editing-index: -1; + in-out property editing-id; + in-out property signature-name; + in-out property signature-text; + in-out property new-index: 0; + in-out property reply-index: 0; + in-out property fingerprint; + in-out property sign: false; + in-out property encrypt: false; + in-out property recipient-keys; + in-out property key-inventory; + in-out property status; + in-out property busy: false; + in-out property desktop-supported: true; + callback load(int); + callback edit-signature(int); + callback save-signature; + callback delete-signature; + callback save-defaults; + callback save-security; + callback key-action(string); + callback composer-reset; + callback composer-account-changed(int); + callback composer-signature(int); + in-out property <[string]> composer-signature-names; + in-out property composer-signature-index: 0; + in-out property composer-security; + in-out property composer-preferences-ready: false; + callback read-message; + callback export-message; + callback close-reader; + callback context-changed; + in-out property reader-open: false; + in-out property reader-status; + in-out property reader-text; +} + +export component AccountMailPreferencesPanel inherits Rectangle { + in property <[AccountRow]> accounts; + in property compact: false; + in-out property security-tab: false; + background: AppTheme.surface; + border-radius: 10px; + border-width: 1px; + border-color: AppTheme.input-border; + vertical-stretch: 0; + content := VerticalBox { + padding: 20px; spacing: 12px; alignment: start; + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("Signatures & OpenPGP"); color: AppTheme.heading; font-size: TypeScale.subtitle; font-weight: 700; } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("Choose an account to manage your signature and message protection."); color: AppTheme.secondary; wrap: word-wrap; } + for account in root.accounts : Button { + text: account.email; + primary: AccountMailPreferences.account-id == account.id; + enabled: !AccountMailPreferences.busy; + clicked => { AccountMailPreferences.load(account.id); } + } + if AccountMailPreferences.account-id > 0 : VerticalBox { + spacing: 12px; + Text { min-height: self.preferred-height; vertical-stretch: 0; text: AccountMailPreferences.account-label; color: AppTheme.heading; font-weight: 600; overflow: elide; } + HorizontalBox { + Button { text: @tr("Signatures"); primary: !root.security-tab; clicked => { root.security-tab = false; } } + Button { text: @tr("OpenPGP"); primary: root.security-tab; clicked => { root.security-tab = true; } } + } + if !root.security-tab : VerticalBox { + spacing: 10px; + SettingsSelectRow { + compact: root.compact; title: @tr("Edit signature"); description: @tr("Saved separately for each account."); + choices: AccountMailPreferences.signature-names; selected-index: AccountMailPreferences.editing-index; + title-color: AppTheme.heading; description-color: AppTheme.secondary; + selected(index, value) => { AccountMailPreferences.edit-signature(index); } + } + Button { text: @tr("New signature"); enabled: !AccountMailPreferences.busy; clicked => { AccountMailPreferences.edit-signature(-1); } } + LineEdit { text <=> AccountMailPreferences.signature-name; input-label: @tr("Signature name"); placeholder-text: @tr("Signature name, for example Work"); enabled: !AccountMailPreferences.busy; } + TextEdit { wrap: word-wrap; height: 160px; text <=> AccountMailPreferences.signature-text; placeholder-text: @tr("Your name\nRole · Company\nPhone · Website"); enabled: !AccountMailPreferences.busy; } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("Plain text works in every email client. You can format the inserted signature in the composer."); color: AppTheme.secondary; wrap: word-wrap; } + HorizontalBox { + Button { text: @tr("Save signature"); primary: true; enabled: !AccountMailPreferences.busy; clicked => { AccountMailPreferences.save-signature(); } } + Button { text: @tr("Delete"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.editing-id != ""; clicked => { AccountMailPreferences.delete-signature(); } } + } + SettingsSelectRow { + compact: root.compact; title: @tr("New messages"); description: @tr("Signature inserted when you start a message."); + choices: AccountMailPreferences.default-names; selected-index <=> AccountMailPreferences.new-index; + title-color: AppTheme.heading; description-color: AppTheme.secondary; + selected(index, value) => { AccountMailPreferences.new-index = index; } + } + SettingsSelectRow { + compact: root.compact; title: @tr("Replies & forwards"); description: @tr("Placed above the quoted message."); + choices: AccountMailPreferences.default-names; selected-index <=> AccountMailPreferences.reply-index; + title-color: AppTheme.heading; description-color: AppTheme.secondary; + selected(index, value) => { AccountMailPreferences.reply-index = index; } + } + Button { text: @tr("Save signature defaults"); enabled: !AccountMailPreferences.busy; clicked => { AccountMailPreferences.save-defaults(); } } + } + if root.security-tab : VerticalBox { + spacing: 10px; + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("OpenPGP protects message bodies and attachments. Digital signatures let recipients check who signed a message."); color: AppTheme.secondary; wrap: word-wrap; } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: AccountMailPreferences.desktop-supported ? @tr("Uses installed GnuPG 2.x and its secure key storage. Passphrases are entered in GnuPG's unlock dialog.") : @tr("OpenPGP is unavailable on this mobile build. Protected accounts cannot send without a supported encryption engine."); color: AppTheme.secondary; wrap: word-wrap; } + Button { text: @tr("Create a new key"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; clicked => { AccountMailPreferences.key-action("generate"); } } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("New keys expire in two years. GnuPG asks you to protect the private key with a passphrase."); color: AppTheme.secondary; wrap: word-wrap; } + LineEdit { text <=> AccountMailPreferences.fingerprint; input-label: @tr("Your full OpenPGP fingerprint"); placeholder-text: @tr("Your full OpenPGP key fingerprint"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; } + SettingsSwitchRow { + title: @tr("Digitally sign messages"); description: @tr("Proves possession of your private key; does not encrypt the message."); + compact: root.compact; checked: AccountMailPreferences.sign; enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; toggled(value) => { AccountMailPreferences.sign = value; } + title-color: AppTheme.heading; description-color: AppTheme.secondary; + } + SettingsSwitchRow { + title: @tr("Require encryption"); description: @tr("Blocks sending unless every recipient has a verified, usable key."); + compact: root.compact; checked: AccountMailPreferences.encrypt; enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; toggled(value) => { AccountMailPreferences.encrypt = value; } + title-color: AppTheme.heading; description-color: AppTheme.secondary; + } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("Verified recipient keys"); color: AppTheme.heading; font-weight: 600; } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("Compare fingerprints with recipients through a separate trusted channel, then enter one email=fingerprint per line. Importing a key alone does not verify it."); color: AppTheme.secondary; wrap: word-wrap; } + TextEdit { wrap: word-wrap; height: 130px; text <=> AccountMailPreferences.recipient-keys; placeholder-text: @tr("person@example.org=FULL_FINGERPRINT"); enabled: !AccountMailPreferences.busy; } + Button { text: @tr("Save OpenPGP settings"); primary: true; enabled: !AccountMailPreferences.busy; clicked => { AccountMailPreferences.save-security(); } } + if !root.compact : HorizontalBox { + Button { text: @tr("Import key"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; clicked => { AccountMailPreferences.key-action("import"); } } + Button { text: @tr("Export public key"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; clicked => { AccountMailPreferences.key-action("export"); } } + Button { text: @tr("Show keys"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; clicked => { AccountMailPreferences.key-action("list"); } } + } + if root.compact : VerticalBox { + spacing: 8px; + Button { text: @tr("Import key"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; clicked => { AccountMailPreferences.key-action("import"); } } + Button { text: @tr("Export public key"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; clicked => { AccountMailPreferences.key-action("export"); } } + Button { text: @tr("Show keys"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; clicked => { AccountMailPreferences.key-action("list"); } } + } + Button { text: @tr("Open message file (.eml)"); enabled: !AccountMailPreferences.busy && AccountMailPreferences.desktop-supported; clicked => { AccountMailPreferences.key-action("open"); } } + if AccountMailPreferences.key-inventory != "" : TextEdit { wrap: word-wrap; height: 150px; read-only: true; text: AccountMailPreferences.key-inventory; } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("Manage expiry, revocation and private-key backups in GnuPG. Only public keys are exported here."); color: AppTheme.secondary; wrap: word-wrap; } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("Subjects and addresses remain visible. Protected drafts stay on this device. Existing server drafts are not retroactively protected. S/MIME certificates are not supported."); color: AppTheme.secondary; wrap: word-wrap; } + } + if AccountMailPreferences.busy || AccountMailPreferences.status != "" : Text { + text: AccountMailPreferences.busy ? @tr("Working…") : AccountMailPreferences.status; + color: AppTheme.secondary; wrap: word-wrap; + } + } + } +} + +export component OpenPgpReader inherits Rectangle { + visible: AccountMailPreferences.reader-open; + background: AppTheme.surface; + VerticalBox { + padding: 24px; spacing: 12px; + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("OpenPGP message"); color: AppTheme.heading; font-size: TypeScale.subtitle; font-weight: 700; } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: AccountMailPreferences.reader-status; color: AppTheme.secondary; wrap: word-wrap; } + TextEdit { wrap: word-wrap; vertical-stretch: 1; read-only: true; text: AccountMailPreferences.reader-text; } + Text { min-height: self.preferred-height; vertical-stretch: 0; text: @tr("Decrypted content is shown as text, without remote content. It is not added to search or saved to the mail database."); color: AppTheme.secondary; wrap: word-wrap; } + Button { text: @tr("Export decrypted message"); enabled: AccountMailPreferences.reader-text != ""; clicked => { AccountMailPreferences.export-message(); } } + Button { text: @tr("Close"); clicked => { AccountMailPreferences.close-reader(); AccountMailPreferences.reader-open = false; AccountMailPreferences.reader-text = ""; AccountMailPreferences.reader-status = ""; } } + } +} diff --git a/ui/components/button.slint b/ui/components/button.slint index 69e4cda..db005ec 100644 --- a/ui/components/button.slint +++ b/ui/components/button.slint @@ -18,8 +18,9 @@ export component Button inherits Rectangle { height: 32px; min-width: 64px; border-radius: LayoutMetrics.corner-radius - 2px; - border-width: root.primary || root.destructive ? 0px : 1px; - border-color: root.selected ? AppTheme.accent : AppTheme.control-border; + forward-focus: keyboard; + border-width: keyboard.has-focus ? 2px : root.primary || root.destructive ? 0px : 1px; + border-color: keyboard.has-focus || root.selected ? AppTheme.accent : AppTheme.control-border; background: !root.enabled ? AppTheme.disabled-bg : root.destructive ? (button-area.pressed ? AppTheme.destructive-pressed : button-area.has-hover ? AppTheme.destructive-hover : AppTheme.destructive) : root.primary ? (button-area.pressed ? AppTheme.primary-pressed : button-area.has-hover ? AppTheme.primary-hover : AppTheme.primary) @@ -62,9 +63,17 @@ export component Button inherits Rectangle { } } + keyboard := FocusScope { + enabled: root.enabled; + accessible-role: none; + key-pressed(event) => { + if event.text == Key.Return || event.text == Key.Space { root.clicked(); return accept; } + reject + } + } button-area := TouchArea { enabled: root.enabled; - clicked => { root.clicked(); } + clicked => { keyboard.focus(); root.clicked(); } mouse-cursor: pointer; } } diff --git a/ui/components/calendar.slint b/ui/components/calendar.slint index 2639449..7416eaf 100644 --- a/ui/components/calendar.slint +++ b/ui/components/calendar.slint @@ -521,7 +521,7 @@ export component FunctionalCalendarView inherits Rectangle { cross-axis-alignment: center; ToolbarIconButton { - icon: @image-url("../icons/phosphor/sidebar-simple-fill.svg"); + icon: @image-url("../icons/phosphor/sidebar-fill.svg"); icon-color: root.secondary-color; hover-background: root.control-hover-color; active-background: root.accent-background; diff --git a/ui/components/document-preview.slint b/ui/components/document-preview.slint new file mode 100644 index 0000000..b25d124 --- /dev/null +++ b/ui/components/document-preview.slint @@ -0,0 +1,60 @@ +import { HorizontalBox, VerticalBox, ScrollView } from "std-widgets.slint"; +import { ZoomControls, ZoomApi } from "zoom-controls.slint"; +import { ToolbarIconButton } from "controls.slint"; +import { AppTheme } from "../design-system.slint"; +import { TypeScale } from "../typography.slint"; + +export component DocumentPreview inherits Rectangle { + in property compact: false; + in property busy: false; + in property source; + in property page: 0; + in property pages: 0; + // Raster resolution can change while its logical page size stays fixed. + in property raster-zoom: 1; + in property document-revision: 0; + callback render-page(int, int); + private property fitting: true; + private property manual-zoom: 1; + private property revision: 0; + private property natural-width: root.source.width / max(0.01, root.raster-zoom); + private property natural-height: root.source.height / max(0.01, root.raster-zoom); + private property zoom: root.fitting ? clamp(canvas.visible-width / 1px / max(1, root.natural-width), 0.1, 3) : root.manual-zoom; + changed document-revision => { root.fitting = true; root.manual-zoom = 1; canvas.viewport-x = 0px; canvas.viewport-y = 0px; } + changed page => { canvas.viewport-x = 0px; canvas.viewport-y = 0px; } + min-width: 0px; min-height: 0px; max-width: 100000px; + horizontal-stretch: 1; vertical-stretch: 1; + function command(action: string) { + if action == "fit" { root.fitting = true; } + else { + private-zoom = ZoomApi.resolve(action, root.zoom, 0.1, 3); + if private-zoom > 0 { root.manual-zoom = clamp(private-zoom, 0.1, 3); root.fitting = false; } + } + root.revision += 1; + canvas.viewport-x = 0px; canvas.viewport-y = 0px; + } + private property private-zoom: 1; + VerticalBox { + width: parent.width; height: parent.height; + padding: 0px; spacing: 8px; cross-axis-alignment: stretch; + ZoomControls { compact: root.compact; zoom: root.zoom; minimum: 0.1; fitting: root.fitting; revision: root.revision; available: root.source.width > 0; command(action) => { root.command(action); } } + if root.pages > 0 : HorizontalBox { + height: root.compact ? 44px : 40px; padding: 0px; + ToolbarIconButton { compact: root.compact; icon-color: AppTheme.secondary; icon: @image-url("../icons/phosphor/caret-left.svg"); tooltip-text: @tr("Previous page"); interactive: !root.busy && root.page > 0; clicked => { root.render-page(root.page - 1, 200); } } + Text { text: @tr("Page {} of {}", root.page + 1, root.pages); color: AppTheme.secondary; font-size: TypeScale.caption; vertical-alignment: center; horizontal-alignment: center; horizontal-stretch: 1; } + ToolbarIconButton { compact: root.compact; icon-color: AppTheme.secondary; icon: @image-url("../icons/phosphor/caret-right.svg"); tooltip-text: @tr("Next page"); interactive: !root.busy && root.page + 1 < root.pages; clicked => { root.render-page(root.page + 1, 200); } } + } + canvas := ScrollView { + vertical-stretch: 1; + mouse-drag-pan-enabled: true; + viewport-width: max(self.visible-width, root.natural-width * root.zoom * 1px); + viewport-height: max(self.visible-height, root.natural-height * root.zoom * 1px); + Image { + source: root.source; image-fit: contain; + x: max(0px, (canvas.visible-width - self.width) / 2); + width: root.natural-width * root.zoom * 1px; + height: root.natural-height * root.zoom * 1px; + } + } + } +} diff --git a/ui/components/email-composer.slint b/ui/components/email-composer.slint index 4d9f9ec..d4ed50e 100644 --- a/ui/components/email-composer.slint +++ b/ui/components/email-composer.slint @@ -1,3 +1,5 @@ +import { AccountMailPreferences } from "account-mail-preferences.slint"; +import { SettingsSelectRow } from "settings-components.slint"; import { ScrollView, VerticalBox } from "std-widgets.slint"; import { AccountRow, ComposeAttachment, ComposeContact, ComposeEditorTile } from "../models.slint"; import { ComposerFromRow, ComposerHeader, ComposerRecipients, ComposerSubject } from "composer-fields.slint"; @@ -84,7 +86,7 @@ export component EmailComposer inherits Rectangle { private property input-caret-y: 0px; private property has-content: root.to != "" || root.cc != "" || root.bcc != "" || root.subject != "" || root.body != "" || root.attachments.length > 0; - private property can-send: root.selected-account-id > 0 && root.to != "" && root.body != ""; + private property can-send: root.selected-account-id > 0 && root.to != "" && root.body != "" && AccountMailPreferences.composer-preferences-ready; function apply-format(kind: string) { if (kind == "link") { @@ -214,9 +216,19 @@ export component EmailComposer inherits Rectangle { title-color: root.title-color; changed(account-id) => { root.search-contacts(root.to, account-id); + AccountMailPreferences.composer-account-changed(account-id); } } + SettingsSelectRow { + compact: root.compact; + title: @tr("Signature"); + description: AccountMailPreferences.composer-security; + choices: AccountMailPreferences.composer-signature-names; + selected-index: AccountMailPreferences.composer-signature-index; + title-color: root.title-color; description-color: root.secondary-color; + selected(index, value) => { if AccountMailPreferences.composer-preferences-ready { AccountMailPreferences.composer-signature(index); } } + } recipients := ComposerRecipients { open: root.open; to <=> root.to; diff --git a/ui/components/infinite-scroll-controller.slint b/ui/components/infinite-scroll-controller.slint new file mode 100644 index 0000000..f82357f --- /dev/null +++ b/ui/components/infinite-scroll-controller.slint @@ -0,0 +1,61 @@ +// Shared by Mail, Contacts and Files. Wait for the next committed revision and +// settled ListView geometry before requesting another page. Errors retry only +// after a fresh user gesture, preventing an automatic request loop. +export component InfiniteScrollController inherits Rectangle { + width: 0px; height: 0px; + in property active: true; + in property can-load-more; + in property loading; + in property row-count; + in property revision; + in property content-height; + in property viewport-y; + in property visible-height; + in property prefetch-distance: 246px; + callback load-more; + private property armed: true; + private property request-rows: -1; + private property request-revision: -1; + private property request-height: -1px; + function check() { + if root.active && root.visible-height > 0px && root.can-load-more + && !root.loading && root.armed + && root.content-height + root.viewport-y - root.visible-height <= root.prefetch-distance { + root.armed = false; + root.request-rows = root.row-count; + root.request-revision = root.revision; + root.request-height = root.content-height; + root.load-more(); + } + } + public function reset() { + root.armed = true; + root.request-rows = -1; + root.request-revision = -1; + root.request-height = -1px; + check-timer.restart(); + } + public function user-scrolled() { + if !root.loading && !root.armed && root.revision == root.request-revision { + root.armed = true; + } + root.check(); + } + changed content-height => { + if !root.armed && root.revision != root.request-revision && root.content-height != root.request-height { root.armed = true; } + check-timer.restart(); + } + changed revision => { + if !root.armed && root.revision != root.request-revision + && (root.row-count == root.request-rows || root.content-height != root.request-height) { root.armed = true; } + check-timer.restart(); + } + changed can-load-more => { check-timer.restart(); } + changed loading => { if !root.loading { check-timer.restart(); } } + changed active => { check-timer.restart(); } + changed visible-height => { check-timer.restart(); } + check-timer := Timer { + interval: 1ms; running: true; + triggered => { self.stop(); root.check(); } + } +} diff --git a/ui/components/mail-security-fields.slint b/ui/components/mail-security-fields.slint new file mode 100644 index 0000000..11d7726 --- /dev/null +++ b/ui/components/mail-security-fields.slint @@ -0,0 +1,77 @@ +import { VerticalBox, HorizontalBox, ComboBox, TextEdit } from "std-widgets.slint"; +import { Button } from "button.slint"; +import { AppTheme } from "../design-system.slint"; +import { TypeScale } from "../typography.slint"; + +// The same account-scoped transport settings are used during setup and edits. +export component MailSecurityFields inherits VerticalBox { + in-out property imap-security: "auto"; + in-out property smtp-security: "auto"; + in-out property certificate-pem; + in property enabled: true; + callback import-certificate(); + private property certificate-expanded: false; + spacing: 8px; + padding: 0px; + vertical-stretch: 0; + Text { text: @tr("Connection security"); color: AppTheme.heading; font-size: TypeScale.secondary; font-weight: 600; } + HorizontalBox { + height: 58px; + min-width: 0px; + padding: 0px; + spacing: 8px; + VerticalBox { + min-width: 0px; + padding: 0px; + spacing: 4px; + Text { text: @tr("IMAP encryption"); color: AppTheme.secondary; font-size: TypeScale.caption; } + ComboBox { + height: 36px; + min-width: 0px; + enabled: root.enabled; + model: [@tr("Automatic"), "SSL/TLS", "STARTTLS"]; + current-index: root.imap-security == "tls" ? 1 : root.imap-security == "starttls" ? 2 : 0; + selected(value) => { root.imap-security = self.current-index == 1 ? "tls" : self.current-index == 2 ? "starttls" : "auto"; } + } + } + VerticalBox { + min-width: 0px; + padding: 0px; + spacing: 4px; + Text { text: @tr("SMTP encryption"); color: AppTheme.secondary; font-size: TypeScale.caption; } + ComboBox { + height: 36px; + min-width: 0px; + enabled: root.enabled; + model: [@tr("Automatic"), "SSL/TLS", "STARTTLS"]; + current-index: root.smtp-security == "tls" ? 1 : root.smtp-security == "starttls" ? 2 : 0; + selected(value) => { root.smtp-security = self.current-index == 1 ? "tls" : self.current-index == 2 ? "starttls" : "auto"; } + } + } + } + Text { + text: @tr("Automatic: IMAP uses STARTTLS on 143, SSL/TLS otherwise. SMTP uses SSL/TLS on 465, STARTTLS otherwise."); + color: AppTheme.secondary; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; + } + Button { + height: 36px; + text: root.certificate-pem != "" ? @tr("Manage trusted certificate") : @tr("Custom server certificate…"); + enabled: root.enabled; + clicked => { root.certificate-expanded = !root.certificate-expanded; } + } + if root.certificate-expanded : VerticalBox { + padding: 0px; + spacing: 8px; + Text { + text: @tr("For Proton Bridge, export its certificate from Bridge settings and import it here. Only trust certificates from your server administrator. Server names and expiry dates are still checked."); + color: AppTheme.secondary; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; + } + Button { height: 36px; text: @tr("Import certificate file"); enabled: root.enabled; clicked => { root.import-certificate(); } } + TextEdit { height: 90px; enabled: root.enabled; text <=> root.certificate-pem; wrap: word-wrap; } + Text { text: @tr("PEM certificate only. Do not paste a private key."); color: AppTheme.secondary; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; } + if root.certificate-pem != "" : Button { height: 36px; text: @tr("Remove certificate"); enabled: root.enabled; clicked => { root.certificate-pem = ""; } } + } + if root.certificate-pem != "" && !root.certificate-expanded : Text { + text: @tr("Custom certificate added for this account."); color: AppTheme.secondary; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; + } +} diff --git a/ui/components/oauth-browser-help.slint b/ui/components/oauth-browser-help.slint new file mode 100644 index 0000000..7c83c14 --- /dev/null +++ b/ui/components/oauth-browser-help.slint @@ -0,0 +1,110 @@ +import { VerticalBox, HorizontalBox } from "std-widgets.slint"; +import { Button } from "button.slint"; +import { LineEdit } from "controls.slint"; +import { AppTheme } from "../design-system.slint"; +import { TypeScale } from "../typography.slint"; + +export component OAuthBrowserHelp inherits VerticalBox { + in property launch-failed: false; + callback retry(); + callback copy-link(); + callback submit-redirect(string); + in-out property expanded: false; + private property redirect-address; + padding: 0px; + spacing: 8px; + + help-header := Rectangle { + height: 36px; + border-radius: 6px; + border-width: header-focus.has-focus ? 2px : 1px; + border-color: header-focus.has-focus ? AppTheme.accent : AppTheme.control-border; + background: header-hit.pressed ? AppTheme.muted-control-bg + : header-hit.has-hover ? AppTheme.control-bg.brighter(AppTheme.dark ? 8% : 2%) + : AppTheme.surface; + + Image { + x: 11px; + y: (parent.height - self.height) / 2; + width: 14px; + height: 14px; + source: root.expanded + ? @image-url("../icons/phosphor/caret-down.svg") + : @image-url("../icons/phosphor/caret-right.svg"); + colorize: AppTheme.secondary; + image-fit: contain; + accessible-role: none; + } + Text { + x: 34px; + width: parent.width - self.x - 12px; + height: parent.height; + text: root.launch-failed ? @tr("Browser did not open — show sign-in help") : @tr("Having trouble with browser sign-in?"); + color: root.launch-failed ? AppTheme.heading : AppTheme.secondary; + font-size: TypeScale.secondary; + font-weight: root.launch-failed ? 600 : 500; + vertical-alignment: center; + overflow: elide; + } + header-focus := FocusScope { + accessible-role: button; + accessible-label: root.launch-failed ? @tr("Browser did not open — show sign-in help") : @tr("Having trouble with browser sign-in?"); + accessible-expanded: root.expanded; + accessible-action-default => { root.expanded = !root.expanded; } + key-pressed(event) => { + if event.text == Key.Return || event.text == Key.Space { + root.expanded = !root.expanded; + return accept; + } + reject + } + } + header-hit := TouchArea { + clicked => { header-focus.focus(); root.expanded = !root.expanded; } + mouse-cursor: pointer; + } + } + + if root.expanded : VerticalBox { + padding: 0px; + spacing: 8px; + Text { + text: root.launch-failed ? @tr("Flectar could not reach the system browser from this app environment. Open the link again or copy it into a browser on this device.") : @tr("Open the sign-in page again or copy its link into a browser on this device."); + color: AppTheme.secondary; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; + } + HorizontalBox { + height: 32px; + padding: 0px; + spacing: 8px; + Button { text: @tr("Open browser"); clicked => { root.retry(); } } + Button { text: @tr("Copy sign-in link"); clicked => { root.copy-link(); } } + } + Text { + text: @tr("If the final browser page says localhost can’t be reached, copy its full address and paste it below."); + color: AppTheme.secondary; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; + } + LineEdit { + height: 36px; + input-label: @tr("Final browser address"); + placeholder-text: @tr("Final localhost address"); + text <=> root.redirect-address; + accepted(value) => { + if value != "" { + root.redirect-address = ""; + root.submit-redirect(value); + } + } + } + Button { + height: 32px; + text: @tr("Finish sign-in"); + primary: root.redirect-address != ""; + enabled: root.redirect-address != ""; + clicked => { + let address = root.redirect-address; + root.redirect-address = ""; + root.submit-redirect(address); + } + } + } +} diff --git a/ui/components/product-rail.slint b/ui/components/product-rail.slint index 30b154e..578d616 100644 --- a/ui/components/product-rail.slint +++ b/ui/components/product-rail.slint @@ -61,11 +61,13 @@ export component ProductRail inherits Rectangle { in property muted-color; out property tooltip-text: mail-item.hovered ? mail-item.label : calendar-item.hovered ? calendar-item.label + : files-item.hovered ? files-item.label : contacts-item.hovered ? contacts-item.label : settings-item.hovered ? settings-item.label : ""; out property tooltip-y: mail-item.hovered ? mail-item.y + 10px : calendar-item.hovered ? calendar-item.y + 10px + : files-item.hovered ? files-item.y + 10px : contacts-item.hovered ? contacts-item.y + 10px : settings-item.hovered ? settings-item.y + 10px : 0px; @@ -116,6 +118,17 @@ export component ProductRail inherits Rectangle { clicked => { root.select-view("calendar"); } } + files-item := ProductRailItem { + label: @tr("Files"); + outline-icon: @image-url("../icons/phosphor/files.svg"); + filled-icon: @image-url("../icons/phosphor/files-fill.svg"); + active: !root.settings-active && root.active-view == "files"; + accent-color: root.accent-color; + accent-background: root.accent-background; + muted-color: root.muted-color; + clicked => { root.select-view("files"); } + } + contacts-item := ProductRailItem { label: @tr("Contacts"); outline-icon: @image-url("../icons/phosphor/address-book.svg"); @@ -127,6 +140,7 @@ export component ProductRail inherits Rectangle { clicked => { root.select-view("contacts"); } } + Rectangle { vertical-stretch: 1; } settings-item := ProductRailItem { @@ -262,6 +276,17 @@ export component CompactProductNav inherits Rectangle { muted-color: root.muted-color; clicked => { root.select-view("calendar"); } } + CompactProductNavItem { + horizontal-stretch: 1; + label: @tr("Files"); + outline-icon: @image-url("../icons/phosphor/files.svg"); + filled-icon: @image-url("../icons/phosphor/files-fill.svg"); + active: !root.settings-active && root.active-view == "files"; + accent-color: root.accent-color; + active-background: root.accent-background; + muted-color: root.muted-color; + clicked => { root.select-view("files"); } + } CompactProductNavItem { horizontal-stretch: 1; label: @tr("Contacts"); diff --git a/ui/components/provider-button.slint b/ui/components/provider-button.slint index b081324..405703f 100644 --- a/ui/components/provider-button.slint +++ b/ui/components/provider-button.slint @@ -16,7 +16,7 @@ export component ProviderButton inherits Rectangle { in property button-border; callback clicked; - height: 52px; + height: 48px; border-radius: 8px; border-width: 1px; border-color: root.button-border; diff --git a/ui/components/settings-components.slint b/ui/components/settings-components.slint index 50e606c..86870b1 100644 --- a/ui/components/settings-components.slint +++ b/ui/components/settings-components.slint @@ -308,6 +308,7 @@ export component ShortcutRow inherits Rectangle { } export component FormField inherits VerticalBox { + in property enabled: true; in property label; in-out property value; in property placeholder; @@ -315,6 +316,7 @@ export component FormField inherits VerticalBox { in property label-color; callback edited(string); + padding: 0px; spacing: 5px; Text { height: 16px; @@ -325,6 +327,7 @@ export component FormField inherits VerticalBox { vertical-alignment: center; } LineEdit { + enabled: root.enabled; height: 36px; text: root.value; placeholder-text: root.placeholder; diff --git a/ui/components/workspace-layout.slint b/ui/components/workspace-layout.slint index 7f62f0d..8b77e5a 100644 --- a/ui/components/workspace-layout.slint +++ b/ui/components/workspace-layout.slint @@ -145,6 +145,7 @@ export component WorkspaceSearchToolbar inherits Rectangle { in property refresh-tooltip; in property refresh-accessible-id; callback edited(string); + callback submitted(string); callback filter-selected(string); callback refresh; @@ -169,6 +170,7 @@ export component WorkspaceSearchToolbar inherits Rectangle { leading-icon-color: AppTheme.secondary; trailing-content-width: LayoutMetrics.control-height; edited(value) => { root.edited(value); } + accepted(value) => { root.submitted(value); } } filter-menu := ContextMenuArea { diff --git a/ui/components/zoom-controls.slint b/ui/components/zoom-controls.slint new file mode 100644 index 0000000..bab0fd6 --- /dev/null +++ b/ui/components/zoom-controls.slint @@ -0,0 +1,33 @@ +import { HorizontalBox } from "std-widgets.slint"; +import { LineEdit, ToolbarIconButton } from "controls.slint"; +import { AppTheme, LayoutMetrics } from "../design-system.slint"; + +export global ZoomApi { pure callback resolve(string, float, float, float) -> float; } + +// One command vocabulary for email bodies and document previews. +export component ZoomControls inherits HorizontalBox { + in property compact: false; + in property available: true; + in property fitting: false; + in property zoom: 1; + in property minimum: 0.5; + in property maximum: 3; + in property revision: 0; + callback command(string); + padding: 0px; spacing: 4px; horizontal-stretch: 0; cross-axis-alignment: center; + height: root.compact ? LayoutMetrics.touch-target : LayoutMetrics.control-height; + ToolbarIconButton { compact: root.compact; icon-color: AppTheme.body; icon: @image-url("../icons/phosphor/magnifying-glass-minus.svg"); tooltip-text: @tr("Zoom out"); tooltip-above: true; tooltip-align-left: true; interactive: root.available && root.zoom > root.minimum; clicked => { root.command("zoom-out"); } } + if !root.compact : ToolbarIconButton { icon-color: AppTheme.body; icon: @image-url("../icons/phosphor/arrow-counter-clockwise.svg"); tooltip-text: @tr("Reset zoom ({}%)", round(root.zoom * 100)); tooltip-above: true; interactive: root.available; clicked => { root.command("zoom-reset"); } } + editor := LineEdit { + in property applied: root.zoom; + in property revision: root.revision; + changed applied => { self.text = "\{round(root.zoom * 100)}%"; } + changed revision => { self.text = "\{round(root.zoom * 100)}%"; } + min-width: 62px; max-width: 62px; height: root.compact ? LayoutMetrics.touch-target : LayoutMetrics.control-height; + enabled: root.available; input-label: @tr("Zoom percentage"); + text: "\{round(root.zoom * 100)}%"; + accepted(value) => { root.command("zoom-set:" + value); self.text = "\{round(root.zoom * 100)}%"; } + } + ToolbarIconButton { compact: root.compact; icon-color: AppTheme.body; icon: @image-url("../icons/phosphor/magnifying-glass-plus.svg"); tooltip-text: @tr("Zoom in"); tooltip-above: true; interactive: root.available && root.zoom < root.maximum; clicked => { root.command("zoom-in"); } } + ToolbarIconButton { compact: root.compact; icon-color: AppTheme.body; icon: @image-url("../icons/phosphor/arrows-out-line-horizontal.svg"); tooltip-text: @tr("Fit to width"); tooltip-above: true; active: root.fitting; interactive: root.available; clicked => { root.command("fit"); } } +} diff --git a/ui/dialogs/attachment-preview-dialog.slint b/ui/dialogs/attachment-preview-dialog.slint new file mode 100644 index 0000000..ca51e59 --- /dev/null +++ b/ui/dialogs/attachment-preview-dialog.slint @@ -0,0 +1,47 @@ +import { HorizontalBox, VerticalBox, ScrollView } from "std-widgets.slint"; +import { MailAttachments } from "../models.slint"; +import { DocumentPreview } from "../components/document-preview.slint"; +import { Button } from "../components/button.slint"; +import { ToolbarIconButton } from "../components/controls.slint"; +import { AppTheme } from "../design-system.slint"; +import { TypeScale } from "../typography.slint"; + +export component AttachmentPreviewDialog inherits Rectangle { + in property compact: false; + visible: MailAttachments.open; + background: #00000088; z: 2000; + accessible-role: groupbox; accessible-label: MailAttachments.name; + changed visible => { if root.visible { keys.focus(); } } + TouchArea {} + panel := Rectangle { + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; + width: root.compact ? parent.width : min(1000px, parent.width - 48px); + height: root.compact ? parent.height : parent.height - 48px; + background: AppTheme.surface; border-radius: root.compact ? 0px : 12px; + keys := FocusScope { + key-pressed(event) => { + if event.text == Key.Escape { MailAttachments.command("close", ""); return accept; } + return reject; + } + VerticalBox { + padding: root.compact ? 12px : 20px; spacing: 12px; + HorizontalBox { + Text { text: MailAttachments.name; color: AppTheme.heading; font-size: TypeScale.title; overflow: elide; horizontal-stretch: 1; } + Button { text: @tr("Download"); enabled: !MailAttachments.busy; clicked => { MailAttachments.command("download-current", ""); } } + ToolbarIconButton { compact: root.compact; icon: @image-url("../icons/phosphor/x.svg"); icon-color: AppTheme.secondary; tooltip-text: @tr("Close"); clicked => { MailAttachments.command("close", ""); } } + } + if MailAttachments.is-image : DocumentPreview { + compact: root.compact; source: MailAttachments.image; busy: MailAttachments.busy; + page: MailAttachments.page; pages: MailAttachments.pages; + raster-zoom: MailAttachments.pages > 0 ? 2 : 1; document-revision: MailAttachments.revision; + render-page(page, zoom) => { MailAttachments.command("page", "" + page); } + } + if !MailAttachments.is-image : ScrollView { + viewport-width: self.visible-width; + Text { width: parent.visible-width; text: MailAttachments.text; wrap: word-wrap; color: AppTheme.body; } + } + Text { text: MailAttachments.busy ? @tr("Loading preview…") : MailAttachments.status; wrap: word-wrap; color: AppTheme.secondary; } + } + } + } +} diff --git a/ui/dialogs/settings-dialog.slint b/ui/dialogs/settings-dialog.slint index bcdbb23..f903c5c 100644 --- a/ui/dialogs/settings-dialog.slint +++ b/ui/dialogs/settings-dialog.slint @@ -1,3 +1,6 @@ +import { AccountMailPreferencesPanel } from "../components/account-mail-preferences.slint"; +import { MailSecurityFields } from "../components/mail-security-fields.slint"; +import { OAuthBrowserHelp } from "../components/oauth-browser-help.slint"; import { HorizontalBox, ScrollView, VerticalBox } from "std-widgets.slint"; import { AccountRow } from "../models.slint"; import { Avatar } from "../components/avatar.slint"; @@ -22,6 +25,7 @@ export component SettingsDialog inherits Rectangle { in property remote-images-available: true; in property account-transfer-supported: true; in property sync_in_progress: false; + in property account_setup_in_progress: false; in property oauth_in_progress: false; in property oauth_settings_saving: false; in property sync_status: ""; @@ -62,6 +66,15 @@ export component SettingsDialog inherits Rectangle { in-out property caldav_url: ""; in-out property caldav_username: ""; in-out property caldav_password: ""; + in-out property imap_security: "auto"; + in-out property smtp_security: "auto"; + in-out property trusted_certificate_pem; + in property oauth_authorization_url; + in property oauth_browser_error; + callback retry_oauth_browser(); + callback copy_oauth_link(); + callback submit_oauth_redirect(string); + callback import_mail_certificate(); in-out property imap_host: ""; in-out property imap_port: "993"; in-out property smtp_host: ""; @@ -180,6 +193,9 @@ export component SettingsDialog inherits Rectangle { root.account_password = ""; root.account_protocol = "imap"; root.jmap_url = ""; + root.imap_security = "auto"; + root.smtp_security = "auto"; + root.trusted_certificate_pem = ""; root.imap_host = ""; root.imap_port = "993"; root.smtp_host = ""; @@ -542,7 +558,6 @@ export component SettingsDialog inherits Rectangle { Text { text: @tr("Accounts"); color: root.title-color; font-size: TypeScale.display; font-weight: 700; } Text { height: root.compact ? 34px : 17px; text: @tr("Manage connected mailboxes and add providers. Drag an account to change its order everywhere."); color: root.secondary-text; font-size: TypeScale.secondary; wrap: word-wrap; } if root.sync_status != "" : Text { - height: 34px; text: root.sync_status; color: root.secondary-text; font-size: TypeScale.secondary; @@ -562,7 +577,7 @@ export component SettingsDialog inherits Rectangle { horizontal-stretch: 1; text: @tr("Add Gmail"); icon: @image-url("../icons/brand/gmail.svg"); - enabled: !root.oauth_in_progress && !root.oauth_settings_saving && root.google_oauth_available; + enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving && root.google_oauth_available; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; @@ -574,7 +589,7 @@ export component SettingsDialog inherits Rectangle { horizontal-stretch: 1; text: @tr("Add Outlook"); icon: @image-url("../icons/brand/outlook.svg"); - enabled: !root.oauth_in_progress && !root.oauth_settings_saving && root.microsoft_oauth_available; + enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving && root.microsoft_oauth_available; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; @@ -587,7 +602,7 @@ export component SettingsDialog inherits Rectangle { text: @tr("Add mail account"); icon: @image-url("../icons/phosphor/envelope.svg"); colorize-icon: true; - enabled: !root.oauth_in_progress && !root.oauth_settings_saving; + enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; @@ -604,12 +619,18 @@ export component SettingsDialog inherits Rectangle { if root.compact : VerticalBox { padding: 14px; spacing: 8px; - ProviderButton { text: @tr("Add Gmail"); icon: @image-url("../icons/brand/gmail.svg"); enabled: !root.oauth_in_progress && !root.oauth_settings_saving && root.google_oauth_available; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; hover-surface: root.accent-soft; button-border: root.border-subtle; clicked => { root.start_oauth("gmail"); } } - ProviderButton { text: @tr("Add Outlook"); icon: @image-url("../icons/brand/outlook.svg"); enabled: !root.oauth_in_progress && !root.oauth_settings_saving && root.microsoft_oauth_available; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; hover-surface: root.accent-soft; button-border: root.border-subtle; clicked => { root.start_oauth("microsoft"); } } - ProviderButton { text: @tr("Add mail account"); icon: @image-url("../icons/phosphor/envelope.svg"); colorize-icon: true; enabled: !root.oauth_in_progress && !root.oauth_settings_saving; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; hover-surface: root.accent-soft; button-border: root.border-subtle; clicked => { root.open_new_imap(); } } + ProviderButton { text: @tr("Add Gmail"); icon: @image-url("../icons/brand/gmail.svg"); enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving && root.google_oauth_available; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; hover-surface: root.accent-soft; button-border: root.border-subtle; clicked => { root.start_oauth("gmail"); } } + ProviderButton { text: @tr("Add Outlook"); icon: @image-url("../icons/brand/outlook.svg"); enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving && root.microsoft_oauth_available; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; hover-surface: root.accent-soft; button-border: root.border-subtle; clicked => { root.start_oauth("microsoft"); } } + ProviderButton { text: @tr("Add mail account"); icon: @image-url("../icons/phosphor/envelope.svg"); colorize-icon: true; enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; foreground: root.body-text; muted: root.muted-text; surface: root.surface-raised; hover-surface: root.accent-soft; button-border: root.border-subtle; clicked => { root.open_new_imap(); } } if root.oauth_in_progress : Button { height: 44px; text: @tr("Cancel sign-in"); clicked => { root.cancel_oauth(); } } } } + if root.oauth_in_progress && root.oauth_authorization_url != "" : OAuthBrowserHelp { + launch-failed: root.oauth_browser_error; + retry => { root.retry_oauth_browser(); } + copy-link => { root.copy_oauth_link(); } + submit-redirect(address) => { root.submit_oauth_redirect(address); } + } for account in root.connected_accounts : drop-target := DropArea { height: root.compact ? 221px : account.sync_error != "" ? 183px : 159px; can-drop(event) => root.read_account_transfer(event.data) != "" ? DragAction.move : DragAction.none; @@ -684,13 +705,16 @@ export component SettingsDialog inherits Rectangle { width: account.provider == "imap" ? 58px : 82px; height: 30px; text: account.provider == "imap" ? @tr("Edit") : @tr("Reconnect"); - enabled: (account.provider == "imap" || !root.oauth_in_progress) && !root.oauth_settings_saving; + enabled: !root.account_setup_in_progress && (account.provider == "imap" || !root.oauth_in_progress) && !root.oauth_settings_saving; clicked => { if account.provider == "imap" { root.account_email = account.email; root.account_username = account.username; root.account_protocol = account.mail_protocol; root.jmap_url = account.jmap_url; + root.imap_security = account.imap_security; + root.smtp_security = account.smtp_security; + root.trusted_certificate_pem = account.trusted_certificate_pem; root.imap_host = account.imap_host; root.imap_port = account.imap_port; root.smtp_host = account.smtp_host; @@ -714,7 +738,7 @@ export component SettingsDialog inherits Rectangle { width: 128px; height: 30px; text: account.calendar_status == "" ? @tr("Add calendar") : @tr("Retry calendar"); - enabled: !root.oauth_in_progress && !root.oauth_settings_saving; + enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; clicked => { root.connect_account_calendar(account.id); } } if !root.compact && !account.calendar_connected && account.provider == "imap" : Button { @@ -751,15 +775,16 @@ export component SettingsDialog inherits Rectangle { } } } + AccountMailPreferencesPanel { accounts: root.connected_accounts; compact: root.compact; } Rectangle { height: root.custom_oauth_open ? (root.compact ? 354px : 276px) : 118px; SettingsCard { title: @tr("OAuth app credentials"); description: root.custom_oauth_configured ? @tr("Custom desktop app credentials are configured.") : @tr("Advanced: use your own Gmail or Microsoft desktop app registration."); surface: root.surface; card-border-color: root.border-subtle; heading: root.heading; secondary-text: root.faint-text; } Button { x: parent.width - self.width - 20px; y: 70px; width: 110px; height: 32px; enabled: !root.oauth_settings_saving; text: root.custom_oauth_open ? @tr("Hide") : @tr("Configure"); clicked => { root.custom_oauth_open = !root.custom_oauth_open; } } if root.custom_oauth_open : VerticalBox { x: 20px; y: 116px; width: parent.width - 40px; height: parent.height - 132px; spacing: 8px; - if !root.compact : HorizontalBox { spacing: 8px; LineEdit { enabled: !root.oauth_in_progress && !root.oauth_settings_saving; text: root.google_client_id; placeholder-text: @tr("Gmail desktop client ID"); edited(text) => { root.google_client_id = text; } } LineEdit { enabled: !root.oauth_in_progress && !root.oauth_settings_saving; text: root.google_client_secret; input-type: password; placeholder-text: @tr("Gmail client secret (optional)"); edited(text) => { root.google_client_secret = text; } } Button { enabled: !root.oauth_in_progress && !root.oauth_settings_saving; text: @tr("Save Gmail"); clicked => { root.save_oauth_app("gmail", root.google_client_id, root.google_client_secret); } } } - if root.compact : VerticalBox { spacing: 8px; LineEdit { enabled: !root.oauth_in_progress && !root.oauth_settings_saving; text: root.google_client_id; placeholder-text: @tr("Gmail desktop client ID"); edited(text) => { root.google_client_id = text; } } LineEdit { enabled: !root.oauth_in_progress && !root.oauth_settings_saving; text: root.google_client_secret; input-type: password; placeholder-text: @tr("Gmail client secret (optional)"); edited(text) => { root.google_client_secret = text; } } Button { enabled: !root.oauth_in_progress && !root.oauth_settings_saving; text: @tr("Save Gmail settings"); clicked => { root.save_oauth_app("gmail", root.google_client_id, root.google_client_secret); } } } - HorizontalBox { spacing: 8px; LineEdit { enabled: !root.oauth_in_progress && !root.oauth_settings_saving; text: root.ms_client_id; placeholder-text: @tr("Microsoft Entra desktop client ID"); edited(text) => { root.ms_client_id = text; } } Button { enabled: !root.oauth_in_progress && !root.oauth_settings_saving; text: @tr("Save Outlook"); clicked => { root.save_oauth_app("microsoft", root.ms_client_id, ""); } } } + if !root.compact : HorizontalBox { spacing: 8px; LineEdit { enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; text: root.google_client_id; placeholder-text: @tr("Gmail desktop client ID"); edited(text) => { root.google_client_id = text; } } LineEdit { enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; text: root.google_client_secret; input-type: password; placeholder-text: @tr("Gmail client secret (optional)"); edited(text) => { root.google_client_secret = text; } } Button { enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; text: @tr("Save Gmail"); clicked => { root.save_oauth_app("gmail", root.google_client_id, root.google_client_secret); } } } + if root.compact : VerticalBox { spacing: 8px; LineEdit { enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; text: root.google_client_id; placeholder-text: @tr("Gmail desktop client ID"); edited(text) => { root.google_client_id = text; } } LineEdit { enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; text: root.google_client_secret; input-type: password; placeholder-text: @tr("Gmail client secret (optional)"); edited(text) => { root.google_client_secret = text; } } Button { enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; text: @tr("Save Gmail settings"); clicked => { root.save_oauth_app("gmail", root.google_client_id, root.google_client_secret); } } } + HorizontalBox { spacing: 8px; LineEdit { enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; text: root.ms_client_id; placeholder-text: @tr("Microsoft Entra desktop client ID"); edited(text) => { root.ms_client_id = text; } } Button { enabled: !root.account_setup_in_progress && !root.oauth_in_progress && !root.oauth_settings_saving; text: @tr("Save Outlook"); clicked => { root.save_oauth_app("microsoft", root.ms_client_id, ""); } } } Text { text: @tr("Outlook public desktop clients use PKCE and do not need a client secret."); color: root.faint-text; font-size: TypeScale.caption; wrap: word-wrap; } } } @@ -1130,6 +1155,7 @@ export component SettingsDialog inherits Rectangle { } VerticalBox { + padding: 0px; padding-top: 22px; padding-right: 22px; padding-bottom: 20px; @@ -1137,6 +1163,7 @@ export component SettingsDialog inherits Rectangle { spacing: 12px; HorizontalBox { + padding: 0px; height: 44px; spacing: 12px; Rectangle { @@ -1147,6 +1174,7 @@ export component SettingsDialog inherits Rectangle { Image { x: 11px; y: 11px; width: 22px; height: 22px; source: @image-url("../icons/phosphor/envelope.svg"); colorize: root.accent; image-fit: contain; accessible-role: none; } } VerticalBox { + padding: 0px; spacing: 2px; Text { text: root.imap_editing ? @tr("Edit {}", root.account_email) : @tr("Connect a mail account"); color: root.title-color; font-size: TypeScale.heading; font-weight: 700; overflow: elide; } Text { text: root.account_protocol == "jmap" ? @tr("Uses JMAP for mail sync and sending, with automatic discovery.") : @tr("Uses separate IMAP and SMTP servers for mail sync and sending."); color: root.faint-text; font-size: TypeScale.caption; overflow: elide; } @@ -1157,32 +1185,37 @@ export component SettingsDialog inherits Rectangle { imap-scroll := ScrollView { vertical-stretch: 1; + viewport-width: self.visible-width; mouse-drag-pan-enabled: root.compact; VerticalBox { - width: imap-scroll.visible-width; + padding: 0px; + width: max(0px, imap-scroll.visible-width - 16px); height: self.preferred-height; padding-right: 4px; spacing: 10px; if !root.compact : HorizontalBox { + padding: 0px; height: 57px; spacing: 10px; - FormField { horizontal-stretch: 1; label: @tr("Email address"); value: root.account_email; placeholder: @tr("name@example.com"); label-color: root.secondary-text; edited(value) => { root.account_email = value; } } - FormField { horizontal-stretch: 1; label: @tr("Username"); value: root.account_username; placeholder: @tr("Usually your email address"); label-color: root.secondary-text; edited(value) => { root.account_username = value; } } + FormField { enabled: !root.account_setup_in_progress; horizontal-stretch: 1; label: @tr("Email address"); value: root.account_email; placeholder: @tr("name@example.com"); label-color: root.secondary-text; edited(value) => { root.account_email = value; } } + FormField { enabled: !root.account_setup_in_progress; horizontal-stretch: 1; label: @tr("Username"); value: root.account_username; placeholder: @tr("Usually your email address"); label-color: root.secondary-text; edited(value) => { root.account_username = value; } } } if root.compact : VerticalBox { + padding: 0px; height: 124px; spacing: 10px; - FormField { height: 57px; label: @tr("Email address"); value: root.account_email; placeholder: @tr("name@example.com"); label-color: root.secondary-text; edited(value) => { root.account_email = value; } } - FormField { height: 57px; label: @tr("Username"); value: root.account_username; placeholder: @tr("Usually your email address"); label-color: root.secondary-text; edited(value) => { root.account_username = value; } } + FormField { enabled: !root.account_setup_in_progress; height: 57px; label: @tr("Email address"); value: root.account_email; placeholder: @tr("name@example.com"); label-color: root.secondary-text; edited(value) => { root.account_email = value; } } + FormField { enabled: !root.account_setup_in_progress; height: 57px; label: @tr("Username"); value: root.account_username; placeholder: @tr("Usually your email address"); label-color: root.secondary-text; edited(value) => { root.account_username = value; } } } - FormField { height: 57px; label: @tr("Password or app password"); value: root.account_password; placeholder: @tr("Stored securely in your system keyring"); input-type: password; label-color: root.secondary-text; edited(value) => { root.account_password = value; } } + FormField { enabled: !root.account_setup_in_progress; height: 57px; label: @tr("Password or app password"); value: root.account_password; placeholder: @tr("Stored securely in your system keyring"); input-type: password; label-color: root.secondary-text; edited(value) => { root.account_password = value; } } HorizontalBox { + padding: 0px; height: 40px; spacing: 8px; - Button { horizontal-stretch: 1; height: 40px; text: @tr("IMAP + SMTP"); primary: root.account_protocol == "imap"; clicked => { root.account_protocol = "imap"; } } - Button { horizontal-stretch: 1; height: 40px; text: @tr("JMAP"); primary: root.account_protocol == "jmap"; clicked => { root.account_protocol = "jmap"; } } + Button { horizontal-stretch: 1; height: 40px; text: @tr("IMAP + SMTP"); enabled: !root.account_setup_in_progress; primary: root.account_protocol == "imap"; clicked => { root.account_protocol = "imap"; } } + Button { horizontal-stretch: 1; height: 40px; text: @tr("JMAP"); enabled: !root.account_setup_in_progress; primary: root.account_protocol == "jmap"; clicked => { root.account_protocol = "jmap"; } } } - if root.account_protocol == "jmap" : FormField { + if root.account_protocol == "jmap" : FormField { enabled: !root.account_setup_in_progress; height: 57px; label: @tr("Server address · Optional"); value: root.jmap_url; @@ -1191,33 +1224,49 @@ export component SettingsDialog inherits Rectangle { edited(value) => { root.jmap_url = value; } } if root.account_protocol == "imap" : HorizontalBox { + padding: 0px; height: 57px; spacing: 10px; - FormField { horizontal-stretch: 1; label: @tr("IMAP server"); value: root.imap_host; placeholder: @tr("imap.example.com"); label-color: root.secondary-text; edited(value) => { root.imap_host = value; } } - FormField { width: 88px; label: @tr("Port"); value: root.imap_port; placeholder: @tr("993"); label-color: root.secondary-text; edited(value) => { root.imap_port = value; } } + FormField { enabled: !root.account_setup_in_progress; horizontal-stretch: 1; label: @tr("IMAP server"); value: root.imap_host; placeholder: @tr("imap.example.com"); label-color: root.secondary-text; edited(value) => { root.imap_host = value; } } + FormField { enabled: !root.account_setup_in_progress; width: 88px; label: @tr("Port"); value: root.imap_port; placeholder: @tr("993"); label-color: root.secondary-text; edited(value) => { root.imap_port = value; } } } if root.account_protocol == "imap" : HorizontalBox { + padding: 0px; height: 57px; spacing: 10px; - FormField { horizontal-stretch: 1; label: @tr("SMTP server"); value: root.smtp_host; placeholder: @tr("smtp.example.com"); label-color: root.secondary-text; edited(value) => { root.smtp_host = value; } } - FormField { width: 88px; label: @tr("Port"); value: root.smtp_port; placeholder: @tr("465"); label-color: root.secondary-text; edited(value) => { root.smtp_port = value; } } + FormField { enabled: !root.account_setup_in_progress; horizontal-stretch: 1; label: @tr("SMTP server"); value: root.smtp_host; placeholder: @tr("smtp.example.com"); label-color: root.secondary-text; edited(value) => { root.smtp_host = value; } } + FormField { enabled: !root.account_setup_in_progress; width: 88px; label: @tr("Port"); value: root.smtp_port; placeholder: @tr("465"); label-color: root.secondary-text; edited(value) => { root.smtp_port = value; } } + } + if root.account_protocol == "imap" : Text { + text: @tr("Proton Bridge: use the local servers, ports and password shown in Bridge, and match its encryption settings."); + color: root.secondary-text; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; + } + if root.account_protocol == "imap" : MailSecurityFields { + imap-security <=> root.imap_security; + smtp-security <=> root.smtp_security; + enabled: !root.account_setup_in_progress; + certificate-pem <=> root.trusted_certificate_pem; + import-certificate => { root.import_mail_certificate(); } + } + if root.sync_status != "" : Text { + text: root.sync_status; color: root.secondary-text; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; } } } HorizontalBox { + padding: 0px; height: root.compact ? 44px : 34px; spacing: 8px; Rectangle { horizontal-stretch: 1; } Button { height: parent.height; text: @tr("Cancel"); clicked => { root.imap_form_open = false; } } Button { height: parent.height; - text: root.imap_editing ? @tr("Save account") : @tr("Connect account"); + text: root.account_setup_in_progress ? @tr("Connecting…") : root.imap_editing ? @tr("Save account") : @tr("Connect account"); primary: true; - enabled: root.imap_form_valid; + enabled: root.imap_form_valid && !root.account_setup_in_progress; clicked => { root.add_password_account(root.account_protocol, root.account_email, root.account_username, root.account_password, root.jmap_url, root.imap_host, root.imap_port, root.smtp_host, root.smtp_port); - root.imap_form_open = false; } } } diff --git a/ui/icons/phosphor/download-simple.svg b/ui/icons/phosphor/download-simple.svg new file mode 100644 index 0000000..631dcde --- /dev/null +++ b/ui/icons/phosphor/download-simple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/phosphor/file.svg b/ui/icons/phosphor/file.svg new file mode 100644 index 0000000..f2e7629 --- /dev/null +++ b/ui/icons/phosphor/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/phosphor/files-fill.svg b/ui/icons/phosphor/files-fill.svg new file mode 100644 index 0000000..c20ba26 --- /dev/null +++ b/ui/icons/phosphor/files-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/phosphor/files.svg b/ui/icons/phosphor/files.svg new file mode 100644 index 0000000..be26945 --- /dev/null +++ b/ui/icons/phosphor/files.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/phosphor/folder-plus.svg b/ui/icons/phosphor/folder-plus.svg new file mode 100644 index 0000000..3ea2fea --- /dev/null +++ b/ui/icons/phosphor/folder-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/phosphor/folder-simple.svg b/ui/icons/phosphor/folder-simple.svg new file mode 100644 index 0000000..36acab2 --- /dev/null +++ b/ui/icons/phosphor/folder-simple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/phosphor/lock-key.svg b/ui/icons/phosphor/lock-key.svg new file mode 100644 index 0000000..02ce716 --- /dev/null +++ b/ui/icons/phosphor/lock-key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/phosphor/share-network.svg b/ui/icons/phosphor/share-network.svg new file mode 100644 index 0000000..dac2123 --- /dev/null +++ b/ui/icons/phosphor/share-network.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/icons/phosphor/sidebar-fill.svg b/ui/icons/phosphor/sidebar-fill.svg new file mode 100644 index 0000000..c4339b3 --- /dev/null +++ b/ui/icons/phosphor/sidebar-fill.svg @@ -0,0 +1 @@ + diff --git a/ui/icons/phosphor/sidebar-simple-fill.svg b/ui/icons/phosphor/sidebar-simple-fill.svg deleted file mode 100644 index 535d46d..0000000 --- a/ui/icons/phosphor/sidebar-simple-fill.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/icons/phosphor/sidebar-simple.svg b/ui/icons/phosphor/sidebar-simple.svg deleted file mode 100644 index b5d8ed2..0000000 --- a/ui/icons/phosphor/sidebar-simple.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/icons/phosphor/sidebar.svg b/ui/icons/phosphor/sidebar.svg new file mode 100644 index 0000000..0a89a49 --- /dev/null +++ b/ui/icons/phosphor/sidebar.svg @@ -0,0 +1 @@ + diff --git a/ui/icons/phosphor/upload-simple.svg b/ui/icons/phosphor/upload-simple.svg new file mode 100644 index 0000000..11f55ec --- /dev/null +++ b/ui/icons/phosphor/upload-simple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/models.slint b/ui/models.slint index 4a4b999..33d06e9 100644 --- a/ui/models.slint +++ b/ui/models.slint @@ -106,6 +106,9 @@ export struct AccountRow { has_avatar: bool, username: string, jmap_url: string, + imap_security: string, + smtp_security: string, + trusted_certificate_pem: string, imap_host: string, imap_port: string, smtp_host: string, @@ -272,6 +275,7 @@ export global EmailReader { pure callback link-at(float, float) -> string; callback command(string); callback find(string, int); + in-out property auto-fit: true; in-out property zoom: 1; in-out property zoom-revision: 0; in-out property width-ratio: 1; @@ -288,3 +292,20 @@ export global EmailReader { in-out property authored-text; in-out property use-authored-text: false; } + +export struct MailAttachment { id: string, name: string, detail: string, previewable: bool } +export global MailAttachments { + callback deliver(); + in-out property <[MailAttachment]> rows; + in-out property open: false; + in-out property busy: false; + in-out property name; + in-out property status; + in-out property image; + in-out property text; + in-out property is-image: false; + in-out property page: 0; + in-out property pages: 0; + in-out property revision: 0; + callback command(string, string); +} diff --git a/ui/typography.slint b/ui/typography.slint index dfa84c7..b02e7d0 100644 --- a/ui/typography.slint +++ b/ui/typography.slint @@ -13,7 +13,7 @@ export global TypeScale { out property heading-small: 1.42rem; out property heading: 1.5rem; out property heading-large: 1.58rem; - out property title-small: 1.67rem; + out property title-small: 1.66rem; out property title: 1.75rem; out property title-large: 1.83rem; out property display: 2rem; diff --git a/ui/views/contacts-view.slint b/ui/views/contacts-view.slint index e50a115..652e2a4 100644 --- a/ui/views/contacts-view.slint +++ b/ui/views/contacts-view.slint @@ -1,3 +1,4 @@ +import { InfiniteScrollController } from "../components/infinite-scroll-controller.slint"; import { HorizontalBox, ListView, ScrollView, VerticalBox } from "std-widgets.slint"; import { ContactSidebarRow, ContactSidebarRowKind, ContactRow } from "../models.slint"; import { Avatar } from "../components/avatar.slint"; @@ -90,46 +91,19 @@ export component ContactsView inherits Rectangle { callback delete-contact(int); callback compose-to-contact(string); - private property pagination-prefetch-distance: 246px; - private property pagination-armed: true; - private property pagination-request-row-count: -1; - private property pagination-request-revision: -1; - private property pagination-request-height: -1px; - private property pagination-row-count: root.contacts.length; - private property pagination-content-height: contact-list.viewport-height; - - function maybe-load-more() { - if (root.can-load-more - && !root.loading-more - && root.pagination-armed - && contact-list.viewport-height + contact-list.viewport-y - - contact-list.visible-height <= root.pagination-prefetch-distance) { - root.pagination-armed = false; - root.pagination-request-row-count = root.pagination-row-count; - root.pagination-request-revision = root.content-revision; - root.pagination-request-height = root.pagination-content-height; - root.load-more(); - } + pagination := InfiniteScrollController { + active: root.active; + can-load-more: root.can-load-more; loading: root.loading-more; + row-count: root.contacts.length; revision: root.content-revision; + content-height: contact-list.viewport-height; + viewport-y: contact-list.viewport-y; visible-height: contact-list.visible-height; + prefetch-distance: 246px; + load-more => { root.load-more(); } } - function reset-pagination() { - root.pagination-armed = true; - root.pagination-request-row-count = -1; - root.pagination-request-revision = -1; - root.pagination-request-height = -1px; contact-list.viewport-y = 0px; - pagination-check.restart(); - } - - function user-scrolled() { - if (!root.loading-more - && !root.pagination-armed - && root.content-revision == root.pagination-request-revision) { - root.pagination-armed = true; - } - root.maybe-load-more(); + pagination.reset(); } - changed selected-id => { root.delete-confirming = false; } changed query => { root.reset-pagination(); @@ -140,29 +114,6 @@ export component ContactsView inherits Rectangle { changed selected-scope => { root.reset-pagination(); } - changed pagination-content-height => { - if (!root.pagination-armed - && root.content-revision != root.pagination-request-revision - && root.pagination-content-height != root.pagination-request-height) { - root.pagination-armed = true; - } - pagination-check.restart(); - } - changed content-revision => { - if (!root.pagination-armed - && root.content-revision != root.pagination-request-revision - && (root.pagination-row-count == root.pagination-request-row-count - || root.pagination-content-height != root.pagination-request-height)) { - root.pagination-armed = true; - } - pagination-check.restart(); - } - changed can-load-more => { pagination-check.restart(); } - changed loading-more => { - if (!root.loading-more) { - pagination-check.restart(); - } - } changed layout-mode => { if (root.layout-mode == "minimal") { root.detail-page-visible = false; @@ -174,15 +125,6 @@ export component ContactsView inherits Rectangle { root.select-filter(scope); } - pagination-check := Timer { - interval: 1ms; - running: true; - triggered => { - self.stop(); - root.maybe-load-more(); - } - } - HorizontalBox { padding: 0px; spacing: 0px; @@ -340,8 +282,8 @@ export component ContactsView inherits Rectangle { title-weight: 600; show-leading: !root.compact; leading-icon: root.sidebar-collapsed - ? @image-url("../icons/phosphor/sidebar-simple.svg") - : @image-url("../icons/phosphor/sidebar-simple-fill.svg"); + ? @image-url("../icons/phosphor/sidebar.svg") + : @image-url("../icons/phosphor/sidebar-fill.svg"); leading-tooltip: root.sidebar-collapsed ? @tr("Show contact groups") : @tr("Hide contact groups"); leading-accessible-id: "contacts-sidebar-button"; leading-ghost: true; @@ -401,7 +343,7 @@ export component ContactsView inherits Rectangle { height: parent.height; horizontal-scrollbar-policy: always-off; mouse-drag-pan-enabled: root.compact; - scrolled => { root.user-scrolled(); } + scrolled => { pagination.user-scrolled(); } for contact in root.contacts : Rectangle { x: 12px; diff --git a/ui/views/files-view.slint b/ui/views/files-view.slint new file mode 100644 index 0000000..b110f5e --- /dev/null +++ b/ui/views/files-view.slint @@ -0,0 +1,617 @@ +import { DocumentPreview } from "../components/document-preview.slint"; +import { InfiniteScrollController } from "../components/infinite-scroll-controller.slint"; +import { ScrollFade } from "../components/scroll-fade.slint"; +import { HorizontalBox, VerticalBox, ListView, ScrollView, ComboBox, CheckBox } from "std-widgets.slint"; +import { AppTheme, LayoutMetrics } from "../design-system.slint"; +import { TypeScale } from "../typography.slint"; +import { Button } from "../components/button.slint"; +import { AccountRow } from "../models.slint"; +import { SidebarItem, SidebarSectionHeader } from "../components/sidebar-controls.slint"; +import { CollapsibleWorkspaceSidebar, WorkspaceMiniHeading, WorkspaceTitleRow, WorkspaceSearchToolbar, WorkspaceSearchFilter } from "../components/workspace-layout.slint"; +import { LineEdit, ToolbarIconButton } from "../components/controls.slint"; + +export struct FileRow { + identity: string, name: string, detail: string, size: string, date: string, directory: bool, + attachment: bool, readable: bool, writable: bool, renameable: bool, + deletable: bool, shareable: bool, locked: bool, owns-lock: bool, + created: string, modified: string, accessed: string, executable: bool, subscribed: bool, +} +export struct OperationRow {id:string,action:string,state:string,error:string,bytes:string} +export global FilesUi { + callback context-changed; + in-out property <[OperationRow]> operations; + in-out property transfers-open: false; + in-out property collision:0; + in-out property file-kind:0; + in-out property file-sort:0; + in-out property descending:false; + in-out property min-size; + in-out property max-size; + in-out property case-insensitive:false; + in-out property <[FileRow]> rows; + in-out property <[string]> accounts: [@tr("All accounts")]; + in-out property account: 0; + in-out property selected-account-id: -1; + in-out property <[string]> storage-accounts; + in-out property storage-account: 0; + in-out property server-attachments: false; + in-out property attachments: true; + in-out property connected: false; + in-out property connection-revision: 0; + in-out property busy: false; + in-out property transfer-status; + in-out property can-create: false; + in-out property can-lock: false; + in-out property content-revision: 0; + in-out property pagination-failed: false; + in-out property can-more: false; + in-out property can-up: false; + in-out property webdav: false; + in-out property endpoint; + in-out property path: @tr("Email attachments"); + in-out property query; + in-out property status; + in-out property quota; + in-out property selected: -1; + in-out property preview-text; + in-out property pdf-supported: true; + in-out property pdf-page: 0; + in-out property pdf-pages: 0; + in-out property pdf-zoom: 100; + in-out property preview-image; + in-out property preview-is-image: false; + in-out property preview-open: false; + in-out property activity-open: false; + in-out property dialog; + callback command(string, string, string); +} + +// Keep feature navigation on the same shared row surface as Mail and Contacts. +component FilesSidebarItem inherits SidebarItem { + sidebar-background: AppTheme.sidebar-bg; + selection-background: AppTheme.accent-soft; + accent-color: AppTheme.accent; + body-color: AppTheme.body; + muted-color: AppTheme.muted; + icon-color: root.selected ? AppTheme.accent : AppTheme.secondary; + row-height: 32px; +} + +component FilesNavigation inherits Rectangle { + in property <[AccountRow]> account-model; + callback navigate(string, string, string); + callback setup; + VerticalBox { + padding-top: LayoutMetrics.sidebar-action-top; + padding-left: LayoutMetrics.sidebar-inset; + padding-right: LayoutMetrics.sidebar-inset; + padding-bottom: 12px; + spacing: 8px; + Button { + height: LayoutMetrics.sidebar-action-height; + text: @tr("Connect storage"); primary: true; has-icon: true; + icon: @image-url("../icons/phosphor/plus.svg"); + enabled: !FilesUi.busy; clicked => { root.setup(); } + } + ScrollView { + vertical-stretch: 1; viewport-width: self.visible-width; + horizontal-scrollbar-policy: always-off; + VerticalBox { + padding: 0px; spacing: 6px; + WorkspaceMiniHeading { label: @tr("UNIFIED"); } + FilesSidebarItem { + label: @tr("Email attachments"); left-padding: 12px; + icon: @image-url("../icons/phosphor/paperclip.svg"); + selected: FilesUi.attachments && FilesUi.account == 0; + clicked(scope) => { root.navigate("account", "0", "attachments"); } + } + WorkspaceMiniHeading { label: @tr("ACCOUNTS"); } + for account in root.account-model : VerticalBox { + padding: 0px; spacing: 3px; + private property expanded: true; + SidebarSectionHeader { + label: account.name; detail: account.email; + avatar: account.initials; avatar-image: account.avatar-small; + has-avatar: true; has-avatar-image: account.has-avatar; + open: parent.expanded; + sidebar-background: AppTheme.sidebar-bg; + body-color: AppTheme.body; muted-color: AppTheme.muted; + icon-color: AppTheme.secondary; hover-background: AppTheme.muted-control-bg; + toggled(open) => { parent.expanded = open; } + } + if self.expanded : FilesSidebarItem { + label: @tr("Email attachments"); + icon: @image-url("../icons/phosphor/paperclip.svg"); + selected: FilesUi.attachments && FilesUi.selected-account-id == account.id; + clicked(scope) => { root.navigate("account-id", "" + account.id, "attachments"); } + } + if self.expanded : FilesSidebarItem { + label: @tr("Server storage"); + icon: @image-url("../icons/phosphor/files.svg"); + selected: !FilesUi.attachments && FilesUi.selected-account-id == account.id; + clicked(scope) => { root.navigate("account-id", "" + account.id, "storage"); } + } + } + if !FilesUi.attachments && FilesUi.storage-accounts.length > 0 : WorkspaceMiniHeading { label: @tr("STORAGE SPACE"); } + for space[index] in FilesUi.storage-accounts : FilesSidebarItem { + visible: !FilesUi.attachments; + label: space; icon: @image-url("../icons/phosphor/folder-simple.svg"); + selected: index == FilesUi.storage-account; + clicked(scope) => { root.navigate("space", "" + index, ""); } + } + Rectangle { vertical-stretch: 1; } + } + } + if !FilesUi.attachments : Text { text: FilesUi.quota; color: AppTheme.secondary; wrap: word-wrap; font-size: TypeScale.caption; } + if !FilesUi.attachments && FilesUi.connected && !FilesUi.webdav : Button { text: @tr("Sharing activity"); enabled: !FilesUi.busy; clicked => { root.navigate("notifications", "", ""); } } + if !FilesUi.attachments : Button { text: @tr("Transfers"); enabled: !FilesUi.busy; clicked => { root.navigate("transfers", "", ""); } } + + } +} + +export component FilesView inherits Rectangle { + in property compact: false; + in property active: true; + in property layout-mode: "default"; + in property <[AccountRow]> account-model; + in-out property sidebar-collapsed: false; + private property navigation-open: false; + private property single-surface: root.compact || root.layout-mode == "minimal"; + private property list-visible: !root.single-surface || !FilesUi.preview-open; + private property detail-visible: !root.single-surface || FilesUi.preview-open; + private property search-text: FilesUi.query; + private property search-pending: false; + changed active => { if !root.active { root.search-pending = false; FilesUi.command("release-preview", "", ""); FilesUi.preview-open = false; } } + public function close-navigation() -> bool { + if root.setup { root.setup = false; return true; } + if root.navigation-open { root.navigation-open = false; return true; } + return false; + } + function navigate(action: string, value: string, scope: string) { + if !FilesUi.busy { + root.navigation-open = false; + root.search-pending = false; + if action == "account" || action == "account-id" || action == "space" || action == "up" || action == "attachment-source" { root.search-text = ""; } + FilesUi.command(action, value, scope); + } + } + pagination := InfiniteScrollController { + active: root.active && root.list-visible && !root.setup && !root.search-pending; + can-load-more: FilesUi.can-more; loading: FilesUi.busy; + row-count: FilesUi.rows.length; revision: FilesUi.content-revision; + content-height: file-list.viewport-height; + viewport-y: file-list.viewport-y; visible-height: file-list.visible-height; + prefetch-distance: 246px; + load-more => { FilesUi.command("more", "", ""); } + } + function reset-pagination() { + file-list.viewport-y = 0px; + pagination.reset(); + } + private property list-scope: "\{FilesUi.account}:\{FilesUi.attachments ? 1 : 0}:\{FilesUi.storage-account}:\{FilesUi.path}:\{FilesUi.query}:\{FilesUi.server-attachments ? 1 : 0}"; + changed list-scope => { root.reset-pagination(); } + search-delay := Timer { + interval: 300ms; + running: root.search-pending && !FilesUi.busy && root.active; + triggered => { if !FilesUi.busy { root.search-pending = false; FilesUi.command("search", root.search-text, ""); } } + } + + Timer { interval: 60s; running: root.active && FilesUi.connected && !FilesUi.attachments && !FilesUi.busy && FilesUi.dialog == "" && !FilesUi.preview-open; triggered => { FilesUi.command("poll", "", ""); } } + in-out property setup: false; + private property connection-attempted: false; + changed setup => { root.connection-attempted = false; } + private property connection-revision: FilesUi.connection-revision; + changed connection-revision => { root.setup = false; } + + private property value; + private property destination; + private property copy-space; + private property allow-write: false; + private property allow-add: false; + private property allow-rename: false; + private property allow-delete: false; + private property allow-share: false; + private property allow-properties:false; + private property property-namespace:"urn:flectar:files"; + private property property-name:"description"; + private property shared-lock:false; + private property recursive-lock:false; + private property created; + private property modified; + private property accessed; + private property executable; + private property subscribed; + private property selected-row: FilesUi.selected >= 0 && FilesUi.selected < FilesUi.rows.length ? FilesUi.rows[FilesUi.selected] : {name: ""}; + background: AppTheme.surface; + forward-focus: files-focus; + files-focus := FocusScope { + width: parent.width; height: parent.height; + key-pressed(event) => { + if event.text == Key.Escape { + if root.navigation-open { root.navigation-open = false; return accept; } + if FilesUi.preview-open { FilesUi.preview-open = false; if FilesUi.busy { FilesUi.command("cancel", "", ""); } FilesUi.command("release-preview", "", ""); return accept; } + if FilesUi.transfers-open { FilesUi.transfers-open = false; return accept; } + if FilesUi.dialog != "" { FilesUi.dialog = ""; return accept; } + if root.setup { root.setup = false; return accept; } + } + return reject; + } + HorizontalBox { + width: parent.width; height: parent.height; + padding: 0px; spacing: 0px; + CollapsibleWorkspaceSidebar { + visible: !root.compact; + collapsed: root.compact || root.sidebar-collapsed; + if !root.compact && !root.sidebar-collapsed : FilesNavigation { + width: parent.width; height: parent.height; + account-model: root.account-model; + navigate(action, value, scope) => { root.navigate(action, value, scope); } + setup => { root.setup = !root.setup; FilesUi.preview-open = false; FilesUi.command("release-preview", "", ""); } + } + } + Rectangle { + visible: root.list-visible; + min-width: 0px; + preferred-width: root.single-surface ? 0px : LayoutMetrics.list-pane-width; + max-width: !root.list-visible ? 0px : root.single-surface ? root.width : LayoutMetrics.list-pane-width; + horizontal-stretch: root.list-visible && root.single-surface ? 1 : 0; + VerticalBox { + padding: 0px; spacing: 0px; + Rectangle { + height: root.compact ? 116px : 102px; + VerticalBox { + padding-top: root.compact ? 8px : LayoutMetrics.workspace-action-center-y - 20px; + padding-left: 12px; padding-right: 12px; padding-bottom: root.compact ? 8px : 2px; spacing: 8px; + WorkspaceTitleRow { + height: root.compact ? LayoutMetrics.touch-target : LayoutMetrics.desktop-icon-button-size; + title: FilesUi.attachments ? @tr("Email attachments") : @tr("Server storage"); + title-font-size: root.compact ? TypeScale.title-small : TypeScale.heading-small; + title-weight: 600; show-leading: true; leading-ghost: true; + leading-icon: root.compact || root.sidebar-collapsed ? @image-url("../icons/phosphor/sidebar.svg") : @image-url("../icons/phosphor/sidebar-fill.svg"); + leading-tooltip: @tr("File sources"); leading-accessible-id: "files-sidebar-button"; + leading-clicked => { if root.compact { root.navigation-open = true; } else { root.sidebar-collapsed = !root.sidebar-collapsed; } } + if !FilesUi.attachments : ToolbarIconButton { icon-color: AppTheme.secondary; + icon: @image-url("../icons/phosphor/upload-simple.svg"); tooltip-text: @tr("Upload"); + interactive: FilesUi.can-create && !FilesUi.busy; clicked => { root.navigate("upload", "", ""); } + } + if !FilesUi.attachments : ToolbarIconButton { icon-color: AppTheme.secondary; + icon: @image-url("../icons/phosphor/folder-plus.svg"); + tooltip-text: @tr("New folder"); interactive: FilesUi.can-create && !FilesUi.busy; + clicked => { root.value = ""; FilesUi.collision = 0; FilesUi.case-insensitive = false; FilesUi.dialog = "folder"; } + } + } + WorkspaceSearchToolbar { + text: root.search-text; + placeholder-text: @tr("Search files"); + refresh-enabled: !FilesUi.busy; + refresh-tooltip: @tr("Refresh"); refresh-accessible-id: "files-refresh-button"; + filter-tooltip: @tr("Filter and sort files"); filter-accessible-id: "files-filter-button"; + filter-active: FilesUi.attachments ? FilesUi.server-attachments : FilesUi.file-kind != 0 || FilesUi.file-sort != 0 || FilesUi.descending || FilesUi.min-size != "" || FilesUi.max-size != ""; + private property <[WorkspaceSearchFilter]> attachment-filters: [ + {title: @tr("Synchronized mail"), value: "local"}, + {title: @tr("All mail on server (JMAP)"), value: "server"}, + {title: @tr("Index cached text"), value: "index"} + ]; + private property <[WorkspaceSearchFilter]> storage-filters: [{title: @tr("Filter and sort files"), value: "filter"}]; + filters: FilesUi.attachments ? self.attachment-filters : self.storage-filters; + edited(value) => { root.search-text = value; root.search-pending = true; search-delay.restart(); } + submitted(value) => { root.search-text = value; root.search-pending = false; if !FilesUi.busy { FilesUi.command("search", value, ""); } else { root.search-pending = true; } } + refresh => { if !root.search-pending { FilesUi.command("refresh", "", ""); } } + filter-selected(value) => { + if !FilesUi.busy { + if value == "filter" { FilesUi.dialog = "filter"; } + else if value == "index" { FilesUi.command("index-text", "", ""); } + else { root.navigate("attachment-source", value, ""); } + } + } + } + } + } + Rectangle { + height: 40px; + HorizontalBox { + padding-left: 12px; padding-right: 12px; padding-top: 4px; padding-bottom: 4px; spacing: 6px; + if FilesUi.can-up : ToolbarIconButton { icon-color: AppTheme.secondary; icon: @image-url("../icons/phosphor/arrow-left.svg"); tooltip-text: @tr("Back"); interactive: !FilesUi.busy; clicked => { root.navigate("up", "", ""); } } + Text { text: FilesUi.attachments ? (FilesUi.server-attachments ? @tr("All mail on server (JMAP)") : @tr("Synchronized mail")) : FilesUi.path; color: AppTheme.muted; font-size: TypeScale.caption; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } + } + } + Rectangle { + vertical-stretch: 1; + if FilesUi.rows.length == 0 : VerticalBox { + alignment: center; padding: 24px; spacing: 12px; + Image { source: @image-url("../icons/phosphor/files.svg"); width: 40px; height: 40px; colorize: AppTheme.muted; horizontal-stretch: 0; } + Text { text: FilesUi.busy ? @tr("Loading files…") : @tr("No files to show"); color: AppTheme.heading; font-weight: 600; font-size: TypeScale.body; horizontal-alignment: center; } + Text { text: FilesUi.attachments ? @tr("Attachments from synchronized messages will appear here.") : FilesUi.connected ? @tr("Upload a file or create a folder to get started.") : @tr("Select an account and connect its storage."); color: AppTheme.secondary; wrap: word-wrap; horizontal-alignment: center; } + } + file-list := ListView { + x: 0px; y: 0px; width: parent.width; height: parent.height; + mouse-drag-pan-enabled: root.compact; + accessible-label: @tr("Files"); accessible-item-count: FilesUi.rows.length; + scrolled => { pagination.user-scrolled(); } + visible: FilesUi.rows.length > 0; + horizontal-scrollbar-policy: always-off; + viewport-width: self.visible-width; + for row[index] in FilesUi.rows : Rectangle { + x: 12px; width: max(0px, parent.visible-width - 16px); height: 82px; border-radius: 6px; background: FilesUi.selected == index ? AppTheme.accent-soft : hit.has-hover ? AppTheme.sidebar-bg : transparent; + HorizontalBox { + padding: 12px; spacing: 12px; + Image { width: 24px; height: 24px; source: row.directory ? @image-url("../icons/phosphor/folder-simple.svg") : row.attachment ? @image-url("../icons/phosphor/paperclip.svg") : @image-url("../icons/phosphor/file.svg"); colorize: row.directory ? AppTheme.accent : AppTheme.secondary; } + VerticalBox { + padding: 0px; spacing: 4px; + Text { text: row.name; color: AppTheme.heading; font-size: TypeScale.body; font-weight: 600; overflow: elide; } + Text { text: row.detail; color: AppTheme.secondary; font-size: TypeScale.caption; overflow: elide; } + } + + Text { text: row.size; color: AppTheme.secondary; font-size: TypeScale.caption; width: 70px; horizontal-alignment: right; } + if row.locked : Image { width: 16px; height: 16px; source: @image-url("../icons/phosphor/lock-key.svg"); colorize: AppTheme.muted; } + } + row-focus := FocusScope { + enabled: !FilesUi.busy; + key-pressed(event) => { + if event.text == Key.Return { FilesUi.selected = index; FilesUi.command(row.directory ? "enter" : "preview", "" + index, ""); return accept; } + if event.text == " " { FilesUi.selected = index; return accept; } + return reject; + } + hit := TouchArea { + enabled: !FilesUi.busy; mouse-cursor: pointer; accessible-role: button; accessible-label: row.name + ", " + row.detail; accessible-checked: FilesUi.selected == index; + clicked => { row-focus.focus(); FilesUi.selected = index; FilesUi.command(row.directory ? "details" : "preview", "" + index, ""); } + double-clicked => { FilesUi.selected = index; FilesUi.command(row.directory ? "enter" : "preview", "" + index, ""); } + accessible-action-default => { row-focus.focus(); FilesUi.selected = index; FilesUi.command(row.directory ? "details" : "preview", "" + index, ""); } + } + } + } + } + ScrollFade { + x: file-list.x; y: file-list.y; width: file-list.visible-width; height: file-list.visible-height; + surface-color: AppTheme.surface; viewport-y: file-list.viewport-y; + viewport-height: file-list.viewport-height; visible-height: file-list.visible-height; + } + } + HorizontalBox { + padding: 12px; + Text { text: FilesUi.busy ? (FilesUi.transfer-status!="" ? FilesUi.transfer-status : @tr("Working…")) : FilesUi.status; color: AppTheme.secondary; wrap: word-wrap; font-size: TypeScale.caption; horizontal-stretch: 1; } + if FilesUi.busy : Button { text: @tr("Cancel"); clicked => { FilesUi.command("cancel", "", ""); } } + if FilesUi.can-more && !FilesUi.busy && FilesUi.pagination-failed : Button { text: @tr("Load more"); clicked => { pagination.user-scrolled(); } } + } + } + } + if root.detail-visible : Rectangle { + horizontal-stretch: 1; min-width: 0px; background: AppTheme.surface; + Rectangle { x: 0px; y: 0px; width: 1px; height: parent.height; background: AppTheme.border; } + VerticalBox { + width: parent.width; height: parent.height; + padding: root.compact ? 12px : 24px; spacing: 16px; cross-axis-alignment: stretch; + if FilesUi.preview-open : WorkspaceTitleRow { + title: FilesUi.activity-open ? @tr("Sharing activity") : root.selected-row.name; + show-leading: root.single-surface; + leading-icon: @image-url("../icons/phosphor/arrow-left.svg"); + leading-tooltip: @tr("Back to files"); + leading-clicked => { FilesUi.preview-open = false; if FilesUi.busy { FilesUi.command("cancel", "", ""); } FilesUi.command("release-preview", "", ""); } + if !root.single-surface : ToolbarIconButton { icon-color: AppTheme.secondary; icon: @image-url("../icons/phosphor/x.svg"); tooltip-text: @tr("Close"); clicked => { FilesUi.preview-open = false; if FilesUi.busy { FilesUi.command("cancel", "", ""); } FilesUi.command("release-preview", "", ""); } } + } + if FilesUi.preview-open && !FilesUi.activity-open : VerticalBox { + padding: 0px; spacing: 8px; + Text { text: root.selected-row.detail; wrap: word-wrap; color: AppTheme.secondary; font-size: TypeScale.caption; } + HorizontalBox { + padding: 0px; spacing: 8px; + Button { text: root.selected-row.directory ? @tr("Open folder") : @tr("Download"); enabled: !FilesUi.busy && root.selected-row.readable; clicked => { FilesUi.command(root.selected-row.directory ? "enter" : "download", "", ""); } } + if !FilesUi.attachments : ToolbarIconButton { icon-color: AppTheme.secondary; icon: @image-url("../icons/phosphor/dots-three.svg"); tooltip-text: @tr("Manage"); interactive: !FilesUi.busy; clicked => { FilesUi.dialog = "manage"; } } + if !root.selected-row.directory : Button { text: @tr("Preview"); enabled: !FilesUi.busy; clicked => { FilesUi.command("preview", "", ""); } } + if FilesUi.attachments : ToolbarIconButton { icon-color: AppTheme.secondary; icon: @image-url("../icons/phosphor/info-fill.svg"); tooltip-text: @tr("Details"); interactive: !FilesUi.busy; clicked => { FilesUi.command("details", "", ""); } } + Rectangle { horizontal-stretch: 1; } + } + } + if !FilesUi.preview-open : VerticalBox { + alignment: center; spacing: 12px; + Image { source: @image-url("../icons/phosphor/files.svg"); width: 40px; height: 40px; colorize: AppTheme.muted; } + Text { text: @tr("Select a file to preview"); color: AppTheme.heading; horizontal-alignment: center; font-size: TypeScale.title-small; } + Text { text: FilesUi.pdf-supported ? @tr("PDFs, images and text appear here. Other formats can be downloaded.") : @tr("Images and text appear here. Other formats can be downloaded."); wrap: word-wrap; color: AppTheme.secondary; horizontal-alignment: center; } + } + if FilesUi.preview-open && FilesUi.busy : Text { text: FilesUi.transfer-status != "" ? FilesUi.transfer-status : @tr("Loading preview…"); color: AppTheme.secondary; wrap: word-wrap; } + if FilesUi.preview-open && FilesUi.activity-open : Button { text: @tr("Clear displayed activity"); enabled: !FilesUi.busy; clicked => { FilesUi.command("clear-notifications", "", ""); } } + if FilesUi.preview-open && FilesUi.preview-is-image : DocumentPreview { + compact: root.compact; busy: FilesUi.busy; source: FilesUi.preview-image; + page: FilesUi.pdf-page; pages: FilesUi.pdf-pages; + raster-zoom: FilesUi.pdf-pages > 0 ? 2 : 1; + document-revision: FilesUi.selected; + render-page(page, zoom) => { FilesUi.command("pdf-page", "" + page, "200"); } + } + if FilesUi.preview-open && !FilesUi.preview-is-image : ScrollView { vertical-stretch: 1; viewport-width: self.visible-width; Text { x: 0px; y: 0px; width: parent.width; height: self.preferred-height; text: FilesUi.preview-text; wrap: word-wrap; color: AppTheme.body; font-size: TypeScale.body; } } + if FilesUi.preview-open && FilesUi.status != "" : Text { text: FilesUi.status; color: AppTheme.secondary; wrap: word-wrap; font-size: TypeScale.caption; } + } + } + } + if root.compact && root.navigation-open : Rectangle { + background: #00000060; + TouchArea { clicked => { root.navigation-open = false; } } + Rectangle { + x: 0px; y: 0px; + width: min(LayoutMetrics.sidebar-width + 40px, parent.width - 24px); + height: parent.height; background: AppTheme.sidebar-bg; + VerticalBox { + padding: 0px; spacing: 0px; + WorkspaceTitleRow { title: @tr("File sources"); horizontal-inset: 12px; Button { text: @tr("Close"); clicked => { root.navigation-open = false; } } } + FilesNavigation { vertical-stretch: 1; account-model: root.account-model; + navigate(action, value, scope) => { root.navigate(action, value, scope); } + setup => { root.navigation-open = false; root.setup = !root.setup; FilesUi.preview-open = false; FilesUi.command("release-preview", "", ""); } + } + } + } + } + + if FilesUi.dialog != "" : Rectangle { + background: #00000060; + TouchArea { } + Rectangle { + x: (parent.width - self.width) / 2; y: max(16px, (parent.height - self.height) / 2); + width: min(440px, parent.width - 24px); height: min(dialog-layout.preferred-height, parent.height - 32px); + background: AppTheme.surface; border-radius: 12px; border-width: 1px; border-color: AppTheme.border; + ScrollView { + viewport-width: self.width; + dialog-layout := VerticalBox { + padding: 24px; spacing: 12px; + Text { text: FilesUi.dialog == "delete" ? @tr("Delete permanently?") : FilesUi.dialog == "share" ? @tr("Share file or folder") : FilesUi.dialog == "folder" ? @tr("New folder") : FilesUi.dialog == "rename" ? @tr("Rename") : FilesUi.dialog == "move" ? @tr("Move to folder") : FilesUi.dialog == "copy" ? @tr("Copy to folder") : FilesUi.dialog == "metadata" ? @tr("File properties") : FilesUi.dialog == "lock" ? @tr("Lock file or folder") : FilesUi.dialog == "filter" ? @tr("Filter and sort files") : @tr("Manage file"); color: AppTheme.heading; font-size: TypeScale.title; font-weight: 700; } + if FilesUi.dialog == "filter" : VerticalBox { + padding:0px;spacing:8px; + ComboBox {height:36px;vertical-stretch:0;model:[@tr("Files and folders"),@tr("Files"),@tr("Folders")];current-index <=> FilesUi.file-kind;} + ComboBox {height:36px;vertical-stretch:0;model:[@tr("Name"),@tr("Size"),@tr("Kind")];current-index <=> FilesUi.file-sort;} + CheckBox {text:@tr("Descending order");checked <=> FilesUi.descending;} + if FilesUi.file-kind!=2 : LineEdit {input-label:@tr("Minimum bytes");placeholder-text:@tr("Minimum bytes (optional)");text <=> FilesUi.min-size;} + if FilesUi.file-kind!=2 : LineEdit {input-label:@tr("Maximum bytes");placeholder-text:@tr("Maximum bytes (optional)");text <=> FilesUi.max-size;} + Button {text:@tr("Reset filters");clicked=>{FilesUi.file-kind=0;FilesUi.file-sort=0;FilesUi.descending=false;FilesUi.min-size="";FilesUi.max-size="";}} + } + if FilesUi.dialog == "manage" : VerticalBox { + padding: 0px; spacing: 8px; + if !root.selected-row.directory : Button {text:@tr("Keep offline");clicked=>{FilesUi.dialog="";FilesUi.command("keep-offline","","");}} + if !root.selected-row.directory : Button {text:@tr("Release offline copy");clicked=>{FilesUi.dialog="";FilesUi.command("release-offline","","");}} + Button { text: @tr("Rename"); enabled: root.selected-row.renameable; clicked => { root.value = root.selected-row.name; FilesUi.collision = 0; FilesUi.case-insensitive = false; FilesUi.dialog = "rename"; } } + Button { text: @tr("Move…"); enabled: root.selected-row.renameable; clicked => { root.value = root.selected-row.name; root.destination = ""; FilesUi.collision = 0; FilesUi.case-insensitive = false; FilesUi.dialog = "move"; } } + Button { text: @tr("Copy…"); enabled: root.selected-row.readable; clicked => { root.value = root.selected-row.name; root.destination = ""; root.copy-space = FilesUi.storage-account; FilesUi.collision = 0; FilesUi.case-insensitive = false; FilesUi.dialog = "copy"; } } + if !root.selected-row.directory : Button { text: @tr("Replace content…"); enabled: root.selected-row.writable; clicked => { FilesUi.dialog = ""; FilesUi.command("replace", "" + FilesUi.selected, ""); } } + Button { text: @tr("Properties…"); enabled: root.selected-row.writable; clicked => { root.created = root.selected-row.created; root.modified = root.selected-row.modified; root.accessed = root.selected-row.accessed; root.executable = root.selected-row.executable; root.subscribed = root.selected-row.subscribed; root.value = ""; FilesUi.dialog = "metadata"; } } + Button { text: @tr("Sharing…"); has-icon: true; icon: @image-url("../icons/phosphor/share-network.svg"); enabled: root.selected-row.shareable; clicked => { root.value = ""; root.allow-write = false; root.allow-properties=false; root.allow-add = false; root.allow-rename = false; root.allow-delete = false; root.allow-share = false; FilesUi.dialog = "share"; } } + if FilesUi.can-lock : Button { text: root.selected-row.owns-lock ? @tr("Release lock") : @tr("Lock…"); enabled: root.selected-row.writable; clicked => { if root.selected-row.owns-lock {FilesUi.dialog="";FilesUi.command("unlock","","");}else{FilesUi.dialog="lock";} } } + if FilesUi.can-lock && root.selected-row.owns-lock : Button { text: @tr("Renew lock for 5 minutes"); enabled: root.selected-row.writable; clicked => { FilesUi.dialog = ""; FilesUi.command("lock", "300", ""); } } + Button { text: @tr("Delete…"); destructive: true; enabled: root.selected-row.deletable; clicked => { FilesUi.dialog = "delete"; } } + } + if FilesUi.dialog == "delete" : Text { text: @tr("This permanently deletes “{}” and all of its contents from storage. This cannot be undone.", root.selected-row.name); wrap: word-wrap; color: AppTheme.body; } + if FilesUi.dialog == "folder" || FilesUi.dialog == "rename" || FilesUi.dialog == "move" || FilesUi.dialog == "copy" : LineEdit { text <=> root.value; input-label: @tr("Name"); placeholder-text: @tr("Name"); } + if FilesUi.dialog == "move" || FilesUi.dialog == "copy" : VerticalBox { + padding: 0px; spacing: 8px; + if FilesUi.dialog == "copy" && !FilesUi.webdav : ComboBox { height: 36px; vertical-stretch: 0; model: FilesUi.storage-accounts; current-index <=> root.copy-space; } + Text { text: @tr("Destination folder path (for example /Documents). Leave empty for the current folder, or the root of another storage space."); wrap: word-wrap; color: AppTheme.secondary; font-size: TypeScale.caption; } + LineEdit { text <=> root.destination; input-label: @tr("Destination folder"); placeholder-text: "/Documents"; } + } + if FilesUi.dialog == "share" : VerticalBox { + padding: 0px; spacing: 8px; + Text { text: FilesUi.webdav ? @tr("Enter the recipient’s principal URL on this server.") : @tr("Enter the recipient’s email or principal ID on this server."); wrap: word-wrap; color: AppTheme.secondary; font-size: TypeScale.caption; } + LineEdit { text <=> root.value; input-label: @tr("Recipient"); placeholder-text: @tr("Recipient"); } + CheckBox { text: FilesUi.webdav ? @tr("Allow editing") : @tr("Modify content"); checked <=> root.allow-write; } + VerticalBox { + padding: 0px; spacing: 4px; + if FilesUi.webdav : CheckBox {text:@tr("Modify properties");checked <=> root.allow-properties;} + CheckBox { text: @tr("Add children"); checked <=> root.allow-add; } + if !FilesUi.webdav : CheckBox { text: @tr("Rename and move"); checked <=> root.allow-rename; } + CheckBox { text: @tr("Delete"); checked <=> root.allow-delete; } + CheckBox { text: @tr("Manage sharing"); checked <=> root.allow-share; } + } + Button { text: @tr("Remove recipient’s access"); enabled: root.value != ""; clicked => { FilesUi.command("unshare", root.value, ""); FilesUi.dialog = ""; } } + } + if FilesUi.dialog == "lock" : VerticalBox { + padding:0px;spacing:8px; + CheckBox {text:@tr("Shared lock");checked <=> root.shared-lock;} + if root.selected-row.directory : CheckBox {text:@tr("Include folder contents");checked <=> root.recursive-lock;} + Text {text:@tr("Locks expire after 5 minutes unless renewed.");color:AppTheme.secondary;wrap:word-wrap;} + } + if FilesUi.dialog=="copy" || FilesUi.dialog=="move" || FilesUi.dialog=="rename" || (FilesUi.dialog=="folder" && !FilesUi.webdav) : VerticalBox { + padding:0px;spacing:8px; + Text {text:@tr("If a name already exists");color:AppTheme.secondary;} + ComboBox {height:36px;vertical-stretch:0;model:[@tr("Stop and report conflict"),@tr("Keep both with a new name"),@tr("Replace existing"),@tr("Keep newest")];current-index <=> FilesUi.collision;} + if FilesUi.collision>=2 : Text {text:@tr("This can replace existing content at the destination.");color:AppTheme.secondary;wrap:word-wrap;} + if !FilesUi.webdav : CheckBox {text:@tr("Compare names without case");checked <=> FilesUi.case-insensitive;} + } + if FilesUi.dialog == "metadata" : VerticalBox { + padding: 0px; spacing: 8px; + if FilesUi.webdav : VerticalBox { + padding:0px;spacing:8px; + LineEdit {text <=> root.property-namespace;input-label:@tr("Property namespace");} + LineEdit {text <=> root.property-name;input-label:@tr("Property name");} + LineEdit {text <=> root.value;input-label:@tr("Value");} + Button {text:@tr("Remove property");clicked=>{FilesUi.command("remove-property","",root.property-namespace+"|"+root.property-name);FilesUi.dialog="";}} + } + if !FilesUi.webdav : VerticalBox { + padding: 0px; spacing: 8px; + Text { text: @tr("Dates use UTC, for example 2026-09-09T12:00:00Z."); wrap: word-wrap; color: AppTheme.secondary; font-size: TypeScale.caption; } + LineEdit { text <=> root.created; input-label: @tr("Created"); placeholder-text: @tr("Created"); } + LineEdit { text <=> root.modified; input-label: @tr("Modified"); placeholder-text: @tr("Modified"); } + LineEdit { text <=> root.accessed; input-label: @tr("Accessed"); placeholder-text: @tr("Accessed"); } + if !root.selected-row.directory : CheckBox { text: @tr("Executable"); checked <=> root.executable; } + Text {text:@tr("Subscription changes are unavailable on this server version.");wrap:word-wrap;color:AppTheme.secondary;} + } + } + HorizontalBox { + padding: 0px; + Button { text: @tr("Cancel"); clicked => { FilesUi.dialog = ""; } } + if FilesUi.dialog != "manage" : Button { text: FilesUi.dialog == "delete" ? @tr("Delete permanently") : @tr("Save"); primary: FilesUi.dialog != "delete"; destructive: FilesUi.dialog == "delete"; enabled: !FilesUi.busy && (FilesUi.dialog == "delete" || FilesUi.dialog == "metadata" || FilesUi.dialog == "lock" || FilesUi.dialog == "filter" || root.value != ""); clicked => { if FilesUi.dialog == "metadata" && !FilesUi.webdav { + FilesUi.command("metadata", root.created + "|" + root.modified + "|" + root.accessed, (root.executable ? "x" : "") + (root.subscribed ? "s" : "")); + } else if FilesUi.dialog=="filter" { + FilesUi.command("filter","",""); + } else if FilesUi.dialog=="lock" { + FilesUi.command("lock","300",(root.shared-lock?"s":"")+(root.recursive-lock?"r":"")); + } else if FilesUi.dialog=="metadata" && FilesUi.webdav { + FilesUi.command("metadata",root.value,root.property-namespace+"|"+root.property-name); + } else if FilesUi.dialog == "copy" && !FilesUi.webdav { + FilesUi.command("copy-space", root.value, "" + root.copy-space + "|" + root.destination); + } else { + FilesUi.command(FilesUi.dialog, root.value, FilesUi.dialog == "share" ? (root.allow-write ? "w" : "") + (root.allow-add ? "a" : "") + (root.allow-rename ? "r" : "") + (root.allow-delete ? "d" : "") + (root.allow-share ? "s" : "") + (root.allow-properties ? "p" : "") : root.destination); + } FilesUi.dialog = ""; } } + } + } + } + } + } + if root.setup : Rectangle { + background: #00000088; z: 100; + TouchArea {} + Rectangle { + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; + width: min(480px, parent.width - 24px); height: min(300px + root.account-model.length * 42px + (root.connection-attempted ? 48px : 0px), parent.height - 24px); + background: AppTheme.surface; border-radius: 12px; border-width: 1px; border-color: AppTheme.border; + FocusScope { + init => { self.focus(); } + key-pressed(event) => { + if event.text == Key.Escape { root.setup = false; files-focus.focus(); return accept; } + reject + } + VerticalBox { + padding: 16px; spacing: 12px; + HorizontalBox { height: 40px; vertical-stretch: 0; + Text { text: @tr("Connect storage"); color: AppTheme.heading; font-size: TypeScale.title; horizontal-stretch: 1; } + ToolbarIconButton { compact: root.compact; icon: @image-url("../icons/phosphor/x.svg"); icon-color: AppTheme.secondary; tooltip-text: @tr("Close"); clicked => { root.setup = false; } } + } + ScrollView { viewport-width: self.visible-width; viewport-height: storage-form.preferred-height; + storage-form := VerticalBox { + width: parent.visible-width; height: self.preferred-height; + alignment: start; padding: 0px; spacing: 10px; + Text { text: @tr("Account"); color: AppTheme.secondary; } + for account in root.account-model : FilesSidebarItem { + label: account.email; selected: FilesUi.selected-account-id == account.id; + clicked(scope) => { root.navigate("account-id", "" + account.id, "attachments"); } + } + Text { text: @tr("Uses the selected account’s saved credentials."); color: AppTheme.secondary; font-size: TypeScale.caption; } + VerticalBox { + alignment: start; padding: 0px; spacing: 8px; + ComboBox { height: 36px; vertical-stretch: 0; model: ["JMAP", "WebDAV"]; current-index: FilesUi.webdav ? 1 : 0; enabled: !FilesUi.busy; selected(value) => { FilesUi.webdav = self.current-index == 1; } } + LineEdit { text <=> FilesUi.endpoint; input-label: @tr("Storage URL"); placeholder-text: FilesUi.webdav ? "https://mail.example.com/dav/file/username/" : "https://mail.example.com"; enabled: !FilesUi.busy; horizontal-stretch: 1; } + Button { text: @tr("Connect"); primary: true; enabled: FilesUi.account > 0 && !FilesUi.busy; clicked => { root.connection-attempted = true; FilesUi.command("connect", FilesUi.endpoint, FilesUi.webdav ? "dav" : "jmap"); } } + } + } + } + Text { visible: root.connection-attempted; height: root.connection-attempted ? self.preferred-height : 0px; text: FilesUi.status; color: AppTheme.secondary; wrap: word-wrap; } + } + } + } + } + if FilesUi.transfers-open : Rectangle { + background: AppTheme.surface; + VerticalBox { + padding: 16px; spacing: 12px; + HorizontalBox {Text {text:@tr("Transfers and operations");color:AppTheme.heading;font-size:TypeScale.title;} Button {text:@tr("Close");clicked=>{FilesUi.transfers-open=false;}}} + Text {text:@tr("Interrupted changes need review before retrying. Refresh storage and inspect the destination first.");wrap:word-wrap;color:AppTheme.secondary;} + ListView { + for operation in FilesUi.operations : VerticalBox { + padding: 8px; spacing: 8px; + Text {text:operation.action + " · " + operation.state + (operation.bytes!="" ? " · " + operation.bytes : "");color:AppTheme.heading;wrap:word-wrap;} + Text {text:operation.error;wrap:word-wrap;color:AppTheme.secondary;} + HorizontalBox { + if operation.state=="queued" : Button {text:@tr("Resume");enabled:!FilesUi.busy;clicked=>{FilesUi.command("retry-operation",operation.id,"");}} + if operation.state=="queued" || operation.state=="uncertain" : Button {text:@tr("Dismiss");enabled:!FilesUi.busy;clicked=>{FilesUi.command("cancel-operation",operation.id,"");}} + if operation.state=="uncertain" : Button {text:@tr("Reviewed as completed");enabled:!FilesUi.busy;clicked=>{FilesUi.command("resolve-operation",operation.id,"");}} + } + } + } + Text {text:FilesUi.status;wrap:word-wrap;color:AppTheme.secondary;} + } + } + } +} diff --git a/ui/views/mail-list-pane.slint b/ui/views/mail-list-pane.slint index 6bad1e2..9880180 100644 --- a/ui/views/mail-list-pane.slint +++ b/ui/views/mail-list-pane.slint @@ -1,3 +1,4 @@ +import { InfiniteScrollController } from "../components/infinite-scroll-controller.slint"; import { HorizontalBox, ListView, VerticalBox } from "std-widgets.slint"; import { EmailRow, MailDragApi } from "../models.slint"; import { Avatar } from "../components/avatar.slint"; @@ -67,6 +68,7 @@ export component MailListPane inherits Rectangle { in property loading_more: false; in property content_revision: 0; in property selection-count: 0; + private property selection-mode: root.selection-count > 0; in-out property mail_sidebar_collapsed: false; in property accent; in property accent-soft; @@ -95,92 +97,22 @@ export component MailListPane inherits Rectangle { callback set_email_checked(int, bool); callback set_all_emails_checked(bool); - // Prefetch while several rows remain instead of waiting for the physical - // bottom. The ListView stays virtualized; this callback only crosses into - // Rust when the viewport enters the trailing buffer. - private property pagination-prefetch-distance: root.dense ? 132px : 288px; - private property pagination-armed: true; - private property pagination-request-row-count: -1; - private property pagination-request-revision: -1; - private property pagination-request-height: -1px; - private property pagination-row-count: root.emails.length; - private property pagination-content-height: mail-list-scroll.viewport-height; - - function maybe-load-more() { - if (root.can_load_more - && !root.loading_more - && root.pagination-armed - && mail-list-scroll.viewport-height + mail-list-scroll.viewport-y - - mail-list-scroll.visible-height <= root.pagination-prefetch-distance) { - root.pagination-armed = false; - root.pagination-request-row-count = root.pagination-row-count; - root.pagination-request-revision = root.content_revision; - root.pagination-request-height = root.pagination-content-height; - root.load_more(); - } + pagination := InfiniteScrollController { + active: true; + can-load-more: root.can_load_more; loading: root.loading_more; + row-count: root.emails.length; revision: root.content_revision; + content-height: mail-list-scroll.viewport-height; + viewport-y: mail-list-scroll.viewport-y; visible-height: mail-list-scroll.visible-height; + prefetch-distance: root.dense ? 132px : 288px; + load-more => { root.load-more(); } } - function reset-pagination() { - root.pagination-armed = true; - root.pagination-request-row-count = -1; - root.pagination-request-revision = -1; - root.pagination-request-height = -1px; mail-list-scroll.viewport-y = 0px; - pagination-check.restart(); - } - - function user-scrolled() { - // A completed request with no revision was an error. A fresh user - // gesture is the retry signal; in-flight and successful requests stay - // latched until their settled content height changes. - if (!root.loading_more - && !root.pagination-armed - && root.content_revision == root.pagination-request-revision) { - root.pagination-armed = true; - } - root.maybe-load-more(); - } - - changed selected_scope => { - root.reset-pagination(); - } - changed search_query => { - root.reset-pagination(); - } - changed search_filter => { - root.reset-pagination(); - } - changed pagination-content-height => { - if (!root.pagination-armed - && root.content_revision != root.pagination-request-revision - && root.pagination-content-height != root.pagination-request-height) { - root.pagination-armed = true; - } - pagination-check.restart(); - } - changed content_revision => { - if (!root.pagination-armed - && root.content_revision != root.pagination-request-revision - && (root.pagination-row-count == root.pagination-request-row-count - || root.pagination-content-height != root.pagination-request-height)) { - root.pagination-armed = true; - } - pagination-check.restart(); - } - changed can_load_more => { pagination-check.restart(); } - changed loading_more => { - if (!root.loading_more) { - pagination-check.restart(); - } - } - pagination-check := Timer { - interval: 1ms; - running: true; - triggered => { - self.stop(); - root.maybe-load-more(); - } + pagination.reset(); } + changed selected_scope => { root.reset-pagination(); } + changed search_query => { root.reset-pagination(); } + changed search_filter => { root.reset-pagination(); } width: LayoutMetrics.list-pane-width; vertical-stretch: 1; @@ -222,8 +154,8 @@ export component MailListPane inherits Rectangle { title-weight: 600; show-leading: true; leading-icon: root.mail_sidebar_collapsed - ? @image-url("../icons/phosphor/sidebar-simple.svg") - : @image-url("../icons/phosphor/sidebar-simple-fill.svg"); + ? @image-url("../icons/phosphor/sidebar.svg") + : @image-url("../icons/phosphor/sidebar-fill.svg"); leading-tooltip: root.mail_sidebar_collapsed ? @tr("Show folders") : @tr("Hide folders"); leading-accessible-id: "mail-folders-button"; leading-ghost: true; @@ -240,16 +172,32 @@ export component MailListPane inherits Rectangle { HorizontalBox { height: LayoutMetrics.control-height; padding: 0px; - spacing: 6px; - - SelectionCheckBox { - width: LayoutMetrics.control-height; + spacing: 0px; + + // Reveal selection controls by animating the space + // they occupy. This lets the search field resize with + // the same motion as the collapsible workspace sidebar. + selection-slot := Rectangle { + private property animated-width: root.selection-mode + ? LayoutMetrics.control-height + 6px : 0px; + width: self.animated-width; height: LayoutMetrics.control-height; - enabled: root.emails.length > 0; - checked: root.emails.length > 0 && root.selection-count == root.emails.length; - indeterminate: root.selection-count > 0 && root.selection-count < root.emails.length; - accessible-label: self.checked ? @tr("Clear selection") : @tr("Select all messages on this page"); - toggled(checked) => { root.set_all_emails_checked(checked); } + clip: true; + background: transparent; + animate animated-width { duration: 160ms; easing: ease-in-out; } + + SelectionCheckBox { + visible: selection-slot.animated-width > 0px; + width: LayoutMetrics.control-height; + height: LayoutMetrics.control-height; + opacity: root.selection-mode ? 1 : 0; + enabled: root.selection-mode && root.emails.length > 0; + checked: root.emails.length > 0 && root.selection-count == root.emails.length; + indeterminate: root.selection-count > 0 && root.selection-count < root.emails.length; + accessible-label: self.checked ? @tr("Clear selection") : @tr("Select all messages on this page"); + animate opacity { duration: 120ms; } + toggled(checked) => { root.set_all_emails_checked(checked); } + } } WorkspaceSearchToolbar { @@ -313,9 +261,13 @@ export component MailListPane inherits Rectangle { mouse-drag-pan-enabled: root.compact; accessible-label: root.scope_title; accessible-item-count: root.emails.length; - scrolled => { root.user-scrolled(); } + scrolled => { pagination.user-scrolled(); } - for row[index] in root.emails : Rectangle { + for row[index] in root.emails : mail-row := Rectangle { + private property touch-pending: false; + private property long-press-triggered: false; + private property touch-start-x: 0; + private property touch-start-y: 0; width: mail-list-scroll.visible-width; height: root.dense ? 44px : 96px; border-radius: 6px; @@ -331,8 +283,24 @@ export component MailListPane inherits Rectangle { accessible-id: "mail-row-" + row.id; accessible-item-index: index; accessible-item-selectable: true; - accessible-item-selected: row.selected; - accessible-action-default => { root.select_email(row.id); } + accessible-item-selected: root.selection-mode ? row.checked : row.selected; + accessible-action-default => { + if (root.compact && root.selection-mode) { + root.set_email_checked(row.id, !row.checked); + } else { + root.select_email(row.id); + } + } + + Timer { + interval: 1s; + running: mail-row.touch-pending; + triggered => { + mail-row.touch-pending = false; + mail-row.long-press-triggered = true; + root.set_email_checked(row.id, true); + } + } Avatar { visible: root.show-avatars; @@ -482,11 +450,10 @@ export component MailListPane inherits Rectangle { } } - // Select on pointer-down rather than waiting for - // `clicked` (pointer-up). A long-running sync can - // replace the row model between those two events; - // in that case the old TouchArea never receives - // the release and the click appears to be lost. + // Desktop selects on pointer-down because a sync can + // replace the row model before release. Compact mode + // waits for release so it can distinguish taps, holds, + // and scrolling gestures. row-drag := DragArea { width: parent.width; height: parent.height; @@ -498,24 +465,57 @@ export component MailListPane inherits Rectangle { row-context := ContextMenuArea { width: parent.width; height: parent.height; + // Android maps this component to long-press. + // Compact rows reserve that gesture for entering + // multi-select mode instead. + enabled: !root.compact; row-hit := TouchArea { width: parent.width; height: parent.height; mouse-cursor: pointer; pointer-event(event) => { - if (event.kind == PointerEventKind.down) { - if (event.button == PointerEventButton.left) { + if (event.kind == PointerEventKind.down && event.button == PointerEventButton.left) { + if root.compact { + mail-row.touch-start-x = row-hit.mouse-x / 1px; + mail-row.touch-start-y = row-hit.mouse-y / 1px; + mail-row.long-press-triggered = false; + mail-row.touch-pending = true; + } else { root.select_email(row.id); - } else if (event.button == PointerEventButton.right) { - row-context.show({ x: row-hit.mouse-x, y: row-hit.mouse-y }); } } + if (root.compact + && event.kind == PointerEventKind.move + && mail-row.touch-pending + && (abs(row-hit.mouse-x / 1px - mail-row.touch-start-x) > 8 + || abs(row-hit.mouse-y / 1px - mail-row.touch-start-y) > 8)) { + mail-row.touch-pending = false; + } + if (root.compact + && (event.kind == PointerEventKind.up || event.kind == PointerEventKind.cancel)) { + let activate = event.kind == PointerEventKind.up + && mail-row.touch-pending + && !mail-row.long-press-triggered; + mail-row.touch-pending = false; + if activate { + if root.selection-mode { + root.set_email_checked(row.id, !row.checked); + } else { + root.select_email(row.id); + } + } + mail-row.long-press-triggered = false; + } + if (event.kind == PointerEventKind.down && event.button == PointerEventButton.right) { + row-context.show({ x: row-hit.mouse-x, y: row-hit.mouse-y }); + } } row-check := SelectionCheckBox { // Keep the control in the keyboard focus order even before hover. - opacity: root.compact || row.checked || root.selection-count > 0 || row-hit.has-hover || self.has-focus ? 1 : 0; + opacity: row.checked || root.selection-mode || (!root.compact && (row-hit.has-hover || self.has-focus)) ? 1 : 0; + enabled: !root.compact || root.selection-mode || row.checked; x: root.show-avatars ? 14px : 0px; y: root.show-avatars ? (root.dense ? 8px : 12px) : (parent.height - self.height) / 2; width: root.show-avatars ? (root.dense ? 28px : 38px) : 32px; @@ -525,6 +525,7 @@ export component MailListPane inherits Rectangle { accessible-label: row.checked ? @tr("Deselect {}", row.sender) : @tr("Select {}", row.sender); + animate opacity { duration: 120ms; } toggled(checked) => { root.set_email_checked(row.id, checked); } } } diff --git a/ui/views/mail-reading-pane.slint b/ui/views/mail-reading-pane.slint index ce8f23c..a02696d 100644 --- a/ui/views/mail-reading-pane.slint +++ b/ui/views/mail-reading-pane.slint @@ -1,5 +1,8 @@ +import { AccountMailPreferences } from "../components/account-mail-preferences.slint"; +import { ZoomControls } from "../components/zoom-controls.slint"; import { Button as ReaderButton, HorizontalBox, ListView, ScrollView, VerticalBox } from "std-widgets.slint"; -import { EmailReader, EmailLink, EmailTile, MailLabelRow } from "../models.slint"; +import { MailAttachments, EmailReader, EmailLink, EmailTile, MailLabelRow } from "../models.slint"; +import { SidebarItem } from "../components/sidebar-controls.slint"; import { Avatar } from "../components/avatar.slint"; import { Button } from "../components/button.slint"; import { IconButton, LineEdit, TextEdit, ToolbarIconButton } from "../components/controls.slint"; @@ -100,6 +103,7 @@ component LabelColorSwatch inherits Rectangle { export component MailReadingPane inherits Rectangle { in property compact: false; in property full-width: false; + private property attachment-menu-mode: root.height < 500px; in property show-avatars: true; // The title wrap width comes from the containing window, not this pane's // preferred width, which avoids a HorizontalBox measurement cycle. @@ -169,6 +173,13 @@ export component MailReadingPane inherits Rectangle { callback select-email-text(); callback load-remote-images-for-message(); + private property viewport-pending: false; + changed email-viewport-width => { root.viewport-pending = true; } + changed email-viewport-height => { root.viewport-pending = true; } + Timer { interval: 50ms; running: root.viewport-pending; triggered => { + root.viewport-pending = false; + if EmailReader.available { EmailReader.command("viewport"); } + } } private property selecting: false; private property touch-pending: false; private property touch-x: 0; @@ -541,6 +552,7 @@ export component MailReadingPane inherits Rectangle { if !root.selected_is_draft : MenuItem { title: @tr("Reply"); activated => { root.compose_message_action("reply"); } } if !root.selected_is_draft : MenuItem { title: @tr("Reply all"); activated => { root.compose_message_action("reply_all"); } } if !root.selected_is_draft : MenuItem { title: @tr("Forward"); activated => { root.compose_message_action("forward"); } } + if !root.selected_is_draft : MenuItem { title: @tr("Decrypt / verify OpenPGP"); activated => { AccountMailPreferences.read-message(); } } if root.print_supported && !root.selected_is_draft : MenuItem { title: @tr("Print message"); activated => { root.print_message(); } } if !root.selected_is_draft : MenuItem { title: @tr("Open in browser"); activated => { root.open_message_in_browser(); } } MenuItem { title: @tr("About email rendering"); activated => { root.rendering_info_open = true; } } @@ -589,15 +601,17 @@ export component MailReadingPane inherits Rectangle { height: min(3, max(1, ceil(root.display-subject.character-count * 10.5px / max(1px, self.width)))) * (self.font-metrics.ascent - self.font-metrics.descent); text: root.display-subject; color: root.title-color; - font-size: root.compact ? TypeScale.title : TypeScale.display; + font-size: root.compact ? TypeScale.title-small : TypeScale.title; font-weight: 600; + letter-spacing: -0.3px; wrap: word-wrap; } - Rectangle { height: 14px; } + Rectangle { height: 8px; } if root.selected-label-count > 0 : HorizontalBox { height: 24px; + padding: 0px; spacing: 6px; alignment: start; for label in root.mail_labels : MailLabelChip { @@ -610,12 +624,13 @@ export component MailReadingPane inherits Rectangle { } } - if root.selected-label-count > 0 : Rectangle { height: 10px; } + if root.selected-label-count > 0 : Rectangle { height: 8px; } HorizontalBox { min-height: 44px; preferred-height: 44px; max-height: 44px; + padding: 0px; spacing: 11px; cross-axis-alignment: center; Avatar { @@ -634,6 +649,7 @@ export component MailReadingPane inherits Rectangle { min-height: 44px; preferred-height: 44px; max-height: 44px; + padding: 0px; spacing: 0px; Rectangle { min-height: 24px; @@ -641,9 +657,9 @@ export component MailReadingPane inherits Rectangle { max-height: 24px; sender-name := Text { x: 0px; - y: 0px; + y: 2px; width: max(0px, parent.width - (root.selected_sender_verification != "" ? 26px : 0px)); - height: parent.height; + height: parent.height - self.y; text: root.selected_sender; color: root.heading; font-size: TypeScale.body-large; @@ -653,7 +669,7 @@ export component MailReadingPane inherits Rectangle { } if root.selected_sender_verification != "" : Image { x: min(sender-name.preferred-width + 8px, parent.width - 18px); - y: 3px; + y: 5px; width: 18px; height: 18px; source: @image-url("../icons/phosphor/check-circle-fill.svg"); @@ -668,15 +684,28 @@ export component MailReadingPane inherits Rectangle { : @tr("Sender domain authenticated with DMARC and DKIM"); } } - Text { + HorizontalBox { min-height: 20px; preferred-height: 20px; max-height: 20px; - text: root.selected_address + " · " + @tr("to {} · {}", root.selected_to, root.selected_time); - color: root.secondary-text; - font-size: TypeScale.secondary; - overflow: elide; - vertical-alignment: center; + padding: 0px; + spacing: 12px; + Text { + min-width: 0px; + horizontal-stretch: 1; + text: root.selected_address + " · " + @tr("to {}", root.selected_to); + color: root.secondary-text; + font-size: TypeScale.secondary; + overflow: elide; + vertical-alignment: center; + } + Text { + text: root.selected_time; + color: root.secondary-text; + font-size: TypeScale.secondary; + horizontal-alignment: right; + vertical-alignment: center; + } } } } @@ -686,27 +715,24 @@ export component MailReadingPane inherits Rectangle { Rectangle { height: 12px; } HorizontalBox { - height: root.compact ? 44px : 32px; padding: 0px; spacing: 4px; cross-axis-alignment: center; - ReaderControl { compact: root.compact; icon: @image-url("../icons/phosphor/magnifying-glass-minus.svg"); tooltip-text: @tr("Zoom out"); tooltip-align-left: true; interactive: EmailReader.available && EmailReader.zoom > 0.5; clicked => { EmailReader.command("zoom-out"); } } - if !root.compact : ReaderControl { compact: root.compact; icon: @image-url("../icons/phosphor/arrow-counter-clockwise.svg"); tooltip-text: @tr("Reset zoom ({}%)", round(EmailReader.zoom * 100)); tooltip-align-left: true; clicked => { EmailReader.command("zoom-reset"); } } - zoom-input := LineEdit { - in property applied-zoom: EmailReader.zoom; - in property zoom-revision: EmailReader.zoom-revision; - changed applied-zoom => { self.text = "\{round(self.applied-zoom * 100)}%"; } - changed zoom-revision => { self.text = "\{round(self.applied-zoom * 100)}%"; } - min-width: 62px; max-width: 62px; - height: root.compact ? 44px : 32px; - enabled: EmailReader.available; - input-label: @tr("Zoom percentage"); - text: "\{round(EmailReader.zoom * 100)}%"; - accepted(value) => { - EmailReader.command("zoom-set:" + value); - self.text = "\{round(EmailReader.zoom * 100)}%"; + height: root.compact ? LayoutMetrics.touch-target : LayoutMetrics.control-height; padding: 0px; spacing: 4px; cross-axis-alignment: center; + ZoomControls { compact: root.compact; available: EmailReader.available; zoom: EmailReader.zoom; revision: EmailReader.zoom-revision; fitting: EmailReader.auto-fit; command(action) => { EmailReader.command(action); } } + Rectangle { horizontal-stretch: 1; } + if root.attachment-menu-mode && MailAttachments.rows.length > 0 : ContextMenuArea { + width: root.compact ? LayoutMetrics.touch-target : LayoutMetrics.control-height; + height: root.compact ? LayoutMetrics.touch-target : LayoutMetrics.control-height; + ToolbarIconButton { + compact: root.compact; icon: @image-url("../icons/phosphor/paperclip.svg"); + icon-color: root.secondary-text; tooltip-text: @tr("Attachments"); + clicked => { parent.show({ x: 0px, y: parent.height }); } + } + Menu { + for attachment in MailAttachments.rows : MenuItem { + title: attachment.name; + activated => { MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id); } + } } } - ReaderControl { compact: root.compact; icon: @image-url("../icons/phosphor/magnifying-glass-plus.svg"); tooltip-text: @tr("Zoom in"); interactive: EmailReader.available && EmailReader.zoom < 3; clicked => { EmailReader.command("zoom-in"); } } - ReaderControl { compact: root.compact; icon: @image-url("../icons/phosphor/arrows-out-line-horizontal.svg"); tooltip-text: @tr("Fit to width"); clicked => { EmailReader.command("fit"); } } - Rectangle { horizontal-stretch: 1; } ReaderControl { compact: root.compact; icon: @image-url("../icons/phosphor/magnifying-glass.svg"); tooltip-text: @tr("Find in message"); active: EmailReader.find-open; clicked => { EmailReader.find-open = !EmailReader.find-open; if EmailReader.find-open { EmailReader.reader-mode = false; root.text_mode = false; root.source_mode = false; find-input.focus(); } } } ReaderControl { compact: root.compact; icon: EmailReader.reader-mode ? @image-url("../icons/phosphor/code.svg") : @image-url("../icons/phosphor/book-open-text.svg"); tooltip-text: EmailReader.reader-mode ? @tr("Show original layout") : @tr("Reader mode"); active: EmailReader.reader-mode; clicked => { EmailReader.reader-mode = !EmailReader.reader-mode; if EmailReader.reader-mode { root.text_mode = false; root.source_mode = false; EmailReader.find-open = false; } } } } @@ -1096,6 +1122,38 @@ export component MailReadingPane inherits Rectangle { } + if MailAttachments.rows.length > 0 && !root.attachment-menu-mode : VerticalBox { + height: 34px + min(168px, MailAttachments.rows.length * 48px); + vertical-stretch: 0; padding: 0px; padding-top: 8px; spacing: 6px; + Text { height: 20px; text: @tr("Attachments"); color: root.heading; font-weight: 600; } + // Keep access independent of the body's scroll position. + attachments-list := ListView { + viewport-width: self.visible-width; + mouse-drag-pan-enabled: root.compact; + for attachment in MailAttachments.rows : HorizontalBox { + width: attachments-list.visible-width; + height: 48px; padding: 0px; spacing: 8px; + attachment-focus := FocusScope { + min-width: 0px; horizontal-stretch: 1; + key-pressed(event) => { + if event.text == Key.Return || event.text == Key.Space { + MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id); return accept; + } + reject + } + SidebarItem { + width: parent.width; row-height: 44px; left-padding: 8px; + label: attachment.name; selected: attachment-focus.has-focus; + icon: @image-url("../icons/phosphor/paperclip.svg"); + sidebar-background: root.surface; selection-background: root.accent-soft; + accent-color: root.accent; body-color: root.heading; muted-color: root.secondary-text; + clicked(scope) => { attachment-focus.focus(); MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id); } + } + } + ToolbarIconButton { compact: root.compact; icon: @image-url("../icons/phosphor/download-simple.svg"); icon-color: AppTheme.secondary; tooltip-text: @tr("Download"); clicked => { MailAttachments.command("download", attachment.id); } } + } + } + } Text { height: EmailReader.destination == "" ? 0px : 20px; text: EmailReader.destination; overflow: elide; color: root.secondary-text; font-size: TypeScale.caption; diff --git a/ui/views/onboarding-view.slint b/ui/views/onboarding-view.slint index fc7bcaf..3004e10 100644 --- a/ui/views/onboarding-view.slint +++ b/ui/views/onboarding-view.slint @@ -1,4 +1,7 @@ -import { HorizontalBox, ScrollView, VerticalBox, Button as NativeButton } from "std-widgets.slint"; +import { FormField } from "../components/settings-components.slint"; +import { MailSecurityFields } from "../components/mail-security-fields.slint"; +import { OAuthBrowserHelp } from "../components/oauth-browser-help.slint"; +import { HorizontalBox, ScrollView, VerticalBox } from "std-widgets.slint"; import { Button } from "../components/button.slint"; import { LineEdit } from "../components/controls.slint"; import { ProviderButton } from "../components/provider-button.slint"; @@ -18,7 +21,18 @@ export component OnboardingView inherits Rectangle { in property oauth-settings-saving: false; in-out property oauth-settings-error: ""; in property startup-ready: false; + in property account-setup-in-progress: false; in property oauth-in-progress: false; + in property oauth-authorization-url; + in property oauth-browser-error; + callback retry-oauth-browser(); + callback copy-oauth-link(); + callback submit-oauth-redirect(string); + callback import-mail-certificate(); + in-out property imap-security: "auto"; + in-out property smtp-security: "auto"; + in-out property trusted-certificate-pem; + in-out property account-username; in property status: ""; in property page-bg; in property surface; @@ -54,28 +68,31 @@ export component OnboardingView inherits Rectangle { setup-scroll := ScrollView { width: parent.width; height: parent.height; + viewport-width: self.visible-width; + viewport-height: max(self.visible-height, setup-content.y + setup-content.height + 24px); setup-content := VerticalBox { x: max(20px, (setup-scroll.visible-width - 400px) / 2); y: max(24px, (setup-scroll.visible-height - self.preferred-height) / 2); width: min(400px, setup-scroll.visible-width - 40px); height: self.preferred-height; + padding: 0px; padding-bottom: 24px; spacing: 0px; HorizontalBox { - height: 62px; + height: root.imap-form-open ? 40px : 62px; Rectangle { horizontal-stretch: 1; } Image { - width: 62px; - height: 62px; + width: root.imap-form-open ? 40px : 62px; + height: root.imap-form-open ? 40px : 62px; source: @image-url("../../resources/app-icon/flectar-mail-masked.svg"); image-fit: contain; accessible-label: @tr("Flectar Mail logo"); } Rectangle { horizontal-stretch: 1; } } - Rectangle { height: 22px; } + Rectangle { height: root.imap-form-open ? 12px : 22px; } Text { text: @tr("Flectar Mail"); color: root.title-color; @@ -97,6 +114,7 @@ export component OnboardingView inherits Rectangle { } if root.startup-ready && !root.imap-form-open : VerticalBox { + padding: 0px; spacing: 10px; Rectangle { height: 6px; } Text { @@ -109,7 +127,7 @@ export component OnboardingView inherits Rectangle { ProviderButton { text: @tr("Continue with Gmail"); icon: @image-url("../icons/brand/gmail.svg"); - enabled: !root.oauth-in-progress && !root.oauth-settings-saving && root.google-available; + enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving && root.google-available; foreground: root.body-text; muted: root.muted-text; surface: root.surface; @@ -120,7 +138,7 @@ export component OnboardingView inherits Rectangle { ProviderButton { text: @tr("Continue with Outlook"); icon: @image-url("../icons/brand/outlook.svg"); - enabled: !root.oauth-in-progress && !root.oauth-settings-saving && root.microsoft-available; + enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving && root.microsoft-available; foreground: root.body-text; muted: root.muted-text; surface: root.surface; @@ -134,7 +152,7 @@ export component OnboardingView inherits Rectangle { colorize-icon: true; // Optical correction for the asymmetric mail-outline glyph. icon-offset-x: 1px; - enabled: !root.oauth-in-progress && !root.oauth-settings-saving; + enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving; foreground: root.body-text; muted: root.muted-text; surface: root.surface; @@ -150,14 +168,14 @@ export component OnboardingView inherits Rectangle { color: root.muted-text; font-size: TypeScale.caption; horizontal-alignment: center; - wrap: word-wrap; + wrap: word-wrap; height: self.preferred-height; } - oauth-settings := NativeButton { - height: 36px; + oauth-settings := Button { + height: 40px; text: @tr("Sign-in settings"); icon: @image-url("../icons/solar-icons/settings-outline.svg"); - colorize-icon: true; - enabled: !root.oauth-in-progress && !root.oauth-settings-saving; + has-icon: true; + enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving; clicked => { root.oauth-setup-open = true; } } Rectangle { height: 8px; } @@ -168,13 +186,19 @@ export component OnboardingView inherits Rectangle { horizontal-alignment: center; } Button { - height: 42px; + height: 40px; text: @tr("Import a backup"); icon: @image-url("../icons/phosphor/tray-arrow-down.svg"); has-icon: true; - enabled: !root.oauth-in-progress && !root.oauth-settings-saving; + enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving; clicked => { root.import-account-backup(); } } + if root.oauth-in-progress && root.oauth-authorization-url != "" : OAuthBrowserHelp { + launch-failed: root.oauth-browser-error; + retry => { root.retry-oauth-browser(); } + copy-link => { root.copy-oauth-link(); } + submit-redirect(address) => { root.submit-oauth-redirect(address); } + } if root.oauth-in-progress : Button { height: 40px; text: @tr("Cancel sign-in"); @@ -183,6 +207,7 @@ export component OnboardingView inherits Rectangle { } if root.startup-ready && root.imap-form-open : VerticalBox { + padding: 0px; spacing: 10px; Rectangle { height: 6px; } Text { @@ -193,32 +218,50 @@ export component OnboardingView inherits Rectangle { horizontal-alignment: center; } Text { - text: root.account-protocol == "jmap" ? @tr("Connect using JMAP for mail sync and sending.") : @tr("Connect using IMAP for mail sync and SMTP for sending."); + text: root.account-protocol == "jmap" ? @tr("Connect using JMAP for mail sync and sending.") : @tr("Connect using IMAP and SMTP. POP3 is not supported."); + wrap: word-wrap; height: self.preferred-height; color: root.muted-text; font-size: TypeScale.secondary; horizontal-alignment: center; } Rectangle { height: 8px; } - LineEdit { - height: 42px; - text: root.account-email; - placeholder-text: @tr("Email address"); + FormField { + enabled: !root.account-setup-in-progress; + height: 60px; + label: @tr("Email address"); + label-color: root.muted-text; + value: root.account-email; + placeholder: @tr("Email address"); edited(value) => { root.account-email = value; } } - LineEdit { - height: 42px; - text: root.account-password; + FormField { + enabled: !root.account-setup-in-progress; + height: 60px; + label: @tr("Username (defaults to email)"); + label-color: root.muted-text; + value: root.account-username; + placeholder: @tr("Username (defaults to email)"); + edited(value) => { root.account-username = value; } + } + FormField { + enabled: !root.account-setup-in-progress; + height: 60px; + label: @tr("Password or app password"); + label-color: root.muted-text; + value: root.account-password; input-type: password; - placeholder-text: @tr("Password or app password"); + placeholder: @tr("Password or app password"); edited(value) => { root.account-password = value; } } HorizontalBox { height: 42px; + padding: 0px; spacing: 8px; Button { horizontal-stretch: 1; height: 42px; text: @tr("IMAP + SMTP"); + enabled: !root.account-setup-in-progress; primary: root.account-protocol == "imap"; clicked => { root.account-protocol = "imap"; } } @@ -226,6 +269,7 @@ export component OnboardingView inherits Rectangle { horizontal-stretch: 1; height: 42px; text: @tr("JMAP"); + enabled: !root.account-setup-in-progress; primary: root.account-protocol == "jmap"; clicked => { root.account-protocol = "jmap"; } } @@ -240,53 +284,78 @@ export component OnboardingView inherits Rectangle { text: @tr("Leave blank to discover /.well-known/jmap from your email domain."); color: root.muted-text; font-size: TypeScale.caption; - wrap: word-wrap; + wrap: word-wrap; height: self.preferred-height; } if root.account-protocol == "imap" : HorizontalBox { - height: 42px; + height: 60px; + padding: 0px; spacing: 8px; - LineEdit { + FormField { + enabled: !root.account-setup-in-progress; horizontal-stretch: 1; - text: root.imap-host; - placeholder-text: @tr("IMAP server"); + label: @tr("IMAP server"); + label-color: root.muted-text; + value: root.imap-host; + placeholder: @tr("IMAP server"); edited(value) => { root.imap-host = value; } } - LineEdit { + FormField { + enabled: !root.account-setup-in-progress; width: 82px; - text: root.imap-port; + label: @tr("Port"); + label-color: root.muted-text; + value: root.imap-port; input-type: number; - placeholder-text: @tr("993"); + placeholder: @tr("993"); edited(value) => { root.imap-port = value; } } } if root.account-protocol == "imap" : HorizontalBox { - height: 42px; + height: 60px; + padding: 0px; spacing: 8px; - LineEdit { + FormField { + enabled: !root.account-setup-in-progress; horizontal-stretch: 1; - text: root.smtp-host; - placeholder-text: @tr("SMTP server"); + label: @tr("SMTP server"); + label-color: root.muted-text; + value: root.smtp-host; + placeholder: @tr("SMTP server"); edited(value) => { root.smtp-host = value; } } - LineEdit { + FormField { + enabled: !root.account-setup-in-progress; width: 82px; - text: root.smtp-port; + label: @tr("Port"); + label-color: root.muted-text; + value: root.smtp-port; input-type: number; - placeholder-text: @tr("465"); + placeholder: @tr("465"); edited(value) => { root.smtp-port = value; } } } + if root.account-protocol == "imap" : Text { + text: @tr("Proton Bridge: use the local servers, ports and password shown in Bridge, and match its encryption settings."); + color: root.muted-text; font-size: TypeScale.caption; wrap: word-wrap; height: self.preferred-height; + } + if root.account-protocol == "imap" : MailSecurityFields { + imap-security <=> root.imap-security; + smtp-security <=> root.smtp-security; + enabled: !root.account-setup-in-progress; + certificate-pem <=> root.trusted-certificate-pem; + import-certificate => { root.import-mail-certificate(); } + } Rectangle { height: 2px; } Button { height: 42px; - text: @tr("Connect account"); + text: root.account-setup-in-progress ? @tr("Connecting…") : @tr("Connect account"); primary: true; - enabled: root.imap-form-valid; + enabled: root.imap-form-valid && !root.account-setup-in-progress; clicked => { root.add-password-account( root.account-protocol, root.account-email, - root.account-email, + root.account-username, root.account-password, root.jmap-url, root.imap-host, @@ -299,19 +368,22 @@ export component OnboardingView inherits Rectangle { Button { height: 40px; text: @tr("Back"); + enabled: !root.account-setup-in-progress; clicked => { root.imap-form-open = false; } } } - Text { - visible: root.startup-ready && root.status != ""; - height: self.visible ? 40px : 0px; + if root.startup-ready && root.status != "" : VerticalBox { + padding: 0px; + padding-top: 12px; + Text { text: root.status; color: root.muted-text; font-size: TypeScale.caption; horizontal-alignment: center; vertical-alignment: center; - wrap: word-wrap; + wrap: word-wrap; height: self.preferred-height; + } } } }