diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..eb987a6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# CODEOWNERS only takes effect when branch or ruleset settings require code-owner review. +/.github/workflows/ @Knownassa +/src-tauri/tauri.conf.json @Knownassa +/src-tauri/capabilities/ @Knownassa +/src-tauri/Cargo.toml @Knownassa +/src-tauri/Cargo.lock @Knownassa +/package.json @Knownassa +/bun.lock @Knownassa +/src/features/updater/ @Knownassa diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ee4e899 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: Knownassa diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index cc84d3a..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Build release artifacts - -on: - workflow_dispatch: - push: - tags: ["v*"] - -jobs: - bundle: - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-22.04 - bundles: deb,appimage - - os: windows-latest - bundles: nsis,msi - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - uses: dtolnay/rust-toolchain@stable - - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - - run: bun install --frozen-lockfile - - run: bun run tauri build --bundles ${{ matrix.bundles }} - - uses: actions/upload-artifact@v4 - with: - name: fontsequal-${{ runner.os }} - path: src-tauri/target/release/bundle/** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a68385..abbabb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,12 @@ name: CI on: push: + branches: [main] pull_request: +permissions: + contents: read + jobs: test: strategy: @@ -12,14 +16,14 @@ jobs: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + # Pin actions to full commit SHAs before merging this workflow. + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - run: bun install --frozen-lockfile - run: bun run typecheck - - run: bun run lint - run: bun run test - run: bun run build - working-directory: src-tauri @@ -27,4 +31,4 @@ jobs: - working-directory: src-tauri run: cargo test - working-directory: src-tauri - run: cargo clippy -- -D warnings + run: cargo clippy --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5d5b010 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Draft signed release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish-tauri: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + environment: production-release + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - name: Linux x86_64 + os: ubuntu-22.04 + rust_target: "" + args: "" + - name: Windows x86_64 + os: windows-latest + rust_target: "" + args: "" + - name: macOS x86_64 + os: macos-latest + rust_target: x86_64-apple-darwin + args: --target x86_64-apple-darwin + - name: macOS aarch64 + os: macos-latest + rust_target: aarch64-apple-darwin + args: --target aarch64-apple-darwin + steps: + # Pin every third-party action below to a reviewed full commit SHA before merging. + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + - uses: swatinem/rust-cache@v2 + with: + workspaces: ./src-tauri -> target + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + - run: bun install --frozen-lockfile + - name: Build, sign, and upload draft assets + uses: tauri-apps/tauri-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + tagName: ${{ github.ref_name }} + releaseName: Fontsequal ${{ github.ref_name }} + releaseDraft: true + prerelease: false + args: ${{ matrix.args }} + - name: Verify generated updater signatures + shell: bash + run: | + set -euo pipefail + count=0 + while IFS= read -r -d '' artifact; do + count=$((count + 1)) + test -s "${artifact}.sig" + done < <(find src-tauri/target -type f \( -name '*.AppImage' -o -name '*.app.tar.gz' -o -name '*.exe' -o -name '*.msi' \) -print0) + test "$count" -gt 0 + + verify-release-metadata: + needs: publish-tauri + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate the draft updater metadata + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + mkdir release-metadata + gh release download "$TAG" --pattern latest.json --dir release-metadata --clobber + latest_json=release-metadata/latest.json + test -s "$latest_json" + version="${TAG#v}" + jq -e --arg version "$version" '.version == $version' "$latest_json" + prefix="https://github.com/Knownassa/Fontsequal/releases/download/${TAG}/" + for platform in linux-x86_64 windows-x86_64 darwin-x86_64 darwin-aarch64; do + jq -e --arg platform "$platform" ' + .platforms[$platform].signature | type == "string" and length > 0 + ' "$latest_json" + jq -e --arg platform "$platform" --arg prefix "$prefix" ' + .platforms[$platform].url | type == "string" and startswith($prefix) + ' "$latest_json" + done diff --git a/.gitignore b/.gitignore index 168d3c1..35bdf44 100755 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ node_modules dist dist-ssr *.local +*.key +*.pem +.env +.env.* .tmp src-tauri/target diff --git a/SECURITY_RELEASE.md b/SECURITY_RELEASE.md new file mode 100644 index 0000000..3285008 --- /dev/null +++ b/SECURITY_RELEASE.md @@ -0,0 +1,22 @@ +# Secure release operations + +Only `@Knownassa`, or a maintainer explicitly delegated by that account, may approve the `production-release` environment and publish a Fontsequal release. + +## Updater signing key + +- Generate the Tauri updater key outside this repository and back up the private key in two separately protected, encrypted locations. +- Store `TAURI_SIGNING_PRIVATE_KEY` and its password only as `production-release` environment secrets. Never commit, log, email, chat, or provide the private key to an AI tool. +- The public key is allowed only in `src-tauri/tauri.conf.json`; do not fetch it at runtime. +- If the private key is suspected compromised, stop releases, revoke environment access, investigate affected draft releases, generate a replacement key, and plan a signed migration for already-installed versions. Do not publish a replacement casually: existing clients trust the current public key. + +## GitHub protections + +Configure GitHub rulesets to require pull requests, passing CI, and code-owner review for `main` and the files listed in `.github/CODEOWNERS`. Block force-pushes and deletion. + +Restrict creation, updates, and deletion of `v*` tags to release maintainers. Configure `production-release` to permit protected tags only, require a maintainer review, and prevent bypass where the GitHub plan supports it. + +## Draft verification + +The release workflow creates drafts only. Before publishing, verify every platform entry in `latest.json`, the release version and notes, non-empty signatures, and the matching uploaded files. Treat release assets as immutable once published. + +All workflow actions are currently referenced by maintained version tags. Before merging a release workflow change, replace every third-party action reference with a reviewed full commit SHA and document the reviewed source. diff --git a/bun.lock b/bun.lock index 8c65fdb..62c185a 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,8 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-process": "~2", + "@tauri-apps/plugin-updater": "~2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.468.0", @@ -36,6 +38,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "bun-types": "^1.3.14", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", @@ -329,6 +332,10 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], + + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -367,6 +374,8 @@ "browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "caniuse-lite": ["caniuse-lite@1.0.30001802", "", {}, "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw=="], @@ -586,9 +595,5 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - - "tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], } } diff --git a/docs/UPDATER_TESTING.md b/docs/UPDATER_TESTING.md new file mode 100644 index 0000000..126b5db --- /dev/null +++ b/docs/UPDATER_TESTING.md @@ -0,0 +1,21 @@ +# Updater test checklist + +Run these checks against a private draft release or a dedicated test endpoint, never the stable public release. + +| Scenario | Expected result | +| --- | --- | +| Valid signed update | The app shows the version and notes, verifies the package, and updates only after the user chooses to do so. | +| Invalid signature | The update is rejected, no installer runs, and the app stays usable. | +| Modified artifact | Signature verification rejects the artifact. | +| Malformed `latest.json` | A safe metadata error is shown; nothing installs. | +| Missing platform entry | The static metadata is rejected; nothing installs. | +| GitHub unavailable | A safe connection error is shown and the app remains usable. | +| Interrupted download | The update fails safely and can be retried. | +| Same or older version | No downgrade or redundant update is offered. | +| Unsupported architecture | No update is offered for that target. | +| Corrupted installer | Verification or installation fails safely. | +| Relaunch failure | The installed update remains pending and a safe error is shown. | + +Perform the valid-update and signature-rejection tests separately on Windows x86_64, Linux x86_64 AppImage, macOS Intel, and macOS Apple Silicon. Also verify that fonts, collections, favorites, and SQLite data survive the update. + +The Bun tests in `src/features/updater/updater.service.test.ts` cover progress calculation, release-note normalization, and safe error handling. End-to-end installer and signature tests require real signed artifacts and remain manual. diff --git a/package.json b/package.json index e183d78..8559580 100755 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit", - "test": "tsc --noEmit" + "test": "bun test" }, "dependencies": { "@hugeicons/core-free-icons": "^4.2.2", @@ -30,6 +30,8 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-process": "~2", + "@tauri-apps/plugin-updater": "~2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.468.0", @@ -46,6 +48,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "bun-types": "^1.3.14", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f1ec9d..d5fb590 100755 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -59,6 +59,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -708,6 +717,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1004,6 +1024,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1044,6 +1074,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", "ttf-parser", ] @@ -1871,6 +1903,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2077,6 +2139,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2309,6 +2377,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2324,6 +2393,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2398,6 +2479,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2414,6 +2501,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2927,15 +3028,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3017,6 +3123,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -3027,6 +3145,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -3059,6 +3204,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3116,6 +3270,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3348,6 +3525,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3549,7 +3742,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3582,6 +3775,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3605,7 +3809,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3739,6 +3943,49 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3749,7 +3996,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3772,7 +4019,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4564,6 +4811,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -4821,6 +5077,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4854,13 +5119,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4891,6 +5173,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4903,6 +5191,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4915,12 +5209,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4933,6 +5239,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4945,6 +5257,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4957,6 +5275,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4969,6 +5293,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -5033,7 +5363,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5080,6 +5410,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5244,6 +5584,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dee9bbe..8070642 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,3 +22,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } sha2 = "0.10" ttf-parser = "0.25" + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-process = "2" +tauri-plugin-updater = "2" diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json new file mode 100644 index 0000000..ddf9aed --- /dev/null +++ b/src-tauri/capabilities/desktop.json @@ -0,0 +1,19 @@ +{ + "identifier": "desktop-capability", + "platforms": [ + "macOS", + "windows", + "linux" + ], + "windows": [ + "main" + ], + "permissions": [ + "updater:default", + "process:allow-restart", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-start-dragging" + ] +} diff --git a/src-tauri/src/commands/collections.rs b/src-tauri/src/commands/collections.rs index f9da2d9..6a88b0d 100755 --- a/src-tauri/src/commands/collections.rs +++ b/src-tauri/src/commands/collections.rs @@ -1,7 +1,9 @@ use crate::{ db::{repositories, DbState}, error::AppResult, - models::{ApiResult, Collection, CreateCollectionInput, FontCollectionInput, RenameCollectionInput}, + models::{ + ApiResult, Collection, CreateCollectionInput, FontCollectionInput, RenameCollectionInput, + }, }; use tauri::State; @@ -12,13 +14,24 @@ pub fn list_collections(db: State<'_, DbState>) -> AppResult, input: RenameCollectionInput) -> AppResult> { +pub fn rename_collection( + db: State<'_, DbState>, + input: RenameCollectionInput, +) -> AppResult> { let connection = db.connection()?; - Ok(ApiResult::ok(repositories::rename_collection(&connection, &input.collection_id, &input.name, input.description)?)) + Ok(ApiResult::ok(repositories::rename_collection( + &connection, + &input.collection_id, + &input.name, + input.description, + )?)) } #[tauri::command] -pub fn delete_collection(db: State<'_, DbState>, collection_id: String) -> AppResult> { +pub fn delete_collection( + db: State<'_, DbState>, + collection_id: String, +) -> AppResult> { let connection = db.connection()?; repositories::delete_collection(&connection, &collection_id)?; Ok(ApiResult::ok(())) diff --git a/src-tauri/src/commands/fonts.rs b/src-tauri/src/commands/fonts.rs index 474b5f2..388a050 100755 --- a/src-tauri/src/commands/fonts.rs +++ b/src-tauri/src/commands/fonts.rs @@ -2,9 +2,8 @@ use crate::{ db::{repositories, DbState}, error::AppResult, models::{ - mock_font_family, ApiResult, FontCategory, FontFamily, - InstallFontInput, InstallResult, ToggleFavoriteInput, UninstallFontInput, - UninstallResult, + mock_font_family, ApiResult, FontCategory, FontFamily, InstallFontInput, InstallResult, + ToggleFavoriteInput, UninstallFontInput, UninstallResult, }, }; use tauri::State; @@ -16,10 +15,15 @@ pub fn install_font( ) -> AppResult> { let connection = db.connection()?; if !matches!(&input.scope, crate::models::InstallScope::User) { - return Err(crate::error::AppError::new("system_install_unsupported", "Fontsequal installs fonts for current user only.")); + return Err(crate::error::AppError::new( + "system_install_unsupported", + "Fontsequal installs fonts for current user only.", + )); } - let family = repositories::get_font_family(&connection, &input.family_id)? - .ok_or_else(|| crate::error::AppError::new("font_not_found", "Font family was not found."))?; + let family = + repositories::get_font_family(&connection, &input.family_id)?.ok_or_else(|| { + crate::error::AppError::new("font_not_found", "Font family was not found.") + })?; let result = crate::fonts::installer::install_font(&family, input.variant_ids.as_deref())?; for file in &result.installed_files { @@ -39,8 +43,14 @@ pub fn uninstall_font( input: UninstallFontInput, ) -> AppResult> { let connection = db.connection()?; - if matches!(input.scope.as_ref(), Some(crate::models::InstallScope::System)) { - return Err(crate::error::AppError::new("system_uninstall_unsupported", "Fontsequal removes managed user fonts only.")); + if matches!( + input.scope.as_ref(), + Some(crate::models::InstallScope::System) + ) { + return Err(crate::error::AppError::new( + "system_uninstall_unsupported", + "Fontsequal removes managed user fonts only.", + )); } let mut managed_files = repositories::list_installed_fonts(&connection)? .into_iter() @@ -51,7 +61,10 @@ pub fn uninstall_font( managed_files.sort_by(|left, right| left.id.cmp(&right.id)); managed_files.dedup_by(|left, right| left.id == right.id); if managed_files.is_empty() { - return Err(crate::error::AppError::new("external_font_protected", "External and system fonts cannot be uninstalled from Fontsequal.")); + return Err(crate::error::AppError::new( + "external_font_protected", + "External and system fonts cannot be uninstalled from Fontsequal.", + )); } let removed_files = crate::fonts::uninstaller::uninstall_managed_files(&managed_files)?; // DB mutation follows successful file removal and cache refresh only. diff --git a/src-tauri/src/commands/import_fonts.rs b/src-tauri/src/commands/import_fonts.rs index ef33ad0..8a075b9 100644 --- a/src-tauri/src/commands/import_fonts.rs +++ b/src-tauri/src/commands/import_fonts.rs @@ -1,13 +1,30 @@ -use crate::{db::{repositories, DbState}, error::{AppError, AppResult}, models::{ApiResult, ImportLocalFontsInput, InstallScope}}; +use crate::{ + db::{repositories, DbState}, + error::{AppError, AppResult}, + models::{ApiResult, ImportLocalFontsInput, InstallScope}, +}; use std::collections::HashSet; use tauri::State; #[tauri::command] -pub fn import_local_fonts(db: State<'_, DbState>, input: ImportLocalFontsInput) -> AppResult>> { - if !matches!(&input.scope, InstallScope::User) { return Err(AppError::new("system_import_unsupported", "Fontsequal imports into current user library only.")); } +pub fn import_local_fonts( + db: State<'_, DbState>, + input: ImportLocalFontsInput, +) -> AppResult>> { + if !matches!(&input.scope, InstallScope::User) { + return Err(AppError::new( + "system_import_unsupported", + "Fontsequal imports into current user library only.", + )); + } let connection = db.connection()?; - let known_hashes = repositories::list_installed_fonts(&connection)?.into_iter().flat_map(|font| font.files).filter_map(|file| file.checksum).collect::>(); - let (records, skipped) = crate::fonts::importer::import_local_fonts(&input.paths, &known_hashes)?; + let known_hashes = repositories::list_installed_fonts(&connection)? + .into_iter() + .flat_map(|font| font.files) + .filter_map(|file| file.checksum) + .collect::>(); + let (records, skipped) = + crate::fonts::importer::import_local_fonts(&input.paths, &known_hashes)?; let mut result = skipped; for record in records { let family = crate::fonts::importer::family_for_import(&record); diff --git a/src-tauri/src/commands/unified_fonts.rs b/src-tauri/src/commands/unified_fonts.rs index d2eea3a..10d32bb 100644 --- a/src-tauri/src/commands/unified_fonts.rs +++ b/src-tauri/src/commands/unified_fonts.rs @@ -1,29 +1,59 @@ -use crate::{db::{repositories, DbState}, error::{AppError, AppResult}, models::{ApiResult, UnifiedFont}, providers::registry::ProviderRegistry}; +use crate::{ + db::{repositories, DbState}, + error::{AppError, AppResult}, + models::{ApiResult, UnifiedFont}, + providers::registry::ProviderRegistry, +}; use std::collections::BTreeMap; use tauri::State; #[tauri::command] pub fn list_unified_fonts(db: State<'_, DbState>) -> AppResult>> { let connection = db.connection()?; - Ok(ApiResult::ok(unify(repositories::list_font_families(&connection)?))) + Ok(ApiResult::ok(unify(repositories::list_font_families( + &connection, + )?))) } #[tauri::command] -pub fn search_unified_fonts(db: State<'_, DbState>, query: String, limit: Option) -> AppResult>> { +pub fn search_unified_fonts( + db: State<'_, DbState>, + query: String, + limit: Option, +) -> AppResult>> { let connection = db.connection()?; let needle = query.trim().to_lowercase(); - let mut fonts = unify(repositories::list_font_families(&connection)?).into_iter().filter(|font| needle.is_empty() || font.family.to_lowercase().contains(&needle)).collect::>(); - if let Some(limit) = limit { fonts.truncate(limit); } + let mut fonts = unify(repositories::list_font_families(&connection)?) + .into_iter() + .filter(|font| needle.is_empty() || font.family.to_lowercase().contains(&needle)) + .collect::>(); + if let Some(limit) = limit { + fonts.truncate(limit); + } Ok(ApiResult::ok(fonts)) } #[tauri::command] -pub fn refresh_font_provider(db: State<'_, DbState>, provider_id: String) -> AppResult> { +pub fn refresh_font_provider( + db: State<'_, DbState>, + provider_id: String, +) -> AppResult> { match provider_id.as_str() { - "system" => { let connection = db.connection()?; let scanned = crate::fonts::scanner::scan_system_fonts(); repositories::upsert_scanned_fonts(&connection, &scanned)?; Ok(ApiResult::ok(())) } - "google" => Err(AppError::new("provider_refresh_requires_google_command", "Use refresh_google_fonts_cache for Google provider.")), + "system" => { + let connection = db.connection()?; + let scanned = crate::fonts::scanner::scan_system_fonts(); + repositories::upsert_scanned_fonts(&connection, &scanned)?; + Ok(ApiResult::ok(())) + } + "google" => Err(AppError::new( + "provider_refresh_requires_google_command", + "Use refresh_google_fonts_cache for Google provider.", + )), "managed" | "bunny" | "fontsource" => Ok(ApiResult::ok(())), - _ => Err(AppError::new("provider_not_found", "Unknown font provider.")), + _ => Err(AppError::new( + "provider_not_found", + "Unknown font provider.", + )), } } @@ -41,8 +71,17 @@ fn unify(families: Vec) -> Vec { for family in families { let unified = UnifiedFont::from_family(family); match grouped.get_mut(&unified.normalized_family) { - Some(existing) => { existing.sources.extend(unified.sources); existing.is_installed |= unified.is_installed; existing.is_managed |= unified.is_managed; existing.is_readonly &= unified.is_readonly; existing.variants.extend(unified.variants); existing.files.extend(unified.files); } - None => { grouped.insert(unified.normalized_family.clone(), unified); } + Some(existing) => { + existing.sources.extend(unified.sources); + existing.is_installed |= unified.is_installed; + existing.is_managed |= unified.is_managed; + existing.is_readonly &= unified.is_readonly; + existing.variants.extend(unified.variants); + existing.files.extend(unified.files); + } + None => { + grouped.insert(unified.normalized_family.clone(), unified); + } } } grouped.into_values().collect() diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 68d0bb8..32c0822 100755 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -117,10 +117,26 @@ pub fn run_migrations(connection: &Connection) -> AppResult<()> { )?; } - let family_provider_exists: bool = connection.prepare("PRAGMA table_info(font_families)")?.query_map([], |row| row.get::<_, String>(1))?.filter_map(Result::ok).any(|column| column == "provider_id"); - if !family_provider_exists { connection.execute_batch("ALTER TABLE font_families ADD COLUMN provider_id TEXT NOT NULL DEFAULT 'google';")?; } - let installed_provider_exists: bool = connection.prepare("PRAGMA table_info(installed_fonts)")?.query_map([], |row| row.get::<_, String>(1))?.filter_map(Result::ok).any(|column| column == "provider_id"); - if !installed_provider_exists { connection.execute_batch("ALTER TABLE installed_fonts ADD COLUMN provider_id TEXT NOT NULL DEFAULT 'managed';")?; } + let family_provider_exists: bool = connection + .prepare("PRAGMA table_info(font_families)")? + .query_map([], |row| row.get::<_, String>(1))? + .filter_map(Result::ok) + .any(|column| column == "provider_id"); + if !family_provider_exists { + connection.execute_batch( + "ALTER TABLE font_families ADD COLUMN provider_id TEXT NOT NULL DEFAULT 'google';", + )?; + } + let installed_provider_exists: bool = connection + .prepare("PRAGMA table_info(installed_fonts)")? + .query_map([], |row| row.get::<_, String>(1))? + .filter_map(Result::ok) + .any(|column| column == "provider_id"); + if !installed_provider_exists { + connection.execute_batch( + "ALTER TABLE installed_fonts ADD COLUMN provider_id TEXT NOT NULL DEFAULT 'managed';", + )?; + } connection.execute_batch("INSERT OR IGNORE INTO font_sources (id, label, kind, readonly) VALUES ('system','System','system',1), ('managed','Managed','managed',0), ('google','Google','remote',0), ('bunny','Bunny','remote',0), ('fontsource','Fontsource','remote',0);")?; connection.pragma_update(None, "user_version", LATEST_SCHEMA_VERSION)?; diff --git a/src-tauri/src/db/repositories.rs b/src-tauri/src/db/repositories.rs index bdf32e9..97c5d41 100755 --- a/src-tauri/src/db/repositories.rs +++ b/src-tauri/src/db/repositories.rs @@ -3,8 +3,7 @@ use crate::{ models::{ mock_collections, mock_google_fonts, mock_installed_fonts, mock_settings, AppSettings, Collection, CreateCollectionInput, FontCategory, FontFamily, FontFile, FontFileFormat, - FontSource, FontStyle, FontVariant, InstallScope, InstalledFont, - UpdateSettingsInput, + FontSource, FontStyle, FontVariant, InstallScope, InstalledFont, UpdateSettingsInput, }, }; use rusqlite::{params, Connection, OptionalExtension}; @@ -410,7 +409,12 @@ pub fn upsert_collection(connection: &Connection, collection: &Collection) -> Ap Ok(()) } -pub fn rename_collection(connection: &Connection, collection_id: &str, name: &str, description: Option) -> AppResult { +pub fn rename_collection( + connection: &Connection, + collection_id: &str, + name: &str, + description: Option, +) -> AppResult { connection.execute( "UPDATE collections SET name = ?1, description = ?2, updated_at = ?3 WHERE id = ?4", params![name.trim(), description, MOCK_NOW, collection_id], @@ -419,8 +423,14 @@ pub fn rename_collection(connection: &Connection, collection_id: &str, name: &st } pub fn delete_collection(connection: &Connection, collection_id: &str) -> AppResult<()> { - connection.execute("DELETE FROM collection_fonts WHERE collection_id = ?1", params![collection_id])?; - connection.execute("DELETE FROM collections WHERE id = ?1", params![collection_id])?; + connection.execute( + "DELETE FROM collection_fonts WHERE collection_id = ?1", + params![collection_id], + )?; + connection.execute( + "DELETE FROM collections WHERE id = ?1", + params![collection_id], + )?; Ok(()) } diff --git a/src-tauri/src/fonts/hash.rs b/src-tauri/src/fonts/hash.rs index 9560da9..d22ba4d 100644 --- a/src-tauri/src/fonts/hash.rs +++ b/src-tauri/src/fonts/hash.rs @@ -1,5 +1,9 @@ use sha2::{Digest, Sha256}; -use std::{fs::File, io::{BufReader, Read}, path::Path}; +use std::{ + fs::File, + io::{BufReader, Read}, + path::Path, +}; pub fn sha256_file(path: &Path) -> std::io::Result { let file = File::open(path)?; @@ -9,7 +13,9 @@ pub fn sha256_file(path: &Path) -> std::io::Result { loop { let bytes_read = reader.read(&mut buffer)?; - if bytes_read == 0 { break; } + if bytes_read == 0 { + break; + } hasher.update(&buffer[..bytes_read]); } @@ -28,6 +34,9 @@ mod tests { #[test] fn generates_stable_sha256() { - assert_eq!(sha256_bytes(b"fontsequal"), "efce5ca6e4aecba23fcfa671cc3eeb867703d099492a41827aa0fd51a85b754e"); + assert_eq!( + sha256_bytes(b"fontsequal"), + "efce5ca6e4aecba23fcfa671cc3eeb867703d099492a41827aa0fd51a85b754e" + ); } } diff --git a/src-tauri/src/fonts/importer.rs b/src-tauri/src/fonts/importer.rs index 88826f6..9d5f405 100644 --- a/src-tauri/src/fonts/importer.rs +++ b/src-tauri/src/fonts/importer.rs @@ -1,21 +1,59 @@ -use crate::{error::{AppError, AppResult}, fonts::{hash::sha256_file, installer, normalizer::{as_family, normalize_scanned_font}, parser::parse_font}, models::{FontFile, FontFileFormat, FontSource, InstalledFont}}; -use std::{collections::HashSet, fs::{self, OpenOptions}, io::Write, path::Path}; +use crate::{ + error::{AppError, AppResult}, + fonts::{ + hash::sha256_file, + installer, + normalizer::{as_family, normalize_scanned_font}, + parser::parse_font, + }, + models::{FontFile, FontFileFormat, FontSource, InstalledFont}, +}; +use std::{ + collections::HashSet, + fs::{self, OpenOptions}, + io::Write, + path::Path, +}; -pub fn import_local_fonts(paths: &[String], known_hashes: &HashSet) -> AppResult<(Vec, Vec)> { +pub fn import_local_fonts( + paths: &[String], + known_hashes: &HashSet, +) -> AppResult<(Vec, Vec)> { let directory = installer::managed_font_directory()?; fs::create_dir_all(&directory)?; - let mut installed = Vec::new(); let mut skipped = Vec::new(); + let mut installed = Vec::new(); + let mut skipped = Vec::new(); for source in paths { let source = Path::new(source); let format = font_format(source)?; - let parsed = parse_font(source).map_err(|message| AppError::new("invalid_font", message))?; + let parsed = + parse_font(source).map_err(|message| AppError::new("invalid_font", message))?; let hash = sha256_file(source)?; - if known_hashes.contains(&hash) { skipped.push(skipped_file(source, format, hash)); continue; } - let target = directory.join(format!("{}-{}.{}", safe(&parsed.family), &hash[..12], extension(&format))); + if known_hashes.contains(&hash) { + skipped.push(skipped_file(source, format, hash)); + continue; + } + let target = directory.join(format!( + "{}-{}.{}", + safe(&parsed.family), + &hash[..12], + extension(&format) + )); let bytes = fs::read(source)?; - match OpenOptions::new().write(true).create_new(true).open(&target) { - Ok(mut file) => { file.write_all(&bytes)?; let mut record = normalize_scanned_font(&target, parsed, hash, true); record.source = FontSource::LocalImport; installed.push(record); } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => skipped.push(skipped_file(source, format, hash)), + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + { + Ok(mut file) => { + file.write_all(&bytes)?; + let mut record = normalize_scanned_font(&target, parsed, hash, true); + record.source = FontSource::LocalImport; + installed.push(record); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + skipped.push(skipped_file(source, format, hash)) + } Err(error) => return Err(error.into()), } } @@ -23,8 +61,58 @@ pub fn import_local_fonts(paths: &[String], known_hashes: &HashSet) -> A Ok((installed, skipped)) } -pub fn family_for_import(font: &InstalledFont) -> crate::models::FontFamily { as_family(font) } -fn font_format(path: &Path) -> AppResult { match path.extension().and_then(|value| value.to_str()).map(str::to_ascii_lowercase).as_deref() { Some("ttf") => Ok(FontFileFormat::Ttf), Some("otf") => Ok(FontFileFormat::Otf), _ => Err(AppError::new("unsupported_font", "Only .ttf and .otf files can be imported.")) } } -fn extension(format: &FontFileFormat) -> &'static str { match format { FontFileFormat::Otf => "otf", _ => "ttf" } } -fn skipped_file(path: &Path, format: FontFileFormat, hash: String) -> FontFile { FontFile { id: format!("import-skip-{}", &hash[..12]), family_id: "local-import".to_string(), variant_id: None, file_name: path.file_name().and_then(|value| value.to_str()).unwrap_or("font.ttf").to_string(), format, path: Some(path.to_string_lossy().into_owned()), url: None, checksum: Some(hash), size_bytes: path.metadata().ok().map(|metadata| metadata.len()) } } -fn safe(value: &str) -> String { value.chars().map(|value| if value.is_ascii_alphanumeric() { value.to_ascii_lowercase() } else { '-' }).collect::().trim_matches('-').to_string() } +pub fn family_for_import(font: &InstalledFont) -> crate::models::FontFamily { + as_family(font) +} +fn font_format(path: &Path) -> AppResult { + match path + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("ttf") => Ok(FontFileFormat::Ttf), + Some("otf") => Ok(FontFileFormat::Otf), + _ => Err(AppError::new( + "unsupported_font", + "Only .ttf and .otf files can be imported.", + )), + } +} +fn extension(format: &FontFileFormat) -> &'static str { + match format { + FontFileFormat::Otf => "otf", + _ => "ttf", + } +} +fn skipped_file(path: &Path, format: FontFileFormat, hash: String) -> FontFile { + FontFile { + id: format!("import-skip-{}", &hash[..12]), + family_id: "local-import".to_string(), + variant_id: None, + file_name: path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("font.ttf") + .to_string(), + format, + path: Some(path.to_string_lossy().into_owned()), + url: None, + checksum: Some(hash), + size_bytes: path.metadata().ok().map(|metadata| metadata.len()), + } +} +fn safe(value: &str) -> String { + value + .chars() + .map(|value| { + if value.is_ascii_alphanumeric() { + value.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} diff --git a/src-tauri/src/fonts/installer.rs b/src-tauri/src/fonts/installer.rs index 560f6bb..1318195 100644 --- a/src-tauri/src/fonts/installer.rs +++ b/src-tauri/src/fonts/installer.rs @@ -4,9 +4,16 @@ use crate::{ models::{FontFamily, FontFile, FontFileFormat, InstallResult, InstallScope, InstalledFont}, os, }; -use std::{fs::{self, OpenOptions}, io::Write, path::{Path, PathBuf}}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; -pub fn install_font(family: &FontFamily, variant_ids: Option<&[String]>) -> AppResult { +pub fn install_font( + family: &FontFamily, + variant_ids: Option<&[String]>, +) -> AppResult { let variants = select_variants(family, variant_ids)?; let managed_directory = managed_font_directory()?; fs::create_dir_all(&managed_directory)?; @@ -15,8 +22,16 @@ pub fn install_font(family: &FontFamily, variant_ids: Option<&[String]>) -> AppR let mut skipped_files = Vec::new(); for variant_id in variants { - let source = family.files.iter().find(|file| file.variant_id.as_deref() == Some(variant_id)) - .ok_or_else(|| AppError::new("font_file_missing", format!("Missing file for variant {variant_id}.")))?; + let source = family + .files + .iter() + .find(|file| file.variant_id.as_deref() == Some(variant_id)) + .ok_or_else(|| { + AppError::new( + "font_file_missing", + format!("Missing file for variant {variant_id}."), + ) + })?; let bytes = download_font_file(source)?; validate_font_file(&bytes).map_err(|message| AppError::new("invalid_font", message))?; let target = unique_target_path(&managed_directory, family, variant_id, source); @@ -28,7 +43,13 @@ pub fn install_font(family: &FontFamily, variant_ids: Option<&[String]>) -> AppR installed_files.push(file); } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - skipped_files.push(managed_file(family, variant_id, source, &target, sha256_bytes(&bytes))); + skipped_files.push(managed_file( + family, + variant_id, + source, + &target, + sha256_bytes(&bytes), + )); } Err(error) => return Err(error.into()), } @@ -46,65 +67,145 @@ pub fn install_font(family: &FontFamily, variant_ids: Option<&[String]>) -> AppR } pub fn installed_records(family: &FontFamily, files: &[FontFile]) -> Vec { - files.iter().map(|file| InstalledFont { - id: format!("installed-managed-{}", file.id), - family: family.family.clone(), - post_script_name: None, - full_name: Some(family.family.clone()), - category: family.category.clone(), - style: family.variants.iter().find(|variant| Some(&variant.id) == file.variant_id.as_ref()).map(|variant| variant.style.clone()).unwrap_or(crate::models::FontStyle::Normal), - weight: family.variants.iter().find(|variant| Some(&variant.id) == file.variant_id.as_ref()).map(|variant| variant.weight).unwrap_or(400), - source: family.source.clone(), - scope: InstallScope::User, - files: vec![file.clone()], - metadata: family.metadata.clone(), - installed_at: None, - is_managed: true, - is_duplicate: false, - }).collect() + files + .iter() + .map(|file| InstalledFont { + id: format!("installed-managed-{}", file.id), + family: family.family.clone(), + post_script_name: None, + full_name: Some(family.family.clone()), + category: family.category.clone(), + style: family + .variants + .iter() + .find(|variant| Some(&variant.id) == file.variant_id.as_ref()) + .map(|variant| variant.style.clone()) + .unwrap_or(crate::models::FontStyle::Normal), + weight: family + .variants + .iter() + .find(|variant| Some(&variant.id) == file.variant_id.as_ref()) + .map(|variant| variant.weight) + .unwrap_or(400), + source: family.source.clone(), + scope: InstallScope::User, + files: vec![file.clone()], + metadata: family.metadata.clone(), + installed_at: None, + is_managed: true, + is_duplicate: false, + }) + .collect() } pub fn download_font_file(file: &FontFile) -> AppResult> { - if let Some(path) = &file.path { return Ok(fs::read(path)?); } - let url = file.url.as_deref().ok_or_else(|| AppError::new("font_file_missing", "Font file has no local path or download URL."))?; + if let Some(path) = &file.path { + return Ok(fs::read(path)?); + } + let url = file.url.as_deref().ok_or_else(|| { + AppError::new( + "font_file_missing", + "Font file has no local path or download URL.", + ) + })?; let response = reqwest::blocking::get(url)?.error_for_status()?; let bytes = response.bytes()?.to_vec(); - if bytes.len() > 40 * 1024 * 1024 { return Err(AppError::new("font_file_too_large", "Font file exceeds 40 MB.")); } + if bytes.len() > 40 * 1024 * 1024 { + return Err(AppError::new( + "font_file_too_large", + "Font file exceeds 40 MB.", + )); + } Ok(bytes) } pub fn refresh_font_cache(directory: &Path) -> AppResult<()> { #[cfg(target_os = "windows")] - { os::windows::refresh_font_cache(directory)?; } + { + os::windows::refresh_font_cache(directory)?; + } #[cfg(target_os = "linux")] - { os::linux::refresh_font_cache(directory)?; } + { + os::linux::refresh_font_cache(directory)?; + } Ok(()) } pub fn managed_font_directory() -> AppResult { #[cfg(target_os = "windows")] - { return os::windows::managed_font_directory(); } + { + return os::windows::managed_font_directory(); + } #[cfg(target_os = "linux")] - { return os::linux::managed_font_directory(); } + { + return os::linux::managed_font_directory(); + } #[allow(unreachable_code)] - Err(AppError::new("unsupported_platform", "User font installation is unsupported on this platform.")) + Err(AppError::new( + "unsupported_platform", + "User font installation is unsupported on this platform.", + )) } -fn select_variants<'a>(family: &'a FontFamily, selected: Option<&'a [String]>) -> AppResult> { - let ids: Vec<&str> = selected.map(|ids| ids.iter().map(|id| id.as_str()).collect()).unwrap_or_else(|| family.variants.iter().map(|variant| variant.id.as_str()).collect()); - if ids.is_empty() { return Err(AppError::new("no_variants", "Choose at least one font variant.")); } +fn select_variants<'a>( + family: &'a FontFamily, + selected: Option<&'a [String]>, +) -> AppResult> { + let ids: Vec<&str> = selected + .map(|ids| ids.iter().map(|id| id.as_str()).collect()) + .unwrap_or_else(|| { + family + .variants + .iter() + .map(|variant| variant.id.as_str()) + .collect() + }); + if ids.is_empty() { + return Err(AppError::new( + "no_variants", + "Choose at least one font variant.", + )); + } Ok(ids) } -fn unique_target_path(directory: &Path, family: &FontFamily, variant_id: &str, source: &FontFile) -> PathBuf { - let extension = match source.format { FontFileFormat::Otf => "otf", _ => "ttf" }; +fn unique_target_path( + directory: &Path, + family: &FontFamily, + variant_id: &str, + source: &FontFile, +) -> PathBuf { + let extension = match source.format { + FontFileFormat::Otf => "otf", + _ => "ttf", + }; let safe = format!("{}-{}", safe_name(&family.family), safe_name(variant_id)); directory.join(format!("{safe}.{extension}")) } -fn managed_file(family: &FontFamily, variant_id: &str, source: &FontFile, path: &Path, checksum: String) -> FontFile { - let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("font.ttf").to_string(); - FontFile { id: format!("managed-{}-{}", family.id, safe_name(variant_id)), family_id: family.id.clone(), variant_id: Some(variant_id.to_string()), file_name, format: source.format.clone(), path: Some(path.to_string_lossy().into_owned()), url: None, checksum: Some(checksum), size_bytes: path.metadata().ok().map(|metadata| metadata.len()) } +fn managed_file( + family: &FontFamily, + variant_id: &str, + source: &FontFile, + path: &Path, + checksum: String, +) -> FontFile { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("font.ttf") + .to_string(); + FontFile { + id: format!("managed-{}-{}", family.id, safe_name(variant_id)), + family_id: family.id.clone(), + variant_id: Some(variant_id.to_string()), + file_name, + format: source.format.clone(), + path: Some(path.to_string_lossy().into_owned()), + url: None, + checksum: Some(checksum), + size_bytes: path.metadata().ok().map(|metadata| metadata.len()), + } } fn write_new_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { @@ -112,7 +213,20 @@ fn write_new_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { file.write_all(bytes) } -fn safe_name(value: &str) -> String { value.chars().map(|character| if character.is_ascii_alphanumeric() { character.to_ascii_lowercase() } else { '-' }).collect::().trim_matches('-').to_string() } +fn safe_name(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} #[cfg(test)] mod tests { diff --git a/src-tauri/src/fonts/mod.rs b/src-tauri/src/fonts/mod.rs index fff2219..5c5ffa2 100644 --- a/src-tauri/src/fonts/mod.rs +++ b/src-tauri/src/fonts/mod.rs @@ -1,6 +1,6 @@ pub mod hash; -pub mod installer; pub mod importer; +pub mod installer; pub mod normalizer; pub mod parser; pub mod scanner; diff --git a/src-tauri/src/fonts/normalizer.rs b/src-tauri/src/fonts/normalizer.rs index d335975..b6ce3f6 100644 --- a/src-tauri/src/fonts/normalizer.rs +++ b/src-tauri/src/fonts/normalizer.rs @@ -1,6 +1,9 @@ use crate::{ fonts::parser::ParsedFont, - models::{FontCategory, FontFamily, FontFile, FontFileFormat, FontMetadata, FontSource, FontVariant, InstallScope, InstalledFont}, + models::{ + FontCategory, FontFamily, FontFile, FontFileFormat, FontMetadata, FontSource, FontVariant, + InstallScope, InstalledFont, + }, }; use std::path::Path; @@ -13,7 +16,11 @@ pub fn normalize_scanned_font( let short_hash = &checksum[..12]; let family_id = format!("local-{}-{}", slug(&parsed.family), short_hash); let variant_id = format!("{}-{}", parsed.weight, style_label(&parsed.style)); - let file_name = path.file_name().and_then(|value| value.to_str()).unwrap_or("font.ttf").to_string(); + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("font.ttf") + .to_string(); let file = FontFile { id: format!("file-{short_hash}"), family_id: family_id.clone(), @@ -35,7 +42,11 @@ pub fn normalize_scanned_font( style: parsed.style.clone(), weight: parsed.weight, source: FontSource::Local, - scope: if is_managed { InstallScope::User } else { InstallScope::System }, + scope: if is_managed { + InstallScope::User + } else { + InstallScope::System + }, files: vec![file], metadata: Some(FontMetadata { designer: None, @@ -63,7 +74,10 @@ pub fn as_family(font: &InstalledFont) -> FontFamily { category: font.category.clone(), source: font.source.clone(), variants: vec![FontVariant { - id: file.variant_id.clone().unwrap_or_else(|| "regular".to_string()), + id: file + .variant_id + .clone() + .unwrap_or_else(|| "regular".to_string()), label: format!("{} {}", font.weight, style_label(&font.style)), weight: font.weight, style: font.style.clone(), @@ -79,7 +93,12 @@ pub fn as_family(font: &InstalledFont) -> FontFamily { } fn format_from_path(path: &Path) -> FontFileFormat { - match path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref() { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { Some("otf") => FontFileFormat::Otf, Some("ttf") => FontFileFormat::Ttf, _ => FontFileFormat::Unknown, @@ -87,15 +106,32 @@ fn format_from_path(path: &Path) -> FontFileFormat { } fn slug(value: &str) -> String { - value.chars().map(|character| if character.is_ascii_alphanumeric() { character.to_ascii_lowercase() } else { '-' }).collect::().trim_matches('-').to_string() + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() } fn style_label(style: &crate::models::FontStyle) -> &'static str { - match style { crate::models::FontStyle::Italic => "italic", crate::models::FontStyle::Oblique => "oblique", crate::models::FontStyle::Normal => "regular" } + match style { + crate::models::FontStyle::Italic => "italic", + crate::models::FontStyle::Oblique => "oblique", + crate::models::FontStyle::Normal => "regular", + } } fn stable_path_id(path: &Path) -> String { let mut value: u64 = 0xcbf29ce484222325; - for byte in path.to_string_lossy().as_bytes() { value = (value ^ u64::from(*byte)).wrapping_mul(0x100000001b3); } + for byte in path.to_string_lossy().as_bytes() { + value = (value ^ u64::from(*byte)).wrapping_mul(0x100000001b3); + } format!("{value:x}") } diff --git a/src-tauri/src/fonts/parser.rs b/src-tauri/src/fonts/parser.rs index 6edcf56..1c32174 100644 --- a/src-tauri/src/fonts/parser.rs +++ b/src-tauri/src/fonts/parser.rs @@ -17,7 +17,9 @@ pub fn parse_font(path: &Path) -> Result { } pub fn validate_font_file(bytes: &[u8]) -> Result<(), String> { - Face::parse(bytes, 0).map(|_| ()).map_err(|error| error.to_string()) + Face::parse(bytes, 0) + .map(|_| ()) + .map_err(|error| error.to_string()) } pub fn parse_font_bytes(bytes: &[u8], path: &Path) -> Result { @@ -27,7 +29,11 @@ pub fn parse_font_bytes(bytes: &[u8], path: &Path) -> Result .unwrap_or_else(|| fallback_family(path)); let full_name = name(&face, name_id::FULL_NAME); let post_script_name = name(&face, name_id::POST_SCRIPT_NAME); - let style = if face.is_italic() { FontStyle::Italic } else { FontStyle::Normal }; + let style = if face.is_italic() { + FontStyle::Italic + } else { + FontStyle::Normal + }; Ok(ParsedFont { family, diff --git a/src-tauri/src/fonts/scanner.rs b/src-tauri/src/fonts/scanner.rs index 7b800bf..accf054 100644 --- a/src-tauri/src/fonts/scanner.rs +++ b/src-tauri/src/fonts/scanner.rs @@ -3,19 +3,34 @@ use crate::{ models::InstalledFont, os, }; -use std::{collections::HashMap, fs, path::{Path, PathBuf}}; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; pub fn scan_system_fonts() -> Vec { let mut paths = HashMap::::new(); for (location, managed) in font_locations() { for path in font_files_in(&location) { - paths.entry(path).and_modify(|current| *current |= managed).or_insert(managed); + paths + .entry(path) + .and_modify(|current| *current |= managed) + .or_insert(managed); } } - let mut fonts = paths.into_iter().filter_map(|(path, managed)| { - Some(normalize_scanned_font(&path, parse_font(&path).ok()?, sha256_file(&path).ok()?, managed)) - }).collect::>(); + let mut fonts = paths + .into_iter() + .filter_map(|(path, managed)| { + Some(normalize_scanned_font( + &path, + parse_font(&path).ok()?, + sha256_file(&path).ok()?, + managed, + )) + }) + .collect::>(); let mut hash_counts = HashMap::::new(); for font in &fonts { @@ -25,7 +40,8 @@ pub fn scan_system_fonts() -> Vec { } for font in &mut fonts { let hash = font.files.first().and_then(|file| file.checksum.as_ref()); - font.is_duplicate = hash.is_some_and(|value| hash_counts.get(value).copied().unwrap_or_default() > 1); + font.is_duplicate = + hash.is_some_and(|value| hash_counts.get(value).copied().unwrap_or_default() > 1); } fonts.sort_by(|left, right| left.family.to_lowercase().cmp(&right.family.to_lowercase())); @@ -34,28 +50,47 @@ pub fn scan_system_fonts() -> Vec { fn font_locations() -> Vec<(PathBuf, bool)> { #[cfg(target_os = "windows")] - { os::windows::font_locations() } + { + os::windows::font_locations() + } #[cfg(target_os = "linux")] - { os::linux::font_locations() } + { + os::linux::font_locations() + } #[cfg(not(any(target_os = "windows", target_os = "linux")))] - { Vec::new() } + { + Vec::new() + } } fn font_files_in(root: &Path) -> Vec { - if !root.is_dir() { return Vec::new(); } + if !root.is_dir() { + return Vec::new(); + } let mut paths = Vec::new(); let mut directories = vec![root.to_path_buf()]; while let Some(directory) = directories.pop() { - let Ok(entries) = fs::read_dir(directory) else { continue; }; + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; for entry in entries.flatten() { let path = entry.path(); - if path.is_dir() { directories.push(path); } - else if is_supported_font(&path) { paths.push(path); } + if path.is_dir() { + directories.push(path); + } else if is_supported_font(&path) { + paths.push(path); + } } } paths } fn is_supported_font(path: &Path) -> bool { - matches!(path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("ttf") | Some("otf")) + matches!( + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("ttf") | Some("otf") + ) } diff --git a/src-tauri/src/fonts/uninstaller.rs b/src-tauri/src/fonts/uninstaller.rs index 68c1237..a6a978b 100644 --- a/src-tauri/src/fonts/uninstaller.rs +++ b/src-tauri/src/fonts/uninstaller.rs @@ -1,11 +1,20 @@ -use crate::{error::{AppError, AppResult}, fonts::installer, models::FontFile}; -use std::{fs, path::{Path, PathBuf}}; +use crate::{ + error::{AppError, AppResult}, + fonts::installer, + models::FontFile, +}; +use std::{ + fs, + path::{Path, PathBuf}, +}; pub fn uninstall_managed_files(files: &[FontFile]) -> AppResult> { let managed_root = installer::managed_font_directory()?; let mut removed = Vec::new(); for file in files { - let path = file.path.as_deref().ok_or_else(|| AppError::new("managed_path_missing", "Managed font record has no path."))?; + let path = file.path.as_deref().ok_or_else(|| { + AppError::new("managed_path_missing", "Managed font record has no path.") + })?; let path = validate_managed_path(&managed_root, Path::new(path))?; fs::remove_file(&path)?; removed.push(file.clone()); @@ -15,10 +24,20 @@ pub fn uninstall_managed_files(files: &[FontFile]) -> AppResult> { } pub fn validate_managed_path(root: &Path, candidate: &Path) -> AppResult { - let root = root.canonicalize().map_err(|_| AppError::new("managed_folder_missing", "Fontsequal managed folder is unavailable."))?; - let candidate = candidate.canonicalize().map_err(|_| AppError::new("font_path_missing", "Managed font file is unavailable."))?; + let root = root.canonicalize().map_err(|_| { + AppError::new( + "managed_folder_missing", + "Fontsequal managed folder is unavailable.", + ) + })?; + let candidate = candidate + .canonicalize() + .map_err(|_| AppError::new("font_path_missing", "Managed font file is unavailable."))?; if !candidate.starts_with(&root) { - return Err(AppError::new("unsafe_font_path", "Only Fontsequal managed font files may be removed.")); + return Err(AppError::new( + "unsafe_font_path", + "Only Fontsequal managed font files may be removed.", + )); } Ok(candidate) } @@ -26,10 +45,16 @@ pub fn validate_managed_path(root: &Path, candidate: &Path) -> AppResult PathBuf { - let stamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("time works").as_nanos(); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time works") + .as_nanos(); std::env::temp_dir().join(format!("fontsequal-safe-path-{stamp}")) } @@ -39,7 +64,10 @@ mod tests { fs::create_dir_all(&root).expect("root creates"); let file = root.join("managed.ttf"); fs::write(&file, b"font").expect("file writes"); - assert_eq!(validate_managed_path(&root, &file).expect("managed path allowed"), file.canonicalize().expect("canonical path")); + assert_eq!( + validate_managed_path(&root, &file).expect("managed path allowed"), + file.canonicalize().expect("canonical path") + ); fs::remove_dir_all(root).expect("root removes"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55247dd..0e84fa5 100755 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,14 +9,17 @@ mod providers; use commands::{ collections::{ - add_font_to_collection, create_collection, delete_collection, list_collections, remove_font_from_collection, rename_collection, + add_font_to_collection, create_collection, delete_collection, list_collections, + remove_font_from_collection, rename_collection, }, fonts::{install_font, toggle_favorite, uninstall_font}, google_fonts::{list_google_fonts, refresh_google_fonts_cache, search_google_fonts}, import_fonts::import_local_fonts, installed::{get_installed_fonts, scan_system_fonts}, settings::{get_settings, update_settings}, - unified_fonts::{list_unified_fonts, refresh_all_font_sources, refresh_font_provider, search_unified_fonts}, + unified_fonts::{ + list_unified_fonts, refresh_all_font_sources, refresh_font_provider, search_unified_fonts, + }, }; use db::DbState; use tauri::Manager; @@ -26,6 +29,13 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .setup(|app| { + #[cfg(desktop)] + { + app.handle().plugin(tauri_plugin_process::init())?; + app.handle() + .plugin(tauri_plugin_updater::Builder::new().build())?; + } + let db = DbState::initialize(&app.handle().clone())?; app.manage(db); Ok(()) @@ -47,11 +57,11 @@ pub fn run() { add_font_to_collection, remove_font_from_collection, get_settings, - update_settings - ,list_unified_fonts - ,search_unified_fonts - ,refresh_font_provider - ,refresh_all_font_sources + update_settings, + list_unified_fonts, + search_unified_fonts, + refresh_font_provider, + refresh_all_font_sources ]) .run(tauri::generate_context!()) .expect("error while running Fontsequal"); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 55481aa..8e0358d 100755 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -126,7 +126,11 @@ pub struct FontFamily { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub enum FontProviderKind { System, Managed, Remote } +pub enum FontProviderKind { + System, + Managed, + Remote, +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -156,16 +160,64 @@ pub struct UnifiedFont { impl UnifiedFont { pub fn from_family(font: FontFamily) -> Self { let (provider_id, label, kind, is_managed, is_readonly) = match font.source { - FontSource::Google => ("google".to_string(), "Google".to_string(), FontProviderKind::Remote, false, false), - FontSource::LocalImport => ("managed".to_string(), "Managed".to_string(), FontProviderKind::Managed, true, false), - FontSource::System => ("system".to_string(), "System".to_string(), FontProviderKind::System, false, true), - FontSource::Local => ("system".to_string(), "System".to_string(), FontProviderKind::System, false, true), + FontSource::Google => ( + "google".to_string(), + "Google".to_string(), + FontProviderKind::Remote, + false, + false, + ), + FontSource::LocalImport => ( + "managed".to_string(), + "Managed".to_string(), + FontProviderKind::Managed, + true, + false, + ), + FontSource::System => ( + "system".to_string(), + "System".to_string(), + FontProviderKind::System, + false, + true, + ), + FontSource::Local => ( + "system".to_string(), + "System".to_string(), + FontProviderKind::System, + false, + true, + ), }; - Self { id: font.id, normalized_family: normalize_family_name(&font.family), family: font.family, category: font.category, variants: font.variants, files: font.files, metadata: font.metadata, sources: vec![FontSourceBadge { provider_id, label, kind }], is_favorite: font.is_favorite, is_installed: font.is_installed, is_managed, is_readonly } + Self { + id: font.id, + normalized_family: normalize_family_name(&font.family), + family: font.family, + category: font.category, + variants: font.variants, + files: font.files, + metadata: font.metadata, + sources: vec![FontSourceBadge { + provider_id, + label, + kind, + }], + is_favorite: font.is_favorite, + is_installed: font.is_installed, + is_managed, + is_readonly, + } } } -pub fn normalize_family_name(value: &str) -> String { value.trim().to_lowercase().split_whitespace().collect::>().join(" ") } +pub fn normalize_family_name(value: &str) -> String { + value + .trim() + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" ") +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/os/linux.rs b/src-tauri/src/os/linux.rs index c87efe2..1856e51 100644 --- a/src-tauri/src/os/linux.rs +++ b/src-tauri/src/os/linux.rs @@ -1,5 +1,9 @@ use crate::error::{AppError, AppResult}; -use std::{env, path::{Path, PathBuf}, process::Command}; +use std::{ + env, + path::{Path, PathBuf}, + process::Command, +}; pub fn font_locations() -> Vec<(PathBuf, bool)> { let home = env::var_os("HOME").map(PathBuf::from); @@ -22,11 +26,23 @@ pub fn platform_name() -> &'static str { } pub fn managed_font_directory() -> AppResult { - let home = env::var_os("HOME").ok_or_else(|| AppError::new("home_missing", "HOME is required for user font installation."))?; + let home = env::var_os("HOME").ok_or_else(|| { + AppError::new( + "home_missing", + "HOME is required for user font installation.", + ) + })?; Ok(PathBuf::from(home).join(".local/share/fonts/fontsequal")) } pub fn refresh_font_cache(directory: &Path) -> AppResult<()> { let status = Command::new("fc-cache").arg(directory).status()?; - if status.success() { Ok(()) } else { Err(AppError::new("font_cache_failed", "fc-cache could not refresh Fontsequal font cache.")) } + if status.success() { + Ok(()) + } else { + Err(AppError::new( + "font_cache_failed", + "fc-cache could not refresh Fontsequal font cache.", + )) + } } diff --git a/src-tauri/src/os/windows.rs b/src-tauri/src/os/windows.rs index af70841..faa296a 100644 --- a/src-tauri/src/os/windows.rs +++ b/src-tauri/src/os/windows.rs @@ -1,5 +1,8 @@ use crate::error::{AppError, AppResult}; -use std::{env, path::{Path, PathBuf}}; +use std::{ + env, + path::{Path, PathBuf}, +}; pub fn font_locations() -> Vec<(PathBuf, bool)> { let mut locations = Vec::new(); @@ -22,7 +25,12 @@ pub fn platform_name() -> &'static str { } pub fn managed_font_directory() -> AppResult { - let local_app_data = env::var_os("LOCALAPPDATA").ok_or_else(|| AppError::new("local_app_data_missing", "LOCALAPPDATA is required for user font installation."))?; + let local_app_data = env::var_os("LOCALAPPDATA").ok_or_else(|| { + AppError::new( + "local_app_data_missing", + "LOCALAPPDATA is required for user font installation.", + ) + })?; Ok(PathBuf::from(local_app_data).join("Fontsequal/fonts")) } @@ -32,15 +40,36 @@ pub fn refresh_font_cache(directory: &Path) -> AppResult<()> { if let Ok(entries) = std::fs::read_dir(directory) { for entry in entries.flatten() { let path = entry.path(); - let supported = matches!(path.extension().and_then(|value| value.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("ttf") | Some("otf")); + let supported = matches!( + path.extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("ttf") | Some("otf") + ); if supported { - let wide_path = path.as_os_str().encode_wide().chain(Some(0)).collect::>(); - unsafe { let _ = AddFontResourceExW(wide_path.as_ptr(), FR_PRIVATE, std::ptr::null_mut()); } + let wide_path = path + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + unsafe { + let _ = + AddFontResourceExW(wide_path.as_ptr(), FR_PRIVATE, std::ptr::null_mut()); + } } } } unsafe { - let _ = SendMessageTimeoutW(HWND_BROADCAST, WM_FONTCHANGE, 0, 0, SMTO_ABORTIFHUNG, 1000, std::ptr::null_mut()); + let _ = SendMessageTimeoutW( + HWND_BROADCAST, + WM_FONTCHANGE, + 0, + 0, + SMTO_ABORTIFHUNG, + 1000, + std::ptr::null_mut(), + ); } Ok(()) } @@ -55,7 +84,19 @@ const WM_FONTCHANGE: u32 = 0x001d; const SMTO_ABORTIFHUNG: u32 = 0x0002; #[cfg(target_os = "windows")] #[link(name = "gdi32")] -extern "system" { fn AddFontResourceExW(path: *const u16, flags: u32, reserved: *mut core::ffi::c_void) -> i32; } +extern "system" { + fn AddFontResourceExW(path: *const u16, flags: u32, reserved: *mut core::ffi::c_void) -> i32; +} #[cfg(target_os = "windows")] #[link(name = "user32")] -extern "system" { fn SendMessageTimeoutW(window: isize, message: u32, wparam: usize, lparam: isize, flags: u32, timeout: u32, result: *mut usize) -> isize; } +extern "system" { + fn SendMessageTimeoutW( + window: isize, + message: u32, + wparam: usize, + lparam: isize, + flags: u32, + timeout: u32, + result: *mut usize, + ) -> isize; +} diff --git a/src-tauri/src/providers/bunny_fonts.rs b/src-tauri/src/providers/bunny_fonts.rs index ac987c3..6cea284 100644 --- a/src-tauri/src/providers/bunny_fonts.rs +++ b/src-tauri/src/providers/bunny_fonts.rs @@ -1,10 +1,28 @@ -use crate::{error::AppResult, models::{FontFamily, FontProviderKind}, providers::provider::FontProvider}; +use crate::{ + error::AppResult, + models::{FontFamily, FontProviderKind}, + providers::provider::FontProvider, +}; use rusqlite::Connection; pub struct BunnyFontsProvider; impl FontProvider for BunnyFontsProvider { - fn id(&self) -> &'static str { "bunny" } - fn label(&self) -> &'static str { "Bunny" } - fn kind(&self) -> FontProviderKind { FontProviderKind::Remote } - fn list_families(&self, _connection: &Connection) -> AppResult> { Ok(vec![]) } - fn get_family(&self, _connection: &Connection, _family_id: &str) -> AppResult> { Ok(None) } + fn id(&self) -> &'static str { + "bunny" + } + fn label(&self) -> &'static str { + "Bunny" + } + fn kind(&self) -> FontProviderKind { + FontProviderKind::Remote + } + fn list_families(&self, _connection: &Connection) -> AppResult> { + Ok(vec![]) + } + fn get_family( + &self, + _connection: &Connection, + _family_id: &str, + ) -> AppResult> { + Ok(None) + } } diff --git a/src-tauri/src/providers/google_fonts.rs b/src-tauri/src/providers/google_fonts.rs index 687fec1..2f66799 100644 --- a/src-tauri/src/providers/google_fonts.rs +++ b/src-tauri/src/providers/google_fonts.rs @@ -1,10 +1,32 @@ -use crate::{db::repositories, error::AppResult, models::{FontFamily, FontProviderKind}, providers::provider::FontProvider}; +use crate::{ + db::repositories, + error::AppResult, + models::{FontFamily, FontProviderKind}, + providers::provider::FontProvider, +}; use rusqlite::Connection; pub struct GoogleFontsProvider; impl FontProvider for GoogleFontsProvider { - fn id(&self) -> &'static str { "google" } - fn label(&self) -> &'static str { "Google" } - fn kind(&self) -> FontProviderKind { FontProviderKind::Remote } - fn list_families(&self, connection: &Connection) -> AppResult> { Ok(repositories::list_font_families(connection)?.into_iter().filter(|font| matches!(font.source, crate::models::FontSource::Google)).collect()) } - fn get_family(&self, connection: &Connection, family_id: &str) -> AppResult> { repositories::get_font_family(connection, family_id) } + fn id(&self) -> &'static str { + "google" + } + fn label(&self) -> &'static str { + "Google" + } + fn kind(&self) -> FontProviderKind { + FontProviderKind::Remote + } + fn list_families(&self, connection: &Connection) -> AppResult> { + Ok(repositories::list_font_families(connection)? + .into_iter() + .filter(|font| matches!(font.source, crate::models::FontSource::Google)) + .collect()) + } + fn get_family( + &self, + connection: &Connection, + family_id: &str, + ) -> AppResult> { + repositories::get_font_family(connection, family_id) + } } diff --git a/src-tauri/src/providers/managed_fonts.rs b/src-tauri/src/providers/managed_fonts.rs index 88d94ee..7e9e9c4 100644 --- a/src-tauri/src/providers/managed_fonts.rs +++ b/src-tauri/src/providers/managed_fonts.rs @@ -1,10 +1,32 @@ -use crate::{db::repositories, error::AppResult, models::{FontFamily, FontProviderKind}, providers::provider::FontProvider}; +use crate::{ + db::repositories, + error::AppResult, + models::{FontFamily, FontProviderKind}, + providers::provider::FontProvider, +}; use rusqlite::Connection; pub struct ManagedFontsProvider; impl FontProvider for ManagedFontsProvider { - fn id(&self) -> &'static str { "managed" } - fn label(&self) -> &'static str { "Managed" } - fn kind(&self) -> FontProviderKind { FontProviderKind::Managed } - fn list_families(&self, connection: &Connection) -> AppResult> { Ok(repositories::list_font_families(connection)?.into_iter().filter(|font| matches!(font.source, crate::models::FontSource::LocalImport)).collect()) } - fn get_family(&self, connection: &Connection, family_id: &str) -> AppResult> { repositories::get_font_family(connection, family_id) } + fn id(&self) -> &'static str { + "managed" + } + fn label(&self) -> &'static str { + "Managed" + } + fn kind(&self) -> FontProviderKind { + FontProviderKind::Managed + } + fn list_families(&self, connection: &Connection) -> AppResult> { + Ok(repositories::list_font_families(connection)? + .into_iter() + .filter(|font| matches!(font.source, crate::models::FontSource::LocalImport)) + .collect()) + } + fn get_family( + &self, + connection: &Connection, + family_id: &str, + ) -> AppResult> { + repositories::get_font_family(connection, family_id) + } } diff --git a/src-tauri/src/providers/provider.rs b/src-tauri/src/providers/provider.rs index 68195e1..61d0a2c 100644 --- a/src-tauri/src/providers/provider.rs +++ b/src-tauri/src/providers/provider.rs @@ -1,4 +1,7 @@ -use crate::{error::AppResult, models::{FontFamily, FontProviderKind, FontVariant}}; +use crate::{ + error::AppResult, + models::{FontFamily, FontProviderKind, FontVariant}, +}; use rusqlite::Connection; pub trait FontProvider: Send + Sync { @@ -6,6 +9,14 @@ pub trait FontProvider: Send + Sync { fn label(&self) -> &'static str; fn kind(&self) -> FontProviderKind; fn list_families(&self, connection: &Connection) -> AppResult>; - fn get_family(&self, connection: &Connection, family_id: &str) -> AppResult>; - fn download_variant(&self, _connection: &Connection, _family_id: &str, _variant_id: &str) -> AppResult> { Ok(None) } + fn get_family(&self, connection: &Connection, family_id: &str) + -> AppResult>; + fn download_variant( + &self, + _connection: &Connection, + _family_id: &str, + _variant_id: &str, + ) -> AppResult> { + Ok(None) + } } diff --git a/src-tauri/src/providers/registry.rs b/src-tauri/src/providers/registry.rs index ceb0a5b..3aface5 100644 --- a/src-tauri/src/providers/registry.rs +++ b/src-tauri/src/providers/registry.rs @@ -1,3 +1,27 @@ -use crate::providers::{google_fonts::GoogleFontsProvider, managed_fonts::ManagedFontsProvider, provider::FontProvider, system_fonts::SystemFontsProvider}; -pub struct ProviderRegistry { providers: Vec> } -impl ProviderRegistry { pub fn built_in() -> Self { Self { providers: vec![Box::new(SystemFontsProvider), Box::new(ManagedFontsProvider), Box::new(GoogleFontsProvider)] } } pub fn all(&self) -> &[Box] { &self.providers } pub fn get(&self, id: &str) -> Option<&dyn FontProvider> { self.providers.iter().map(Box::as_ref).find(|provider| provider.id() == id) } } +use crate::providers::{ + google_fonts::GoogleFontsProvider, managed_fonts::ManagedFontsProvider, provider::FontProvider, + system_fonts::SystemFontsProvider, +}; +pub struct ProviderRegistry { + providers: Vec>, +} +impl ProviderRegistry { + pub fn built_in() -> Self { + Self { + providers: vec![ + Box::new(SystemFontsProvider), + Box::new(ManagedFontsProvider), + Box::new(GoogleFontsProvider), + ], + } + } + pub fn all(&self) -> &[Box] { + &self.providers + } + pub fn get(&self, id: &str) -> Option<&dyn FontProvider> { + self.providers + .iter() + .map(Box::as_ref) + .find(|provider| provider.id() == id) + } +} diff --git a/src-tauri/src/providers/system_fonts.rs b/src-tauri/src/providers/system_fonts.rs index 9aa09b6..5017d3e 100644 --- a/src-tauri/src/providers/system_fonts.rs +++ b/src-tauri/src/providers/system_fonts.rs @@ -1,10 +1,37 @@ -use crate::{db::repositories, error::AppResult, models::{FontFamily, FontProviderKind}, providers::provider::FontProvider}; +use crate::{ + db::repositories, + error::AppResult, + models::{FontFamily, FontProviderKind}, + providers::provider::FontProvider, +}; use rusqlite::Connection; pub struct SystemFontsProvider; impl FontProvider for SystemFontsProvider { - fn id(&self) -> &'static str { "system" } - fn label(&self) -> &'static str { "System" } - fn kind(&self) -> FontProviderKind { FontProviderKind::System } - fn list_families(&self, connection: &Connection) -> AppResult> { Ok(repositories::list_font_families(connection)?.into_iter().filter(|font| matches!(font.source, crate::models::FontSource::System | crate::models::FontSource::Local)).collect()) } - fn get_family(&self, connection: &Connection, family_id: &str) -> AppResult> { repositories::get_font_family(connection, family_id) } + fn id(&self) -> &'static str { + "system" + } + fn label(&self) -> &'static str { + "System" + } + fn kind(&self) -> FontProviderKind { + FontProviderKind::System + } + fn list_families(&self, connection: &Connection) -> AppResult> { + Ok(repositories::list_font_families(connection)? + .into_iter() + .filter(|font| { + matches!( + font.source, + crate::models::FontSource::System | crate::models::FontSource::Local + ) + }) + .collect()) + } + fn get_family( + &self, + connection: &Connection, + family_id: &str, + ) -> AppResult> { + repositories::get_font_family(connection, family_id) + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f88e4cc..704f421 100755 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,8 @@ "width": 1200, "height": 800, "minWidth": 960, - "minHeight": 640 + "minHeight": 640, + "decorations": false } ], "security": { diff --git a/src/app/App.tsx b/src/app/App.tsx index e632da6..227049e 100755 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -5,7 +5,10 @@ import { Navigate, RouterProvider, } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { isTauri } from "@tauri-apps/api/core"; import { Toaster } from "@/components/ui/sonner"; +import { UpdateDialog, useUpdaterStore } from "@/features/updater"; import { BrowsePage } from "../features/browse/BrowsePage"; import { CollectionsPage } from "../features/collections/CollectionsPage"; import { InstalledPage } from "../features/installed/InstalledPage"; @@ -17,11 +20,31 @@ const rootRoute = createRootRoute({ component: () => ( <> + + ), }); +function UpdaterBootstrap() { + const checkForUpdates = useUpdaterStore((state) => state.checkForUpdates); + const loadCurrentVersion = useUpdaterStore((state) => state.loadCurrentVersion); + + useEffect(() => { + if (!isTauri()) return; + + void loadCurrentVersion(); + const timeoutId = window.setTimeout(() => { + void checkForUpdates({ openDialogOnAvailable: false }); + }, 7_000); + + return () => window.clearTimeout(timeoutId); + }, [checkForUpdates, loadCurrentVersion]); + + return null; +} + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", diff --git a/src/app/AppHeader.tsx b/src/app/AppHeader.tsx index a71a420..4e29aa3 100755 --- a/src/app/AppHeader.tsx +++ b/src/app/AppHeader.tsx @@ -1,7 +1,10 @@ -import { Settings2 } from "lucide-react"; +import { isTauri } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { Minus, Plus, Settings2, X } from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; import { ImportFontsDialog } from "@/features/import/ImportFontsDialog"; +import { UpdateAvailableBadge } from "@/features/updater"; import { SearchCommand } from "./SearchCommand"; import { ThemeToggle } from "./ThemeToggle"; @@ -11,10 +14,61 @@ type AppHeaderProps = { export function AppHeader({ title }: AppHeaderProps) { const navigate = useNavigate(); + const appWindow = isTauri() ? getCurrentWindow() : null; + + const runWindowCommand = (command: () => Promise) => { + void command().catch(() => undefined); + }; return ( -
+
{ + if ( + event.button === 0 && + appWindow && + !(event.target instanceof Element && event.target.closest("button, input, select, textarea, [role='button']")) + ) { + runWindowCommand(() => appWindow.startDragging()); + } + }} + >
+ {appWindow ?
event.stopPropagation()}> + + + +
: null} Fontsequal / {title} @@ -23,6 +77,7 @@ export function AppHeader({ title }: AppHeaderProps) {
+
diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 5d38617..57ff65a 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isTauri } from "@tauri-apps/api/core"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -10,6 +11,7 @@ import { useTheme } from "@/app/theme-provider"; import { refreshGoogleFontsCache } from "@/lib/api/google-fonts"; import { scanSystemFonts } from "@/lib/api/fonts"; import { getSettings, updateSettings } from "@/lib/api/settings"; +import { useUpdaterStore } from "@/features/updater"; import type { ApiResult } from "@/types/result"; import type { UpdateSettingsInput } from "@/types/settings"; @@ -28,11 +30,19 @@ export function SettingsPage() {
patch({ previewText: event.target.value })} />
patch({ googleFontsApiKey: event.target.value })} />
+
!open && setDanger(null)}>{danger === "data" ? "Reset all local app data?" : "Reset Google Fonts cache?"}{danger === "data" ? "This action is destructive. Managed files and system fonts are not removed by this dialog." : "Cached Google Fonts metadata will be removed."}; } -function SettingsNav() { return ; } +function SettingsNav() { return ; } +function UpdatesSection() { + const status = useUpdaterStore((state) => state.status); const currentVersion = useUpdaterStore((state) => state.currentVersion); const update = useUpdaterStore((state) => state.update); const error = useUpdaterStore((state) => state.error); const loadCurrentVersion = useUpdaterStore((state) => state.loadCurrentVersion); const checkForUpdates = useUpdaterStore((state) => state.checkForUpdates); const setDialogOpen = useUpdaterStore((state) => state.setDialogOpen); + useEffect(() => { void loadCurrentVersion(); }, [loadCurrentVersion]); + const isChecking = status === "checking"; const canCheck = isTauri() && !["checking", "downloading", "installing"].includes(status); + const statusMessage = status === "upToDate" ? "You’re up to date." : status === "available" && update ? `Version ${update.version} is available.` : status === "ready" ? "An update is ready to restart." : status === "error" ? error?.message : undefined; + return

{currentVersion ?? (isTauri() ? "Loading…" : "Desktop app only")}

Stable

{(status === "available" || status === "ready") && update ? : null}
; +} function ThemeSegmented({ value, onChange }: { value: ThemeMode; onChange: (theme: ThemeMode) => void }) { return
{(["light", "dark", "system"] as ThemeMode[]).map((theme) => )}
; } function Section({ id, title, description, children }: { id: string; title: string; description: string; children: React.ReactNode }) { return

{title}

{description}

{children}
; } function Row({ label, help, children }: { label: string; help?: string; children: React.ReactNode }) { return

{label}

{help ?

{help}

: null}
{children}
; } diff --git a/src/features/updater/UpdateAvailableBadge.tsx b/src/features/updater/UpdateAvailableBadge.tsx new file mode 100644 index 0000000..7cbde57 --- /dev/null +++ b/src/features/updater/UpdateAvailableBadge.tsx @@ -0,0 +1,14 @@ +import { Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useUpdaterStore } from "./updater.store"; + +export function UpdateAvailableBadge() { + const status = useUpdaterStore((state) => state.status); + const update = useUpdaterStore((state) => state.update); + const setDialogOpen = useUpdaterStore((state) => state.setDialogOpen); + + if (status !== "available" && status !== "downloading" && status !== "ready" && status !== "installing") return null; + + const label = status === "ready" ? "Update ready" : status === "downloading" || status === "installing" ? "Updating" : `Update ${update?.version ?? "available"}`; + return ; +} diff --git a/src/features/updater/UpdateDialog.tsx b/src/features/updater/UpdateDialog.tsx new file mode 100644 index 0000000..da3bd4f --- /dev/null +++ b/src/features/updater/UpdateDialog.tsx @@ -0,0 +1,51 @@ +import { LoaderCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { UpdateProgress } from "./UpdateProgress"; +import { useUpdaterStore } from "./updater.store"; + +export function UpdateDialog() { + const state = useUpdaterStore(); + const close = () => state.setDialogOpen(false); + const title = dialogTitle(state.status, state.isInstalled, state.update?.version); + + return {title}{dialogDescription(state.status, state.isInstalled, state.update?.version)} + {state.status === "available" && state.update?.releaseNotes ?
{state.update.releaseNotes}
: null} + {state.status === "available" && state.update?.releaseDate ?

Released {formatReleaseDate(state.update.releaseDate)}

: null} + {state.status === "downloading" || state.status === "installing" ? : null} + {state.status === "error" && state.error ?

{state.error.message}

: null} + + {state.status === "available" ? <> : null} + {state.status === "ready" && state.isInstalled ? <> : null} + {state.status === "ready" && !state.isInstalled ? <> : null} + {state.status === "error" ? <> : null} + {state.status === "checking" || state.status === "downloading" || state.status === "installing" ? : null} + {state.status === "upToDate" ? : null} + +
; +} + +function dialogTitle(status: string, isInstalled: boolean, version?: string): string { + if (status === "available") return `Fontsequal ${version ?? "update"} is available`; + if (status === "ready") return isInstalled ? "Update ready to restart" : "Update downloaded"; + if (status === "downloading") return "Downloading update"; + if (status === "installing") return "Preparing update"; + if (status === "checking") return "Checking for updates"; + if (status === "upToDate") return "You’re up to date"; + return "Update check failed"; +} + +function dialogDescription(status: string, isInstalled: boolean, version?: string): string { + if (status === "available") return `Version ${version ?? ""} is ready when you are.`; + if (status === "ready") return isInstalled ? "Restart Fontsequal when convenient to finish the update." : "The update is downloaded. Install it when you are ready."; + if (status === "downloading") return "Fontsequal will verify the signed update before it can be installed."; + if (status === "installing") return "Preparing the verified update. Fontsequal will not restart automatically."; + if (status === "checking") return "This does not interrupt your work."; + if (status === "upToDate") return "You already have the latest stable version."; + return "No update was installed."; +} + +function formatReleaseDate(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date); +} diff --git a/src/features/updater/UpdateProgress.tsx b/src/features/updater/UpdateProgress.tsx new file mode 100644 index 0000000..4552d16 --- /dev/null +++ b/src/features/updater/UpdateProgress.tsx @@ -0,0 +1,25 @@ +import { calculateProgress } from "./updater.service"; + +type UpdateProgressProps = { + downloadedBytes: number; + totalBytes?: number; +}; + +export function UpdateProgress({ downloadedBytes, totalBytes }: UpdateProgressProps) { + const progress = calculateProgress(downloadedBytes, totalBytes); + + return
+
+
+
+

+ {formatBytes(downloadedBytes)} downloaded{totalBytes ? ` of ${formatBytes(totalBytes)}${progress === undefined ? "" : ` (${progress}%)`}` : ""} +

+
; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/features/updater/index.ts b/src/features/updater/index.ts new file mode 100644 index 0000000..b0b61e4 --- /dev/null +++ b/src/features/updater/index.ts @@ -0,0 +1,5 @@ +export { UpdateAvailableBadge } from "./UpdateAvailableBadge"; +export { UpdateDialog } from "./UpdateDialog"; +export { UpdateProgress } from "./UpdateProgress"; +export { useUpdaterStore } from "./updater.store"; +export type { UpdateDetails, UpdaterError, UpdaterState, UpdaterStatus } from "./updater.types"; diff --git a/src/features/updater/updater.service.test.ts b/src/features/updater/updater.service.test.ts new file mode 100644 index 0000000..2d60c2e --- /dev/null +++ b/src/features/updater/updater.service.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { calculateProgress, normalizeReleaseNotes, normalizeUpdaterError, readDownloadEvent } from "./updater.service"; + +describe("updater safety helpers", () => { + test("calculates bounded progress only when a content length is available", () => { + expect(calculateProgress(25, 100)).toBe(25); + expect(calculateProgress(150, 100)).toBe(100); + expect(calculateProgress(25)).toBeUndefined(); + expect(calculateProgress(25, 0)).toBeUndefined(); + }); + + test("normalizes release notes for text-only display", () => { + expect(normalizeReleaseNotes(" First line\r\nSecond\u0000 line ")).toBe("First line\nSecond line"); + expect(normalizeReleaseNotes(" \n ")).toBeUndefined(); + }); + + test("keeps verification and network failures user-safe", () => { + expect(normalizeUpdaterError(new Error("signature verification failed")).message).toBe("The update could not be verified. No changes were made."); + expect(normalizeUpdaterError(new Error("network timeout")).message).toBe("Could not reach the update service. Check your connection and try again."); + expect(normalizeUpdaterError(new Error("token=should-not-appear")).diagnostic).toContain("token=[redacted]"); + }); + + test("models download events without requiring a content length", () => { + expect(readDownloadEvent({ event: "Started", data: {} }, 99)).toEqual({ downloadedBytes: 0, totalBytes: undefined, finished: false }); + expect(readDownloadEvent({ event: "Progress", data: { chunkLength: 12 } }, 7)).toEqual({ downloadedBytes: 19, finished: false }); + expect(readDownloadEvent({ event: "Finished" }, 19)).toEqual({ downloadedBytes: 19, finished: true }); + }); +}); diff --git a/src/features/updater/updater.service.ts b/src/features/updater/updater.service.ts new file mode 100644 index 0000000..1967ac6 --- /dev/null +++ b/src/features/updater/updater.service.ts @@ -0,0 +1,85 @@ +import { getVersion } from "@tauri-apps/api/app"; +import { isTauri } from "@tauri-apps/api/core"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; +import type { UpdateDetails, UpdaterError } from "./updater.types"; + +const MAX_RELEASE_NOTE_LENGTH = 12_000; +const MAX_DIAGNOSTIC_LENGTH = 240; + +export function isDesktopTauri(): boolean { + return isTauri(); +} + +export async function getCurrentVersion(): Promise { + return getVersion(); +} + +export async function checkForUpdate(): Promise { + return check(); +} + +export async function relaunchApplication(): Promise { + await relaunch(); +} + +export function toUpdateDetails(update: Update): UpdateDetails { + return { + currentVersion: update.currentVersion, + version: update.version, + releaseDate: update.date, + releaseNotes: normalizeReleaseNotes(update.body), + }; +} + +export function calculateProgress(downloadedBytes: number, totalBytes?: number): number | undefined { + if (!totalBytes || totalBytes <= 0) return undefined; + return Math.min(100, Math.max(0, Math.round((downloadedBytes / totalBytes) * 100))); +} + +export function normalizeReleaseNotes(notes: string | undefined): string | undefined { + if (!notes) return undefined; + + const normalized = notes + .replace(/\r\n?/g, "\n") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "") + .trim(); + + if (!normalized) return undefined; + return normalized.slice(0, MAX_RELEASE_NOTE_LENGTH); +} + +export function normalizeUpdaterError(error: unknown): UpdaterError { + const diagnostic = redactDiagnostic(error instanceof Error ? error.message : String(error)); + const lowerCase = diagnostic.toLowerCase(); + + if (/(signature|verification|verified|public key)/.test(lowerCase)) { + return { message: "The update could not be verified. No changes were made.", diagnostic }; + } + if (/(network|connection|timeout|timed out|offline|dns|http)/.test(lowerCase)) { + return { message: "Could not reach the update service. Check your connection and try again.", diagnostic }; + } + if (/(json|metadata|manifest|parse)/.test(lowerCase)) { + return { message: "The update information was invalid. No changes were made.", diagnostic }; + } + return { message: "The update could not be completed. Please try again.", diagnostic }; +} + +export function readDownloadEvent( + event: DownloadEvent, + downloadedBytes: number, +): { downloadedBytes: number; totalBytes?: number; finished: boolean } { + if (event.event === "Started") { + return { downloadedBytes: 0, totalBytes: event.data.contentLength, finished: false }; + } + if (event.event === "Progress") { + return { downloadedBytes: downloadedBytes + event.data.chunkLength, finished: false }; + } + return { downloadedBytes, finished: true }; +} + +function redactDiagnostic(value: string): string { + return value + .replace(/(authorization|token|password|secret)=?\s*[^\s,;]+/gi, "$1=[redacted]") + .slice(0, MAX_DIAGNOSTIC_LENGTH); +} diff --git a/src/features/updater/updater.store.ts b/src/features/updater/updater.store.ts new file mode 100644 index 0000000..776fa69 --- /dev/null +++ b/src/features/updater/updater.store.ts @@ -0,0 +1,152 @@ +import { create } from "zustand"; +import type { Update } from "@tauri-apps/plugin-updater"; +import { + checkForUpdate, + getCurrentVersion, + isDesktopTauri, + normalizeUpdaterError, + readDownloadEvent, + relaunchApplication, + toUpdateDetails, +} from "./updater.service"; +import type { UpdaterState } from "./updater.types"; + +let pendingUpdate: Update | null = null; +let operationInFlight = false; + +function releasePendingUpdate(): void { + const update = pendingUpdate; + pendingUpdate = null; + if (update) void update.close().catch(() => undefined); +} + +export const useUpdaterStore = create((set, get) => ({ + status: "idle", + downloadedBytes: 0, + isInstalled: false, + isDialogOpen: false, + + loadCurrentVersion: async () => { + if (!isDesktopTauri()) return; + try { + set({ currentVersion: await getCurrentVersion() }); + } catch (error) { + if (import.meta.env.DEV) console.warn("[updater] version lookup failed", normalizeUpdaterError(error).diagnostic); + } + }, + + checkForUpdates: async (options) => { + if (!isDesktopTauri() || operationInFlight) return; + operationInFlight = true; + releasePendingUpdate(); + set({ status: "checking", error: undefined, downloadedBytes: 0, totalBytes: undefined, isInstalled: false }); + + try { + const currentVersion = await getCurrentVersion(); + const update = await checkForUpdate(); + if (!update) { + set({ status: "upToDate", currentVersion, update: undefined }); + return; + } + + pendingUpdate = update; + set({ + status: "available", + currentVersion, + update: toUpdateDetails(update), + isDialogOpen: options?.openDialogOnAvailable ?? false, + }); + } catch (error) { + const normalized = normalizeUpdaterError(error); + if (import.meta.env.DEV) console.warn("[updater] update check failed", normalized.diagnostic); + set({ status: "error", error: normalized }); + } finally { + operationInFlight = false; + } + }, + + downloadUpdate: async () => { + const update = pendingUpdate; + if (!update || operationInFlight || get().status !== "available") return; + operationInFlight = true; + set({ status: "downloading", error: undefined, downloadedBytes: 0, totalBytes: undefined, isInstalled: false }); + + try { + await update.download((event) => { + set((state) => ({ ...readDownloadEvent(event, state.downloadedBytes) })); + }); + set({ status: "ready" }); + } catch (error) { + const normalized = normalizeUpdaterError(error); + if (import.meta.env.DEV) console.warn("[updater] download failed", normalized.diagnostic); + set({ status: "error", error: normalized }); + } finally { + operationInFlight = false; + } + }, + + installUpdate: async () => { + const update = pendingUpdate; + if (!update || operationInFlight || get().status !== "ready" || get().isInstalled) return; + operationInFlight = true; + set({ status: "installing", error: undefined }); + + try { + await update.install(); + releasePendingUpdate(); + set({ status: "ready", isInstalled: true }); + } catch (error) { + const normalized = normalizeUpdaterError(error); + if (import.meta.env.DEV) console.warn("[updater] install failed", normalized.diagnostic); + set({ status: "error", error: normalized }); + } finally { + operationInFlight = false; + } + }, + + downloadAndInstallUpdate: async () => { + const update = pendingUpdate; + if (!update || operationInFlight || get().status !== "available") return; + operationInFlight = true; + set({ status: "downloading", error: undefined, downloadedBytes: 0, totalBytes: undefined, isInstalled: false }); + + try { + await update.downloadAndInstall((event) => { + set((state) => ({ ...readDownloadEvent(event, state.downloadedBytes) })); + }); + releasePendingUpdate(); + set({ status: "ready", isInstalled: true }); + } catch (error) { + const normalized = normalizeUpdaterError(error); + if (import.meta.env.DEV) console.warn("[updater] download and install failed", normalized.diagnostic); + set({ status: "error", error: normalized }); + } finally { + operationInFlight = false; + } + }, + + restartApplication: async () => { + if (operationInFlight || get().status !== "ready" || !get().isInstalled) return; + operationInFlight = true; + try { + await relaunchApplication(); + } catch (error) { + const normalized = normalizeUpdaterError(error); + if (import.meta.env.DEV) console.warn("[updater] relaunch failed", normalized.diagnostic); + set({ status: "error", error: normalized }); + operationInFlight = false; + } + }, + + dismissAvailableUpdate: () => { + releasePendingUpdate(); + set({ status: "idle", update: undefined, downloadedBytes: 0, totalBytes: undefined, isInstalled: false, error: undefined, isDialogOpen: false }); + }, + + resetUpdaterState: () => { + releasePendingUpdate(); + set({ status: "idle", update: undefined, downloadedBytes: 0, totalBytes: undefined, isInstalled: false, error: undefined, isDialogOpen: false }); + }, + + setDialogOpen: (isDialogOpen) => set({ isDialogOpen }), +})); diff --git a/src/features/updater/updater.types.ts b/src/features/updater/updater.types.ts new file mode 100644 index 0000000..c2ea44a --- /dev/null +++ b/src/features/updater/updater.types.ts @@ -0,0 +1,45 @@ +export type UpdaterStatus = + | "idle" + | "checking" + | "available" + | "downloading" + | "ready" + | "installing" + | "upToDate" + | "error"; + +export type UpdateDetails = { + currentVersion: string; + version: string; + releaseDate?: string; + releaseNotes?: string; +}; + +export type UpdaterError = { + message: string; + diagnostic: string; +}; + +export type CheckForUpdatesOptions = { + openDialogOnAvailable?: boolean; +}; + +export type UpdaterState = { + status: UpdaterStatus; + currentVersion?: string; + update?: UpdateDetails; + downloadedBytes: number; + totalBytes?: number; + isInstalled: boolean; + error?: UpdaterError; + isDialogOpen: boolean; + loadCurrentVersion: () => Promise; + checkForUpdates: (options?: CheckForUpdatesOptions) => Promise; + downloadUpdate: () => Promise; + installUpdate: () => Promise; + downloadAndInstallUpdate: () => Promise; + restartApplication: () => Promise; + dismissAvailableUpdate: () => void; + resetUpdaterState: () => void; + setDialogOpen: (open: boolean) => void; +}; diff --git a/tsconfig.json b/tsconfig.json index e14f8ae..326b503 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2020"], + "types": ["bun-types"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true,