diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 01479e9..b66f207 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -60,7 +60,10 @@ jobs:
for suite in tests/*.test.mjs; do node "$suite"; done
- name: Verify isolated skill installation and packaging scripts
- run: python3 tests/cli-scripts.test.py
+ run: |
+ python3 tests/cli-scripts.test.py
+ python3 tests/release.test.py
+ python3 scripts/release.py docs --check
- name: Verify real browser and CLI authentication
run: |
diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml
index b2bbd03..cff6259 100644
--- a/.github/workflows/cli-release.yml
+++ b/.github/workflows/cli-release.yml
@@ -4,6 +4,7 @@ on:
push:
tags: ['cli-v*']
workflow_dispatch:
+ workflow_call:
permissions:
contents: read
@@ -17,25 +18,22 @@ env:
CARGO_INCREMENTAL: '0'
jobs:
+ targets:
+ runs-on: ubuntu-24.04
+ outputs:
+ matrix: ${{ steps.matrix.outputs.value }}
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - id: matrix
+ run: echo "value=$(python3 scripts/release.py matrix cli)" >> "$GITHUB_OUTPUT"
+
build:
+ needs: targets
name: Package ${{ matrix.target }}
strategy:
fail-fast: false
- matrix:
- include:
- - os: ubuntu-24.04
- target: x86_64-unknown-linux-musl
- - os: ubuntu-24.04-arm
- target: aarch64-unknown-linux-musl
- - os: macos-15-intel
- target: x86_64-apple-darwin
- - os: macos-14
- target: aarch64-apple-darwin
- - os: windows-2022
- target: x86_64-pc-windows-msvc
- - os: windows-11-arm
- target: aarch64-pc-windows-msvc
- runs-on: ${{ matrix.os }}
+ matrix: ${{ fromJSON(needs.targets.outputs.matrix) }}
+ runs-on: ${{ matrix.runner }}
timeout-minutes: 30
defaults:
run:
@@ -66,13 +64,19 @@ jobs:
if: runner.os == 'Windows'
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
+ - name: Cache CLI build dependencies
+ uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
+ with:
+ key: cli-${{ matrix.target }}
+ cache-on-failure: true
+
- name: Validate version and tag
env:
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
run: |
version=$(sed -n 's/^version = "\([^"]*\)"$/\1/p' crates/hireferry-cli/Cargo.toml | head -n 1)
- if [[ "$REF_TYPE" == tag ]]; then test "$REF_NAME" = "cli-v$version"; fi
+ if [[ "$REF_TYPE" == tag && "$REF_NAME" == cli-v* ]]; then test "$REF_NAME" = "cli-v$version"; fi
- name: Build and test native client
env:
@@ -105,7 +109,7 @@ jobs:
publish:
name: Publish complete CLI release
needs: build
- if: startsWith(github.ref, 'refs/tags/cli-v')
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cli-v')
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
@@ -118,7 +122,11 @@ jobs:
path: artifacts/cli-release
- name: Assemble and verify all six packages
- run: python3 scripts/assemble-cli-release.py artifacts/cli-release dist/release
+ env:
+ RELEASE_TAG: ${{ github.ref_name }}
+ run: |
+ python3 scripts/release.py assemble-cli artifacts/cli-release dist/release \
+ --tag "$RELEASE_TAG" --repository "$GITHUB_REPOSITORY" --commit "$GITHUB_SHA"
- name: Publish GitHub Release
env:
@@ -128,7 +136,15 @@ jobs:
state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft 2>/dev/null || printf missing)
if [[ "$state" == false ]]; then echo 'This release is already published; bump the CLI version.' >&2; exit 1; fi
if [[ "$state" == missing ]]; then
- gh release create "$RELEASE_TAG" --verify-tag --draft --title "HireFerry CLI ${RELEASE_TAG#cli-v}" --notes-file docs/cli-release-notes.md
+ gh release create "$RELEASE_TAG" --verify-tag --draft --title "HireFerry CLI ${RELEASE_TAG#cli-v}" --notes-file dist/release/downloads.md
fi
gh release upload "$RELEASE_TAG" dist/release/* --clobber
- gh release edit "$RELEASE_TAG" --draft=false --latest
+ gh release edit "$RELEASE_TAG" --notes-file dist/release/downloads.md --draft=false --latest
+
+ update-docs:
+ needs: publish
+ permissions:
+ contents: write
+ uses: ./.github/workflows/release-docs.yml
+ with:
+ tag: ${{ github.ref_name }}
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
new file mode 100644
index 0000000..2ae7025
--- /dev/null
+++ b/.github/workflows/desktop-release.yml
@@ -0,0 +1,261 @@
+name: Desktop and CLI packages
+
+on:
+ push:
+ tags: ['desktop-v*']
+ pull_request:
+ paths:
+ - 'desktop/**'
+ - 'packaging/**'
+ - 'release/**'
+ - 'scripts/release.py'
+ - 'scripts/package-flatpak.sh'
+ - 'tests/release.test.py'
+ - '.github/workflows/desktop-release.yml'
+ - '.github/workflows/cli-release.yml'
+ - '.github/workflows/release-docs.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: desktop-release-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_INCREMENTAL: '0'
+ CARGO_PROFILE_TEST_DEBUG: '0'
+
+jobs:
+ prepare:
+ name: Validate release catalog
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ outputs:
+ desktop: ${{ steps.targets.outputs.desktop }}
+ flatpak: ${{ steps.targets.outputs.flatpak }}
+ version: ${{ steps.targets.outputs.version }}
+ tag: ${{ steps.targets.outputs.tag }}
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - name: Verify catalog, generated docs and package boundaries
+ run: |
+ python3 tests/release.test.py
+ python3 scripts/release.py docs --check
+ bash -n scripts/package-flatpak.sh
+ cargo fmt --manifest-path desktop/src-tauri/Cargo.toml -- --check
+ - id: targets
+ name: Read version and build matrices
+ env:
+ REF_TYPE: ${{ github.ref_type }}
+ REF_NAME: ${{ github.ref_name }}
+ run: |
+ python3 - <<'PY'
+ import json, os, pathlib, re, tomllib
+ root = pathlib.Path('.')
+ version = json.loads((root/'desktop/src-tauri/tauri.conf.json').read_text())['version']
+ cargo = tomllib.loads((root/'desktop/src-tauri/Cargo.toml').read_text())['package']['version']
+ npm = json.loads((root/'desktop/package.json').read_text())['version']
+ assert version == cargo == npm, 'Desktop Cargo, npm and Tauri versions must agree'
+ assert re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', version), 'Use a stable desktop SemVer'
+ tag = f'desktop-v{version}'
+ if os.environ['REF_TYPE'] == 'tag':
+ assert os.environ['REF_NAME'] == tag, 'Tag does not match desktop package version'
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as out:
+ out.write(f'version={version}\ntag={tag}\n')
+ PY
+ echo "desktop=$(python3 scripts/release.py matrix desktop)" >> "$GITHUB_OUTPUT"
+ echo "flatpak=$(python3 scripts/release.py matrix flatpak)" >> "$GITHUB_OUTPUT"
+ - name: Require committed dependency lockfiles
+ run: |
+ test -s desktop/package-lock.json
+ test -s desktop/src-tauri/Cargo.lock
+
+ desktop:
+ name: Desktop ${{ matrix.target }}
+ needs: prepare
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.prepare.outputs.desktop) }}
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '24'
+ cache: npm
+ cache-dependency-path: desktop/package-lock.json
+ - name: Install Linux WebKit and bundle dependencies
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ build-essential pkg-config libwebkit2gtk-4.1-dev libssl-dev \
+ libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev \
+ patchelf libfuse2 file rpm xdg-utils xvfb xauth dbus-x11
+ - name: Install Rust target
+ env:
+ TARGET: ${{ matrix.target }}
+ run: |
+ rustup toolchain install stable --profile minimal
+ rustup default stable
+ rustup target add "$TARGET"
+ - name: Link the Windows C runtime statically
+ if: runner.os == 'Windows'
+ run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
+ - name: Cache desktop build dependencies
+ uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
+ with:
+ workspaces: desktop/src-tauri -> target
+ key: ${{ matrix.target }}
+ cache-on-failure: true
+ - name: Install pinned Tauri CLI
+ working-directory: desktop
+ run: npm ci --ignore-scripts
+ - name: Test desktop URL and capability boundaries
+ env:
+ TARGET: ${{ matrix.target }}
+ run: cargo test --locked --release --manifest-path desktop/src-tauri/Cargo.toml --target "$TARGET"
+ - name: Build platform installers
+ working-directory: desktop
+ env:
+ TARGET: ${{ matrix.target }}
+ BUNDLES: ${{ matrix.bundles }}
+ APPIMAGE_EXTRACT_AND_RUN: '1'
+ run: npm run tauri -- build --ci --target "$TARGET" --bundles "$BUNDLES" -- --locked
+ - name: Start the Linux desktop launcher under a real WebKit view
+ if: runner.os == 'Linux'
+ env:
+ TARGET: ${{ matrix.target }}
+ run: |
+ dbus-run-session -- xvfb-run -a \
+ "desktop/src-tauri/target/$TARGET/release/hireferry-desktop" --smoke-test
+ - name: Normalize packages and record checksums
+ env:
+ TARGET: ${{ matrix.target }}
+ run: |
+ python3 scripts/release.py stage-desktop --target "$TARGET" \
+ --bundle-dir "desktop/src-tauri/target/$TARGET/release/bundle" --out dist/desktop
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: desktop-release-${{ matrix.target }}
+ path: dist/desktop/
+ if-no-files-found: error
+ retention-days: 7
+
+ cli:
+ name: User CLI
+ # The reusable workflow contains a write-scoped publish job for cli-v tags.
+ # Its build jobs explicitly inherit contents: read; publish is skipped here.
+ permissions:
+ contents: write
+ uses: ./.github/workflows/cli-release.yml
+
+ flatpak:
+ name: Flatpak ${{ matrix.arch }}
+ needs: [prepare, desktop]
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.prepare.outputs.flatpak) }}
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 35
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: desktop-release-${{ matrix.target }}
+ path: artifacts/native
+ - name: Install Flatpak toolchain and shared runtime
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends flatpak librsvg2-common xvfb xauth dbus-x11
+ flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
+ flatpak install --user --noninteractive -y flathub org.gnome.Platform//51 org.gnome.Sdk//51
+ - name: Package and smoke-test the isolated desktop
+ env:
+ TARGET: ${{ matrix.target }}
+ ARCH: ${{ matrix.arch }}
+ VERSION: ${{ needs.prepare.outputs.version }}
+ run: |
+ (cd artifacts/native && sha256sum -c SHA256SUMS)
+ bash scripts/package-flatpak.sh \
+ --deb "artifacts/native/hireferry-desktop-$VERSION-$TARGET.deb" \
+ --version "$VERSION" --arch "$ARCH" --out-dir dist/flatpak-build --smoke-test
+ python3 scripts/release.py stage-flatpak --target "$TARGET" \
+ --binary "$(find dist/flatpak-build -maxdepth 1 -name '*.flatpak' -type f)" --out dist/flatpak
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: flatpak-release-${{ matrix.target }}
+ path: dist/flatpak/
+ if-no-files-found: error
+ retention-days: 7
+
+ assemble:
+ name: Verify complete release and generate downloads
+ needs: [prepare, desktop, cli, flatpak]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ pattern: '*-release-*'
+ path: artifacts/release
+ - name: Validate every required package and generate Markdown, SVG and JSON
+ env:
+ RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
+ run: |
+ python3 scripts/release.py assemble artifacts/release dist/release \
+ --tag "$RELEASE_TAG" --repository "$GITHUB_REPOSITORY" --commit "$GITHUB_SHA"
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: complete-desktop-release
+ path: dist/release/
+ if-no-files-found: error
+ retention-days: 14
+
+ publish:
+ name: Publish verified GitHub Release
+ needs: [prepare, assemble]
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-v')
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: complete-desktop-release
+ path: dist/release
+ - name: Publish only after all packages and checksums are present
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
+ run: |
+ (cd dist/release && sha256sum -c SHA256SUMS)
+ state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft 2>/dev/null || printf missing)
+ if [[ "$state" == false ]]; then
+ echo 'This release is already published; bump the desktop version.' >&2
+ exit 1
+ fi
+ if [[ "$state" == missing ]]; then
+ gh release create "$RELEASE_TAG" --verify-tag --draft --latest=false \
+ --title "HireFerry Desktop ${RELEASE_TAG#desktop-v}" --notes-file dist/release/downloads.md
+ fi
+ gh release upload "$RELEASE_TAG" dist/release/* --clobber
+ gh release edit "$RELEASE_TAG" --notes-file dist/release/downloads.md --draft=false --latest=false
+
+ update-docs:
+ needs: [prepare, publish]
+ permissions:
+ contents: write
+ uses: ./.github/workflows/release-docs.yml
+ with:
+ tag: ${{ needs.prepare.outputs.tag }}
diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml
new file mode 100644
index 0000000..6446ba0
--- /dev/null
+++ b/.github/workflows/release-docs.yml
@@ -0,0 +1,74 @@
+name: Update download matrix
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Published stable tag, for example desktop-v0.1.0 or cli-v0.1.2
+ required: true
+ type: string
+ workflow_call:
+ inputs:
+ tag:
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release-download-documentation
+ cancel-in-progress: false
+ queue: max
+
+jobs:
+ update:
+ if: github.event_name != 'release' || ((startsWith(github.event.release.tag_name, 'desktop-v') || startsWith(github.event.release.tag_name, 'cli-v')) && !github.event.release.prerelease)
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+ fetch-depth: 0
+ - name: Download the published manifest and verify its checksum
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
+ run: |
+ [[ "$RELEASE_TAG" =~ ^(desktop|cli)-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1
+ gh release view "$RELEASE_TAG" --json isDraft,isPrerelease,tagName > "$RUNNER_TEMP/release-metadata.json"
+ gh release download "$RELEASE_TAG" --pattern downloads.json --pattern SHA256SUMS --dir "$RUNNER_TEMP/hfy-downloads"
+ python3 - <<'PY'
+ import hashlib, json, os, pathlib
+ folder = pathlib.Path(os.environ['RUNNER_TEMP']) / 'hfy-downloads'
+ metadata = json.loads((folder.parent/'release-metadata.json').read_text())
+ assert not metadata['isDraft'] and not metadata['isPrerelease'], 'Only published stable releases update documentation'
+ assert metadata['tagName'] == os.environ['RELEASE_TAG']
+ expected = [line.split()[0] for line in (folder/'SHA256SUMS').read_text().splitlines()
+ if len(line.split()) == 2 and line.split()[1] == 'downloads.json']
+ actual = hashlib.sha256((folder/'downloads.json').read_bytes()).hexdigest()
+ assert expected == [actual], 'Download manifest checksum mismatch'
+ manifest = json.loads((folder/'downloads.json').read_text())
+ assert manifest['repository'] == os.environ['GITHUB_REPOSITORY']
+ product = os.environ['RELEASE_TAG'].split('-v', 1)[0]
+ assert manifest['releases'][product] == os.environ['RELEASE_TAG']
+ PY
+ - name: Generate documentation from actual published packages
+ run: |
+ python3 scripts/release.py docs --manifest "$RUNNER_TEMP/hfy-downloads/downloads.json"
+ python3 scripts/release.py docs --check
+ - name: Commit the updated download matrix
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
+ run: |
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git add docs/downloads.md docs/assets/release-matrix.svg release/latest.json
+ if git diff --cached --quiet; then exit 0; fi
+ git commit -m "docs: refresh download matrix for $RELEASE_TAG"
+ git pull --rebase origin "$DEFAULT_BRANCH"
+ git push origin "HEAD:$DEFAULT_BRANCH"
diff --git a/.gitignore b/.gitignore
index 4d1744d..1927634 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,5 @@
*test-results*
*final-tests*
.DS_Store
+__pycache__/
+*.py[cod]
diff --git a/README.md b/README.md
index 7a12a4b..96657ad 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@
官网 ·
文档 ·
- 下载 CLI ·
+ 下载桌面版 / CLI ·
问题反馈
@@ -32,7 +32,7 @@
服务端使用 **Rust + Axum + SQLite**。一个二进制内嵌网页,TOML 管理配置。求职者免费,招聘方费用按站点配置收取。
-**本页导航**:[功能](#features) · [服务流程](#workflow) · [快速开始](#quick-start) · [CLI 与 AI](#cli-and-ai) · [系统架构](#architecture) · [文档](#documentation) · [参与贡献](#contributing)
+**本页导航**:[桌面下载](#desktop) · [功能](#features) · [服务流程](#workflow) · [快速开始](#quick-start) · [CLI 与 AI](#cli-and-ai) · [系统架构](#architecture) · [文档](#documentation) · [参与贡献](#contributing)
@@ -96,6 +96,16 @@ cargo run --locked -- --config config.toml serve
生产部署使用 [config.example.toml](config.example.toml)。systemd、Caddy、Docker、管理员初始化与备份见[自托管指南](docs/self-hosting.md)。
+
+
+## 桌面版与 CLI 下载
+
+
+
+Tauri 2 桌面版复用系统 WebView,连接官方或自托管站点。Windows 提供 NSIS / MSI,macOS 提供 DMG,Linux 提供 deb、rpm、AppImage 和 Flatpak;`hfy` 用户 CLI 独立打包。文档由同一份发布目录自动生成,成功发布后展示实际包版本、大小与下载链接。
+
+**[下载矩阵 →](docs/downloads.md)** · [桌面安装与发行版兼容说明](docs/desktop.md) · [发布工作流](.github/workflows/desktop-release.yml)
+
## CLI 与 AI
diff --git a/desktop/.gitignore b/desktop/.gitignore
new file mode 100644
index 0000000..6ae4253
--- /dev/null
+++ b/desktop/.gitignore
@@ -0,0 +1,6 @@
+/node_modules/
+__pycache__/
+*.pyc
+/src-tauri/target/
+/src-tauri/gen/
+/src-tauri/permissions/autogenerated/
diff --git a/desktop/generate-icons.py b/desktop/generate-icons.py
new file mode 100644
index 0000000..4660cec
--- /dev/null
+++ b/desktop/generate-icons.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Rasterize our simple SVG geometry into the native bundle icon formats.
+
+Requires Pillow only for maintainers regenerating icons; builds use committed files.
+"""
+from pathlib import Path
+import xml.etree.ElementTree as ET
+
+from PIL import Image, ImageDraw
+
+root = Path(__file__).resolve().parent / "src-tauri" / "icons"
+source = ET.parse(root / "icon.svg").getroot()
+size = 1024
+scale = size / float(source.attrib["viewBox"].split()[2])
+image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+draw = ImageDraw.Draw(image)
+for node in source:
+ tag = node.tag.rsplit("}", 1)[-1]
+ if tag == "rect":
+ draw.rounded_rectangle(
+ (0, 0, float(node.attrib["width"]) * scale - 1,
+ float(node.attrib["height"]) * scale - 1),
+ radius=float(node.attrib["rx"]) * scale,
+ fill=node.attrib["fill"],
+ )
+ elif tag == "polygon":
+ points = [tuple(float(value) * scale for value in point.split(","))
+ for point in node.attrib["points"].split()]
+ draw.polygon(points, fill=node.attrib["fill"])
+for filename, width in [("32x32.png", 32), ("128x128.png", 128), ("128x128@2x.png", 256), ("icon.png", 512)]:
+ image.resize((width, width), Image.Resampling.LANCZOS).save(root / filename, optimize=True)
+image.save(root / "icon.ico", sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
+image.save(root / "icon.icns")
+print("Generated PNG, ICO and ICNS icons from icon.svg")
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
new file mode 100644
index 0000000..dd16fba
--- /dev/null
+++ b/desktop/package-lock.json
@@ -0,0 +1,248 @@
+{
+ "name": "hireferry-desktop",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "hireferry-desktop",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-or-later",
+ "devDependencies": {
+ "@tauri-apps/cli": "2.12.2"
+ }
+ },
+ "node_modules/@tauri-apps/cli": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz",
+ "integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==",
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "bin": {
+ "tauri": "tauri.js"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ },
+ "optionalDependencies": {
+ "@tauri-apps/cli-darwin-arm64": "2.12.2",
+ "@tauri-apps/cli-darwin-x64": "2.12.2",
+ "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2",
+ "@tauri-apps/cli-linux-arm64-gnu": "2.12.2",
+ "@tauri-apps/cli-linux-arm64-musl": "2.12.2",
+ "@tauri-apps/cli-linux-riscv64-gnu": "2.12.2",
+ "@tauri-apps/cli-linux-x64-gnu": "2.12.2",
+ "@tauri-apps/cli-linux-x64-musl": "2.12.2",
+ "@tauri-apps/cli-win32-arm64-msvc": "2.12.2",
+ "@tauri-apps/cli-win32-ia32-msvc": "2.12.2",
+ "@tauri-apps/cli-win32-x64-msvc": "2.12.2"
+ }
+ },
+ "node_modules/@tauri-apps/cli-darwin-arm64": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz",
+ "integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-darwin-x64": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz",
+ "integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz",
+ "integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz",
+ "integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm64-musl": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz",
+ "integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz",
+ "integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-x64-gnu": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz",
+ "integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-x64-musl": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz",
+ "integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz",
+ "integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz",
+ "integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-x64-msvc": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz",
+ "integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ }
+ }
+}
diff --git a/desktop/package.json b/desktop/package.json
new file mode 100644
index 0000000..57e485e
--- /dev/null
+++ b/desktop/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "hireferry-desktop",
+ "version": "0.1.0",
+ "private": true,
+ "description": "HireFerry desktop client powered by Tauri 2",
+ "license": "AGPL-3.0-or-later",
+ "scripts": {
+ "tauri": "tauri",
+ "dev": "tauri dev",
+ "build": "tauri build"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "2.12.2"
+ }
+}
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
new file mode 100644
index 0000000..97452b1
--- /dev/null
+++ b/desktop/src-tauri/Cargo.lock
@@ -0,0 +1,4816 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "atk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
+dependencies = [
+ "atk-sys",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "brotli"
+version = "9.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "6.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cairo-rs"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
+dependencies = [
+ "bitflags 2.13.2",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "camino"
+version = "1.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9"
+dependencies = [
+ "semver",
+ "serde",
+ "toml 1.1.8+spec-1.1.0",
+]
+
+[[package]]
+name = "cc"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3"
+dependencies = [
+ "fnv",
+ "uuid",
+ "web-time",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
+dependencies = [
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
+dependencies = [
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "ctor"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
+
+[[package]]
+name = "darling"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "dbus"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "defmt"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "dlopen2"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
+dependencies = [
+ "dlopen2_derive",
+ "libc",
+ "once_cell",
+ "winapi",
+]
+
+[[package]]
+name = "dlopen2_derive"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "dom_query"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc"
+dependencies = [
+ "bit-set",
+ "cssparser",
+ "foldhash",
+ "html5ever",
+ "precomputed-hash",
+ "selectors",
+ "tendril",
+]
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "3.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 1.1.8+spec-1.1.0",
+ "vswhom",
+ "winreg",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gdk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
+dependencies = [
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
+dependencies = [
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkx11"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
+dependencies = [
+ "gdk",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "libc",
+ "x11",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps",
+ "x11",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "gio"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
+dependencies = [
+ "bitflags 2.13.2",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "once_cell",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 2.0.2",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+
+[[package]]
+name = "gobject-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
+dependencies = [
+ "atk",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
+dependencies = [
+ "proc-macro-crate 1.3.1",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hireferry-desktop"
+version = "0.1.0"
+dependencies = [
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-opener",
+ "url",
+]
+
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "httparse",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167"
+dependencies = [
+ "byteorder",
+ "png",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "jiff"
+version = "0.2.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
+dependencies = [
+ "defmt",
+ "log",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "keyboard-types"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
+dependencies = [
+ "bitflags 2.13.2",
+ "serde",
+]
+
+[[package]]
+name = "libappindicator"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
+dependencies = [
+ "glib",
+ "gtk",
+ "gtk-sys",
+ "libappindicator-sys",
+ "log",
+]
+
+[[package]]
+name = "libappindicator-sys"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
+dependencies = [
+ "gtk-sys",
+ "libloading",
+ "once_cell",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "muda"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "gtk",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "once_cell",
+ "png",
+ "serde",
+ "thiserror 2.0.21",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.2",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
+dependencies = [
+ "objc2-encode",
+ "objc2-exception-helper",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-quartz-core",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-exception-helper"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-web-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "open"
+version = "5.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
+dependencies = [
+ "dunce",
+ "is-wsl",
+ "libc",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "pango"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
+dependencies = [
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "plist"
+version = "1.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
+dependencies = [
+ "base64 0.23.1",
+ "indexmap 2.14.2",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
+dependencies = [
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.17+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
+dependencies = [
+ "libredox",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schemars"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
+dependencies = [
+ "dyn-clone",
+ "indexmap 1.9.3",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "selectors"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
+dependencies = [
+ "bitflags 2.13.2",
+ "cssparser",
+ "derive_more",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "rustc-hash",
+ "servo_arc",
+ "smallvec",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-untagged"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
+dependencies = [
+ "base64 0.23.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.2",
+ "jiff",
+ "schemars 0.9.0",
+ "schemars 1.2.3",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "softbuffer"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
+dependencies = [
+ "bytemuck",
+ "js-sys",
+ "ndk",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "redox_syscall",
+ "tracing",
+ "wasm-bindgen",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "soup3"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
+dependencies = [
+ "futures-channel",
+ "gio",
+ "glib",
+ "libc",
+ "soup3-sys",
+]
+
+[[package]]
+name = "soup3-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_vcruntime"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "swift-rs"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9"
+dependencies = [
+ "base64 0.21.7",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.2",
+ "version-compare",
+]
+
+[[package]]
+name = "tao"
+version = "0.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "core-foundation",
+ "core-graphics",
+ "crossbeam-channel",
+ "dbus",
+ "dispatch2",
+ "dlopen2",
+ "dpi",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gtk",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "parking_lot",
+ "percent-encoding",
+ "raw-window-handle",
+ "tao-macros",
+ "unicode-segmentation",
+ "url",
+ "windows",
+ "windows-core",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "2.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "cookie",
+ "dirs",
+ "dunce",
+ "embed_plist",
+ "getrandom 0.3.4",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "jni",
+ "libc",
+ "log",
+ "mime",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "percent-encoding",
+ "plist",
+ "raw-window-handle",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "swift-rs",
+ "tauri-build",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "thiserror 2.0.21",
+ "tokio",
+ "tray-icon",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "window-vibrancy",
+ "windows",
+]
+
+[[package]]
+name = "tauri-build"
+version = "2.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs",
+ "glob",
+ "heck 0.5.0",
+ "json-patch",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde_json",
+ "static_vcruntime",
+ "strsim",
+ "tauri-utils",
+ "tauri-winres",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316"
+dependencies = [
+ "base64 0.23.1",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "syn 2.0.119",
+ "tauri-utils",
+ "thiserror 2.0.21",
+ "time",
+ "url",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-plugin"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291"
+dependencies = [
+ "anyhow",
+ "glob",
+ "plist",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-plugin-opener"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
+dependencies = [
+ "dunce",
+ "glob",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "open",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.21",
+ "url",
+ "windows",
+ "zbus",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9"
+dependencies = [
+ "cookie",
+ "dpi",
+ "gtk",
+ "http",
+ "jni",
+ "objc2",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror 2.0.21",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "2.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f"
+dependencies = [
+ "gtk",
+ "http",
+ "jni",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "percent-encoding",
+ "raw-window-handle",
+ "softbuffer",
+ "tao",
+ "tauri-runtime",
+ "tauri-utils",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+ "wry",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "2.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3"
+dependencies = [
+ "anyhow",
+ "brotli",
+ "cargo_metadata",
+ "ctor",
+ "dom_query",
+ "dunce",
+ "glob",
+ "http",
+ "infer",
+ "json-patch",
+ "log",
+ "memchr",
+ "phf",
+ "plist",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde-untagged",
+ "serde_json",
+ "serde_with",
+ "swift-rs",
+ "thiserror 2.0.21",
+ "toml 1.1.8+spec-1.1.0",
+ "url",
+ "urlpattern",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796"
+dependencies = [
+ "dunce",
+ "embed-resource",
+ "toml 1.1.8+spec-1.1.0",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.54.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.8+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
+dependencies = [
+ "indexmap 2.14.2",
+ "serde_core",
+ "serde_spanned 1.1.2",
+ "toml_datetime 1.1.2+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.14.2",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
+dependencies = [
+ "indexmap 2.14.2",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.17+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
+dependencies = [
+ "indexmap 2.14.2",
+ "toml_datetime 1.1.2+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+dependencies = [
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tray-icon"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
+dependencies = [
+ "crossbeam-channel",
+ "dirs",
+ "libappindicator",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "once_cell",
+ "png",
+ "serde",
+ "thiserror 2.0.21",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlpattern"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b"
+dependencies = [
+ "icu_properties",
+ "regex",
+ "serde",
+ "url",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup3",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pkg-config",
+ "soup3-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb"
+dependencies = [
+ "thiserror 2.0.21",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "window-vibrancy"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580"
+dependencies = [
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "windows-sys 0.61.2",
+ "windows-version",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "wry"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
+dependencies = [
+ "base64 0.22.1",
+ "block2",
+ "cookie",
+ "crossbeam-channel",
+ "dirs",
+ "dom_query",
+ "dpi",
+ "dunce",
+ "gdkx11",
+ "gtk",
+ "http",
+ "javascriptcore-rs",
+ "jni",
+ "libc",
+ "ndk",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "sha2",
+ "soup3",
+ "tao-macros",
+ "thiserror 2.0.21",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows",
+ "windows-core",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.4",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
+dependencies = [
+ "serde",
+ "winnow 1.0.4",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zvariant"
+version = "5.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 1.0.4",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.9",
+ "winnow 1.0.4",
+]
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
new file mode 100644
index 0000000..1e41f2f
--- /dev/null
+++ b/desktop/src-tauri/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "hireferry-desktop"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.88"
+description = "HireFerry desktop client"
+authors = ["HireFerry contributors"]
+license = "AGPL-3.0-or-later"
+repository = "https://github.com/TokenNotIncluded/hireferry"
+
+# The desktop toolchain is deliberately independent of the server/CLI workspace.
+[workspace]
+resolver = "3"
+
+[build-dependencies]
+tauri-build = { version = "=2.7.3", features = [] }
+
+[dependencies]
+tauri = { version = "=2.12.4", features = [] }
+tauri-plugin-opener = "=2.7.2"
+url = "2.5.8"
+
+[profile.release]
+opt-level = "z"
+lto = true
+strip = true
+codegen-units = 1
+panic = "abort"
diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs
new file mode 100644
index 0000000..4039f3b
--- /dev/null
+++ b/desktop/src-tauri/build.rs
@@ -0,0 +1,8 @@
+fn main() {
+ // Application commands must participate in the capability ACL, too.
+ tauri_build::try_build(
+ tauri_build::Attributes::new()
+ .app_manifest(tauri_build::AppManifest::new().commands(&["connect", "open_browser"])),
+ )
+ .expect("failed to build the desktop application");
+}
diff --git a/desktop/src-tauri/capabilities/launcher.json b/desktop/src-tauri/capabilities/launcher.json
new file mode 100644
index 0000000..eb06f4a
--- /dev/null
+++ b/desktop/src-tauri/capabilities/launcher.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "launcher",
+ "description": "Only the bundled connection screen may open a workbench or browser.",
+ "windows": ["launcher"],
+ "local": true,
+ "permissions": ["allow-connect", "allow-open-browser"]
+}
diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png
new file mode 100644
index 0000000..d261da1
Binary files /dev/null and b/desktop/src-tauri/icons/128x128.png differ
diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png
new file mode 100644
index 0000000..b9d89b5
Binary files /dev/null and b/desktop/src-tauri/icons/128x128@2x.png differ
diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png
new file mode 100644
index 0000000..75eb2c5
Binary files /dev/null and b/desktop/src-tauri/icons/32x32.png differ
diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns
new file mode 100644
index 0000000..7d5c03a
Binary files /dev/null and b/desktop/src-tauri/icons/icon.icns differ
diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico
new file mode 100644
index 0000000..1a6934b
Binary files /dev/null and b/desktop/src-tauri/icons/icon.ico differ
diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png
new file mode 100644
index 0000000..f1ee7b7
Binary files /dev/null and b/desktop/src-tauri/icons/icon.png differ
diff --git a/desktop/src-tauri/icons/icon.svg b/desktop/src-tauri/icons/icon.svg
new file mode 100644
index 0000000..4662869
--- /dev/null
+++ b/desktop/src-tauri/icons/icon.svg
@@ -0,0 +1,7 @@
+
+ HireFerry ferry
+
+
+
+
+
diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs
new file mode 100644
index 0000000..2fb0e84
--- /dev/null
+++ b/desktop/src-tauri/src/main.rs
@@ -0,0 +1,204 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+mod policy;
+
+use std::sync::atomic::{AtomicU64, Ordering};
+use tauri::{
+ AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
+ menu::{MenuBuilder, SubmenuBuilder},
+ webview::{NewWindowResponse, PageLoadEvent},
+};
+use url::Url;
+
+static NEXT_WINDOW: AtomicU64 = AtomicU64::new(1);
+
+fn require_launcher(window: &WebviewWindow) -> Result<(), String> {
+ if window.label() != "launcher" || !window.url().is_ok_and(|url| policy::launcher_url(&url)) {
+ return Err("not_allowed".into());
+ }
+ Ok(())
+}
+
+fn open_external(url: &Url) -> Result<(), String> {
+ if !policy::web_url(url) {
+ return Err("invalid_server".into());
+ }
+ tauri_plugin_opener::open_url(url.as_str(), None::<&str>).map_err(|_| "browser_failed".into())
+}
+
+fn show_launcher(app: &AppHandle) {
+ if let Some(window) = app.get_webview_window("launcher") {
+ let _ = window.show();
+ let _ = window.unminimize();
+ let _ = window.set_focus();
+ }
+}
+
+#[tauri::command]
+async fn open_browser(window: WebviewWindow, server: String) -> Result<(), String> {
+ require_launcher(&window)?;
+ let url = policy::server_origin(&server).map_err(str::to_owned)?;
+ open_external(&url)
+}
+
+#[tauri::command]
+async fn connect(window: WebviewWindow, app: AppHandle, server: String) -> Result<(), String> {
+ require_launcher(&window)?;
+ let url = policy::server_origin(&server).map_err(str::to_owned)?;
+ let origin = url.origin();
+ let browser_home = url.clone();
+ let home = url.clone();
+ let label = format!("workbench-{}", NEXT_WINDOW.fetch_add(1, Ordering::Relaxed));
+ let old_windows: Vec<_> = app
+ .webview_windows()
+ .into_values()
+ .filter(|window| window.label().starts_with("workbench-"))
+ .collect();
+ let workbench = WebviewWindowBuilder::new(&app, &label, WebviewUrl::External(url.clone()))
+ .title(format!(
+ "HireFerry · {}",
+ url.origin().ascii_serialization()
+ ))
+ .inner_size(1280.0, 860.0)
+ .min_inner_size(680.0, 540.0)
+ .center()
+ // No remote capability is assigned to workbench-* windows. The remote
+ // service keeps its own CSP; it can never invoke our native commands.
+ .on_navigation(move |next| {
+ if next.origin() == origin && policy::web_url(next) {
+ // A browser session cannot be transferred back to this webview.
+ // Start Google's entire flow in the browser, including its CSRF cookie.
+ if next.path() == "/api/auth/google/start" {
+ let _ = open_external(&browser_home);
+ return false;
+ }
+ return true;
+ }
+ let _ = open_external(next);
+ false
+ })
+ .on_new_window(|url, _| {
+ let _ = open_external(&url);
+ NewWindowResponse::Deny
+ })
+ .build()
+ .map_err(|_| "window_failed".to_owned())?;
+
+ let handle = app.clone();
+ let closed_label = label.clone();
+ workbench.on_window_event(move |event| {
+ if matches!(event, tauri::WindowEvent::Destroyed)
+ && handle
+ .webview_windows()
+ .keys()
+ .all(|label| !label.starts_with("workbench-") || *label == closed_label)
+ {
+ show_launcher(&handle);
+ }
+ });
+ workbench.on_menu_event(move |window, event| {
+ let Some(webview) = window.get_webview_window(window.label()) else {
+ return;
+ };
+ match event.id().as_ref() {
+ "home" => {
+ let _ = webview.navigate(home.clone());
+ }
+ "reload" => {
+ let _ = webview.reload();
+ }
+ "settings" => show_launcher(window.app_handle()),
+ _ => {}
+ }
+ });
+ for old in old_windows {
+ let _ = old.destroy();
+ }
+ window.hide().map_err(|_| "window_failed".to_owned())?;
+ Ok(())
+}
+
+fn main() {
+ let args: Vec<_> = std::env::args().skip(1).collect();
+ if args.iter().any(|arg| arg == "--version" || arg == "-V") {
+ println!("hireferry-desktop {}", env!("CARGO_PKG_VERSION"));
+ return;
+ }
+ let smoke_test = args.iter().any(|arg| arg == "--smoke-test");
+ tauri::Builder::default()
+ .invoke_handler(tauri::generate_handler![connect, open_browser])
+ .setup(move |app| {
+ let app_menu = SubmenuBuilder::new(app, "HireFerry")
+ .text("settings", "Connection / 连接设置")
+ .separator()
+ .quit()
+ .build()?;
+ let edit_menu = SubmenuBuilder::new(app, "Edit / 编辑")
+ .undo()
+ .redo()
+ .separator()
+ .cut()
+ .copy()
+ .paste()
+ .select_all()
+ .build()?;
+ let view_menu = SubmenuBuilder::new(app, "View / 视图")
+ .text("home", "Home / 首页")
+ .text("reload", "Reload / 刷新")
+ .separator()
+ .fullscreen()
+ .build()?;
+ let menu = MenuBuilder::new(app)
+ .items(&[&app_menu, &edit_menu, &view_menu])
+ .build()?;
+ app.set_menu(menu)?;
+ let handle = app.handle().clone();
+ let launcher =
+ WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
+ .title("HireFerry · 聘渡")
+ .inner_size(920.0, 700.0)
+ .min_inner_size(640.0, 580.0)
+ .center()
+ .on_navigation(policy::launcher_url)
+ .on_new_window(|_, _| NewWindowResponse::Deny)
+ .on_page_load(move |_, event| {
+ if smoke_test && matches!(event.event(), PageLoadEvent::Finished) {
+ println!("HireFerry desktop smoke test: launcher loaded");
+ handle.exit(0);
+ }
+ })
+ .build()?;
+ let connection_handle = app.handle().clone();
+ launcher.on_window_event(move |event| {
+ if let tauri::WindowEvent::CloseRequested { api, .. } = event
+ && connection_handle
+ .webview_windows()
+ .keys()
+ .any(|label| label.starts_with("workbench-"))
+ {
+ // Keep connection settings available from the menu while
+ // the user continues working in the remote window.
+ api.prevent_close();
+ if let Some(window) = connection_handle.get_webview_window("launcher") {
+ let _ = window.hide();
+ }
+ }
+ });
+ launcher.on_menu_event(|window, event| {
+ if event.id().as_ref() == "settings" {
+ show_launcher(window.app_handle());
+ }
+ });
+ if smoke_test {
+ let handle = app.handle().clone();
+ std::thread::spawn(move || {
+ std::thread::sleep(std::time::Duration::from_secs(20));
+ eprintln!("desktop smoke test timed out before the launcher loaded");
+ handle.exit(1);
+ });
+ }
+ Ok(())
+ })
+ .run(tauri::generate_context!())
+ .expect("failed to run HireFerry desktop");
+}
diff --git a/desktop/src-tauri/src/policy.rs b/desktop/src-tauri/src/policy.rs
new file mode 100644
index 0000000..3bb9a06
--- /dev/null
+++ b/desktop/src-tauri/src/policy.rs
@@ -0,0 +1,90 @@
+use url::{Host, Url};
+
+fn loopback(url: &Url) -> bool {
+ match url.host() {
+ Some(Host::Domain("localhost")) => true,
+ Some(Host::Ipv4(ip)) => ip.is_loopback(),
+ Some(Host::Ipv6(ip)) => ip.is_loopback(),
+ _ => false,
+ }
+}
+
+pub fn web_url(url: &Url) -> bool {
+ (url.scheme() == "https" || (url.scheme() == "http" && loopback(url)))
+ && url.host().is_some()
+ && url.username().is_empty()
+ && url.password().is_none()
+ && url.port() != Some(0)
+}
+
+pub fn server_origin(input: &str) -> Result {
+ let url = Url::parse(input.trim()).map_err(|_| "invalid_server")?;
+ if !web_url(&url) || url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
+ return Err("invalid_server");
+ }
+ Ok(url)
+}
+
+pub fn launcher_url(url: &Url) -> bool {
+ ((url.scheme() == "tauri" && url.host_str() == Some("localhost"))
+ || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost")))
+ && url.username().is_empty()
+ && url.password().is_none()
+ && url.port().is_none()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn accepts_https_and_actual_loopback_origins() {
+ for input in [
+ "https://hireferry.lmm.best",
+ "https://selfhost.example:8443/",
+ "http://localhost:3000",
+ "http://127.0.0.1:3000",
+ "http://[::1]:3000",
+ ] {
+ assert!(server_origin(input).is_ok(), "{input}");
+ }
+ }
+
+ #[test]
+ fn rejects_insecure_remote_origins_credentials_and_paths() {
+ for input in [
+ "http://hireferry.lmm.best",
+ "http://localhost.example/",
+ "http://192.168.1.2/",
+ "https://user:password@hireferry.lmm.best/",
+ "https://hireferry.lmm.best/login",
+ "https://hireferry.lmm.best/?token=secret",
+ "https://hireferry.lmm.best/#settings",
+ "https://hireferry.lmm.best:0/",
+ "file:///etc/passwd",
+ "javascript:alert(1)",
+ ] {
+ assert!(server_origin(input).is_err(), "{input}");
+ }
+ }
+
+ #[test]
+ fn launcher_identity_does_not_accept_lookalike_remote_origins() {
+ for input in [
+ "tauri://localhost/index.html",
+ "http://tauri.localhost/index.html",
+ "https://tauri.localhost/",
+ ] {
+ assert!(launcher_url(&Url::parse(input).unwrap()), "{input}");
+ }
+ for input in [
+ "https://tauri.localhost.example/",
+ "https://tauri.localhost:444/",
+ "https://user@tauri.localhost/",
+ "https://localhost/",
+ "tauri://remote/",
+ ] {
+ assert!(!launcher_url(&Url::parse(input).unwrap()), "{input}");
+ }
+ }
+}
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
new file mode 100644
index 0000000..94237ed
--- /dev/null
+++ b/desktop/src-tauri/tauri.conf.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "https://schema.tauri.app/config/2",
+ "productName": "HireFerry",
+ "version": "0.1.0",
+ "identifier": "best.lmm.hireferry",
+ "mainBinaryName": "hireferry-desktop",
+ "build": {
+ "frontendDist": "../ui"
+ },
+ "app": {
+ "withGlobalTauri": true,
+ "windows": [],
+ "security": {
+ "capabilities": ["launcher"],
+ "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"
+ }
+ },
+ "bundle": {
+ "active": true,
+ "targets": "all",
+ "category": "Business",
+ "publisher": "HireFerry",
+ "homepage": "https://hireferry.lmm.best",
+ "shortDescription": "People and opportunities, connected.",
+ "longDescription": "HireFerry is a lightweight desktop client for the HireFerry recruitment platform. Connect to the official service or your own deployment.",
+ "license": "AGPL-3.0-or-later",
+ "licenseFile": "../../LICENSE",
+ "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
+ "windows": {
+ "webviewInstallMode": { "type": "downloadBootstrapper", "silent": true },
+ "nsis": {
+ "compression": "lzma",
+ "installMode": "currentUser",
+ "languages": ["English", "SimpChinese", "TradChinese"],
+ "displayLanguageSelector": false
+ }
+ },
+ "macOS": { "minimumSystemVersion": "11.0" },
+ "linux": {
+ "deb": { "section": "net" },
+ "rpm": { "compression": { "type": "xz", "level": 6 } },
+ "appimage": { "bundleMediaFramework": false }
+ }
+ }
+}
diff --git a/desktop/ui/app.js b/desktop/ui/app.js
new file mode 100644
index 0000000..0c69006
--- /dev/null
+++ b/desktop/ui/app.js
@@ -0,0 +1,87 @@
+'use strict';
+
+const messages = {
+ zh: {
+ eyebrow:'你的桌面工作台', heading:'让人才与机会,\n在此相遇。',
+ lead:'一扇轻巧的窗口,连接你的聘渡。\n继续每一次沟通,开启下一段旅程。',
+ ready:'准备就绪', connectTitle:'从这里出发',
+ connectDescription:'使用官方服务,或连接你自己的聘渡站点。',
+ serverLabel:'服务地址', serverHelp:'使用 HTTPS 地址。本机开发可使用 http://localhost:3000。',
+ connectButton:'进入工作台', browserButton:'在浏览器中打开',
+ loginNote:'桌面内可使用邮箱验证码登录。Google 登录请在浏览器中完成;浏览器与桌面的登录状态各自独立。',
+ footer:'人类 HR 与 AI,协作同行。', footerHelp:'连接设置可随时在应用菜单中重新打开。',
+ opening:'正在打开工作台…', opened:'工作台已打开。若页面未加载,可在应用菜单中刷新或切换服务地址。',
+ browserOpened:'已在默认浏览器中打开。', invalid_server:'请输入完整的 HTTPS 站点地址,不包含账号、路径、查询参数或片段。本机允许 HTTP。',
+ not_allowed:'只能在聘渡桌面版的连接页面执行此操作。',
+ window_failed:'暂时无法创建窗口,请重试或选择在浏览器中打开。',
+ browser_failed:'无法打开默认浏览器,请检查系统的默认浏览器设置。',
+ unavailable:'请在 HireFerry 桌面版中打开此页面。', unexpected:'操作未完成,请重试。'
+ },
+ en: {
+ eyebrow:'YOUR DESKTOP WORKSPACE', heading:'People. Possibilities.\nA place to meet.',
+ lead:'A lighter window into HireFerry.\nPick up the conversation. Start your next chapter.',
+ ready:'Ready to connect', connectTitle:'Your next port of call',
+ connectDescription:'Use the official service or connect to your own HireFerry deployment.',
+ serverLabel:'Server address', serverHelp:'Use HTTPS. Local development can use http://localhost:3000.',
+ connectButton:'Open workspace', browserButton:'Open in browser',
+ loginNote:'Email-code sign-in works in the app. Use your browser for Google sign-in. Browser and desktop sessions stay separate.',
+ footer:'Human HR and AI, working together.', footerHelp:'Reopen connection settings from the application menu.',
+ opening:'Opening your workspace…', opened:'Workspace opened. Use the application menu to reload or change servers if the page does not load.',
+ browserOpened:'Opened in your default browser.', invalid_server:'Enter a full HTTPS origin without credentials, paths, query parameters or fragments. HTTP is allowed on loopback only.',
+ not_allowed:'This action is available only from the desktop connection screen.',
+ window_failed:'The window could not be opened. Try again or open the service in your browser.',
+ browser_failed:'Your browser could not be opened. Check the default browser in system settings.',
+ unavailable:'Open this page in the HireFerry desktop application.', unexpected:'The action did not complete. Please try again.'
+ }
+};
+const storage = {
+ get(key) { try { return localStorage.getItem(key); } catch { return null; } },
+ set(key, value) { try { localStorage.setItem(key, value); } catch { /* A session still works without storage. */ } }
+};
+let language = storage.get('hireferry.desktop.language') || (navigator.language.startsWith('zh') ? 'zh' : 'en');
+if (!messages[language]) language = 'en';
+let statusKey = '', statusError = false;
+const server = document.getElementById('server');
+server.value = storage.get('hireferry.desktop.server') || 'https://hireferry.lmm.best';
+
+function render() {
+ document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en';
+ for (const element of document.querySelectorAll('[data-i18n]')) {
+ const lines = messages[language][element.dataset.i18n].split('\n');
+ element.replaceChildren(...lines.flatMap((text, index) => index ? [document.createElement('br'), document.createTextNode(text)] : [document.createTextNode(text)]));
+ }
+ const toggle = document.getElementById('language');
+ toggle.textContent = language === 'zh' ? 'EN' : '中文';
+ toggle.setAttribute('aria-label', language === 'zh' ? 'Switch to English' : '切换为简体中文');
+ document.getElementById('status').textContent = statusKey ? messages[language][statusKey] : '';
+ document.getElementById('status').classList.toggle('error', statusError);
+}
+
+function status(key, error = false) { statusKey = key; statusError = error; render(); }
+
+async function run(command) {
+ if (!server.checkValidity()) { server.reportValidity(); return; }
+ const invoke = window.__TAURI__?.core?.invoke;
+ if (!invoke) { status('unavailable', true); return; }
+ const buttons = [document.getElementById('connect'), document.getElementById('browser')];
+ buttons.forEach(button => { button.disabled = true; });
+ if (command === 'connect') status('opening');
+ try {
+ const address = server.value.trim();
+ await invoke(command, { server: address });
+ storage.set('hireferry.desktop.server', address);
+ status(command === 'connect' ? 'opened' : 'browserOpened');
+ } catch (error) {
+ const code = typeof error === 'string' && Object.prototype.hasOwnProperty.call(messages.en, error) ? error : 'unexpected';
+ status(code, true);
+ } finally { buttons.forEach(button => { button.disabled = false; }); }
+}
+
+document.getElementById('language').addEventListener('click', () => {
+ language = language === 'zh' ? 'en' : 'zh';
+ storage.set('hireferry.desktop.language', language);
+ render();
+});
+document.getElementById('connect-form').addEventListener('submit', event => { event.preventDefault(); run('connect'); });
+document.getElementById('browser').addEventListener('click', () => run('open_browser'));
+render();
diff --git a/desktop/ui/index.html b/desktop/ui/index.html
new file mode 100644
index 0000000..bb4a7c1
--- /dev/null
+++ b/desktop/ui/index.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+ HireFerry · 聘渡
+
+
+
+
+
+
+
+
+
+ 你的桌面工作台
+ 让人才与机会, 在此相遇。
+ 一扇轻巧的窗口,连接你的聘渡。 继续每一次沟通,开启下一段旅程。
+
+ HireFerry ferry
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 准备就绪
+ DESKTOP
+
+ 从这里出发
+ 使用官方服务,或连接你自己的聘渡站点。
+
+ 在浏览器中打开
+
+ 桌面内可使用邮箱验证码登录。Google 登录请在浏览器中完成;浏览器与桌面的登录状态各自独立。
+
+
+
+
+
+
+
diff --git a/desktop/ui/style.css b/desktop/ui/style.css
new file mode 100644
index 0000000..6e9a682
--- /dev/null
+++ b/desktop/ui/style.css
@@ -0,0 +1,59 @@
+:root { color-scheme: light dark; --paper:#f8f7f3; --surface:#fffefb; --ink:#292824; --muted:#77746c; --line:#e4e1d8; --accent:#3b5046; --tint:#edf0e9; --danger:#a02a30; }
+* { box-sizing:border-box; }
+body { margin:0; background:var(--paper); color:var(--ink); font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","Microsoft YaHei",sans-serif; -webkit-font-smoothing:antialiased; }
+button,input { font:inherit; }
+button { cursor:pointer; }
+button:disabled { cursor:wait; opacity:.65; }
+button,input { -webkit-tap-highlight-color:transparent; }
+button:focus-visible,input:focus-visible { outline:2px solid var(--accent); outline-offset:4px; }
+.shell { max-width:1120px; min-height:100vh; margin:0 auto; padding:30px 40px 24px; display:flex; flex-direction:column; }
+header { display:flex; align-items:center; justify-content:space-between; gap:20px; }
+.brand { display:flex; align-items:center; gap:11px; font-size:19px; font-weight:650; letter-spacing:.03em; }
+.brand svg { width:37px; height:37px; fill:var(--ink); }
+.brand svg path { fill:var(--paper); }
+.brand-en { font-size:17px; letter-spacing:-.03em; margin-left:5px; }
+.language { border:1px solid var(--line); background:transparent; color:var(--muted); border-radius:20px; padding:7px 11px; font-size:12px; font-weight:600; }
+.language:hover { background:var(--surface); color:var(--ink); }
+main { display:grid; grid-template-columns:1fr 1.05fr; gap:42px; flex:1; align-items:center; padding:42px 0 30px; }
+.eyebrow { color:var(--muted); font-size:11px; font-weight:600; letter-spacing:.16em; margin:0 0 23px; }
+h1 { font-family:Georgia,"Noto Serif SC","Songti SC",serif; font-size:clamp(32px,4.6vw,48px); font-weight:500; letter-spacing:-.04em; line-height:1.34; margin:0 0 20px; }
+.lead { color:var(--muted); font-size:13px; line-height:1.9; margin:0; }
+.voyage { display:block; width:100%; max-width:350px; margin-top:22px; overflow:visible; }
+.voyage pattern circle { fill:var(--line); }
+.route { fill:none; stroke:var(--line); stroke-width:1.5; stroke-dasharray:3 7; }
+.sail { fill:var(--accent); opacity:.6; }
+.hull { fill:var(--accent); }
+.water { fill:none; stroke:var(--muted); stroke-width:1.5; stroke-linecap:round; }
+.destination { fill:var(--accent); }
+.destination-ring { stroke:var(--accent); stroke-width:1; fill:none; opacity:.25; }
+.ferry { animation:float 5s ease-in-out infinite; transform-origin:center; }
+.connection { background:var(--surface); border:1px solid var(--line); border-radius:20px; padding:27px 27px 22px; box-shadow:0 8px 32px #28251d04; }
+.connection-head { display:flex; align-items:center; gap:7px; margin-bottom:23px; font-size:11px; color:var(--accent); }
+.status-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 4px var(--tint); margin-right:4px; }
+.desktop-label { margin-left:auto; font-size:9px; letter-spacing:.15em; color:var(--muted); }
+h2 { font-size:23px; font-weight:600; letter-spacing:-.03em; margin:0 0 9px; }
+.form-description { margin:0 0 24px; font-size:12px; color:var(--muted); line-height:1.75; }
+label { display:block; font-size:12px; font-weight:550; margin-bottom:9px; }
+input { width:100%; min-width:0; padding:12px 12px; border:1px solid var(--line); border-radius:9px; background:var(--paper); color:var(--ink); font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:11px; }
+input:hover { border-color:var(--muted); }
+.field-help { margin:8px 0 21px; font-size:10px; color:var(--muted); line-height:1.6; }
+.primary { display:flex; align-items:center; justify-content:center; gap:14px; width:100%; min-height:44px; border:0; border-radius:10px; background:var(--ink); color:var(--paper); font-size:13px; font-weight:550; transition:transform .15s,background .15s; }
+.primary:hover { background:var(--accent); }
+.primary:active { transform:translateY(1px); }
+.primary svg,.browser svg { width:17px; height:17px; fill:none; stroke:currentColor; stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; }
+.browser { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; min-height:40px; margin:7px 0 0; border:0; background:transparent; color:var(--muted); font-size:11px; border-radius:9px; }
+.browser:hover { color:var(--ink); background:var(--paper); }
+.browser svg { width:13px; height:13px; }
+.status { margin:0; color:var(--accent); font-size:11px; line-height:1.5; }
+.status:not(:empty) { margin:4px 0 14px; }
+.status.error { color:var(--danger); }
+.login-note { display:flex; align-items:flex-start; gap:8px; margin-top:16px; border-top:1px solid var(--line); padding-top:15px; }
+.login-note svg { width:14px; height:14px; flex:none; margin-top:2px; fill:none; stroke:var(--muted); stroke-width:1.5; stroke-linecap:round; }
+.login-note p { margin:0; color:var(--muted); font-size:10px; line-height:1.75; }
+footer { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px 20px; border-top:1px solid var(--line); padding-top:17px; color:var(--muted); font-size:10px; }
+.footer-help { font-size:9px; }
+@keyframes float { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-5px); } }
+@media (prefers-color-scheme:dark) { :root { --paper:#1d1e1b; --surface:#252621; --ink:#eeeae1; --muted:#aba99f; --line:#393b33; --accent:#adc2b3; --tint:#323c32; --danger:#ff9b9f; } }
+@media (max-width:760px) { .shell { padding:24px; } main { gap:24px; grid-template-columns:.9fr 1.1fr; padding-top:32px; } .connection { padding:22px; } h1 { font-size:32px; } .brand { font-size:17px; } .lead { font-size:12px; } .voyage { margin-top:15px; } }
+@media (max-width:580px) { main { grid-template-columns:1fr; padding-top:30px; } .intro .voyage { display:none; } .intro h1 br,.intro .lead br { display:none; } h1 { font-size:30px; margin-bottom:12px; } .eyebrow { margin-bottom:12px; } .connection { max-width:420px; width:100%; margin:auto; } .brand-en { font-size:15px; } }
+@media (prefers-reduced-motion:reduce) { *,*::before,*::after { animation:none!important; transition:none!important; } }
diff --git a/docs/README.md b/docs/README.md
index 8f4dbd2..a67484f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,10 +16,12 @@
| [优惠码](promo-codes.md) | 申请、审批、免费开通、发布减免与次数预占 |
| [公开政策](legal-policies.md) | 中英文条款与隐私模板、政策变量及自定义文本 |
-## 使用 CLI 与 AI
+## 使用桌面版、CLI 与 AI
| 文档 | 内容 |
| --- | --- |
+| [桌面版与 CLI 下载矩阵](downloads.md) | 自动生成的系统、软件包、架构、大小和下载链接 |
+| [桌面安装与发布](desktop.md) | Tauri 2、Linux 兼容范围、签名与 Release 流程 |
| [命令行客户端 `hfy`](cli.md) | 安装、登录、JSON、连接管理、升级与卸载 |
| [Agent Skill](../skills/hireferry/SKILL.md) | AI 使用 CLI 的操作约定 |
| [API 与 MCP](api.md) | HTTP、远程 MCP、WebMCP、字段、权限及分页 |
@@ -38,6 +40,7 @@
| [生产配置模板](../config.example.toml) | 服务与业务配置字段 |
| [开发配置模板](../config.development.toml) | 仅回环访问的本机环境 |
| [CI 工作流](../.github/workflows/ci.yml) | Rust、网页资源和 CLI 的验证步骤 |
+| [桌面发布工作流](../.github/workflows/desktop-release.yml) | 桌面版与 CLI 完整发布、校验与矩阵更新 |
| [CLI 发布工作流](../.github/workflows/cli-release.yml) | 六平台构建及版本发布 |
## 验收记录与进展
diff --git a/docs/assets/release-matrix.svg b/docs/assets/release-matrix.svg
new file mode 100644
index 0000000..bf86fcc
--- /dev/null
+++ b/docs/assets/release-matrix.svg
@@ -0,0 +1,11 @@
+
+HireFerry release matrix Package availability by operating system and architecture; 21 package variants. Configured build targets, not a claim of published or tested packages.
+
+
+
+
+HireFerry · Every desktop, one ferry.
+BUILD TARGETS · DOWNLOADS APPEAR AFTER A COMPLETE RELEASE
+Windows x64 + ARM64 NSIS .exe · .msi Desktop + hfy CLI macOS x64 + ARM64 .dmg · .app.tar.gz Desktop + hfy CLI Linux x64 + ARM64 .deb · .rpm · .AppImage · Flatpak Desktop + hfy CLI
+21 package variants · SHA-256 verification · one release catalog
+
diff --git a/docs/desktop.md b/docs/desktop.md
new file mode 100644
index 0000000..1ba9747
--- /dev/null
+++ b/docs/desktop.md
@@ -0,0 +1,150 @@
+# 聘渡桌面版与多平台发布
+
+
+
+**[下载矩阵](downloads.md) · [所有 Releases](https://github.com/TokenNotIncluded/hireferry/releases) · [CLI 使用指南](cli.md)**
+
+桌面版使用 **Tauri 2 + 系统 WebView**,提供聘渡网站的独立应用窗口。默认连接 `https://hireferry.lmm.best`,也可以连接自托管站点。应用不包含聘渡服务器或数据库;业务数据仍由所选站点管理,使用业务功能需要联网。
+
+`hfy` 是独立的用户端 CLI,不需要安装桌面版。服务器程序仍名为 `hireferry`,与桌面程序 `hireferry-desktop`、用户 CLI `hfy` 分开发布。
+
+## 选择安装包
+
+| 系统 | 架构 | 桌面包 | 用户 CLI |
+| --- | --- | --- | --- |
+| Windows | x64 | NSIS `.exe`、`.msi` | `.zip` |
+| Windows | ARM64 | NSIS `.exe` | `.zip` |
+| macOS | Intel x64、Apple Silicon ARM64 | `.dmg`、`.app.tar.gz` | `.tar.gz` |
+| Linux | x64、ARM64 | `.deb`、`.rpm`、`.AppImage`、`.flatpak` | 静态 musl `.tar.gz` |
+
+每次完整桌面 Release 包含 **15 个桌面包和 6 个 CLI 包**。包的实际下载大小、版本、架构及 SHA-256 会由构建产物生成,见下载矩阵及 Release 附件 `downloads.json`。源码文档在首次成功发布前显示构建目标,不提前宣称已有可下载包。
+
+优先选系统原生包,可以复用已安装的图形库和 WebView。Windows NSIS 按需下载 WebView2,不在每个安装包里嵌入完整浏览器;macOS 使用系统 WebKit。Flatpak 首次安装运行时占用的空间会大于应用包本身,但同一运行时可供多个应用共享。Tauri 的这些平台依赖见[官方前置条件](https://v2.tauri.app/start/prerequisites/)与 [Windows WebView2 安装选项](https://v2.tauri.app/distribute/windows-installer/#webview2-installation-options)。
+
+## Linux 覆盖范围
+
+Linux 使用多种包格式覆盖主流发行版系列,不能仅凭扩展名保证所有历史版本均能运行。
+
+| 发行版系列 | 建议 | 条件与范围 |
+| --- | --- | --- |
+| Ubuntu、Debian、Mint、Pop!_OS、Zorin、elementary | `.deb` | 原生构建基线 Ubuntu 22.04;需要 WebKitGTK 4.1。Debian 12 和提供相同依赖的后续版本可安装 |
+| Fedora、openSUSE、RHEL、Rocky、Alma | `.rpm`;必要时用 Flatpak | 安装器必须能满足所需库;旧企业发行版缺少 WebKitGTK 4.1 时使用 Flatpak |
+| Arch、Manjaro、EndeavourOS、Gentoo | AppImage 或 Flatpak | 图形会话、宿主 glibc 与运行时需兼容;不是所有历史版本的兼容承诺 |
+| NixOS、SteamOS、不可变桌面 | Flatpak | 通过发行版配置安装 Flatpak、运行时及桌面 portal |
+| Alpine / musl | CLI 用 musl 压缩包;桌面使用可用的 Flatpak 环境 | glibc `.deb`、`.rpm` 和 AppImage 不适用于原生 musl 环境 |
+
+Tauri 2 原生 Linux 构建依赖 WebKitGTK 4.1。原生包在 **Ubuntu 22.04 的 x64 / ARM64 运行器**分别构建,避免在较新宿主上编译后意外提高 glibc 最低要求。AppImage 仍有宿主 ABI 和图形环境限制;无法把较新的 glibc 二进制变成所有发行版通用程序。参见 [Tauri Debian 打包说明](https://v2.tauri.app/distribute/debian/#limitations)与 [AppImage 限制](https://v2.tauri.app/distribute/appimage/#limitations)。
+
+Flatpak 使用独立共享运行时提供图形依赖,通过 `.flatpak` 附件分发。**生成 Flatpak 附件不表示已上架 Flathub。** 安装运行时需要可用的软件源;部分受限或旧系统需要先配置相应服务。参考 [Flatpak 安装帮助](https://flatpak.org/setup/)及 [Tauri Flatpak 文档](https://v2.tauri.app/distribute/flatpak/)。
+
+本仓库的 CI 验证构建、打包、包集合和校验信息;没有把每个发行版都实际运行过的结果记录为通过。32 位 x86、ARMv7 和 RISC-V 暂不在当前发布矩阵中。
+
+## 安装与校验
+
+从同一个 Release 下载安装包及 `SHA256SUMS`,不要混用不同版本的校验文件。
+
+```sh
+# Linux:下载的那个文件显示 OK 才继续安装
+sha256sum --ignore-missing --check SHA256SUMS
+```
+
+```sh
+# macOS:本机只下载了部分附件时,其他条目提示缺失是正常的
+shasum -a 256 -c SHA256SUMS
+```
+
+```powershell
+# Windows:把结果与 SHA256SUMS 中对应文件名的值比较
+Get-FileHash .\hireferry-desktop-*.exe -Algorithm SHA256
+```
+
+安装命令中的文件名请替换为下载矩阵给出的实际名称,并只选与 CPU 对应的包。
+
+```sh
+# Debian / Ubuntu 家族
+sudo apt install ./hireferry-desktop--.deb
+
+# Fedora / RHEL 家族
+sudo dnf install ./hireferry-desktop--.rpm
+
+# openSUSE
+sudo zypper install ./hireferry-desktop--.rpm
+
+# AppImage
+chmod +x ./hireferry-desktop--.AppImage
+./hireferry-desktop--.AppImage
+
+# Flatpak:先按发行版指南配置 Flatpak 和 Flathub 运行时源
+flatpak install --user ./hireferry-desktop--.flatpak
+flatpak run best.lmm.hireferry
+```
+
+AppImage 需要的 FUSE 配置因发行版而异;不能挂载时可尝试 AppImage 的 `--appimage-extract-and-run`。Flatpak 在没有运行时源时,需要先添加发行版推荐的软件源。Windows 双击 NSIS / MSI;macOS 打开 DMG 后把应用拖进 Applications。
+
+CLI 的一键安装、登录、升级与卸载见 [CLI 文档](cli.md)。它继续使用独立的 `cli-v<版本>` 发布通道。
+
+## 连接站点与登录
+
+启动器接受 HTTPS 站点的**来源地址**,例如 `https://hireferry.example`;本机开发可用 `http://localhost:3000` 或回环 IP。用户名密码、查询参数和业务路径不能写进站点地址。
+
+远程业务窗口不获得本地文件、Shell 或 Tauri 命令权限。外部站点链接交给系统浏览器;本地启动器仅获得连接站点所需的最小权限。
+
+桌面窗口内可使用站点提供的邮箱验证码登录。Google 等第三方提供方可能拒绝嵌入式 WebView;系统浏览器与桌面窗口的会话也不自动共享。此版本没有加入浏览器 OAuth 会话回传桥接,使用此类登录时应在浏览器里继续访问站点。不要把“浏览器成功登录”误认为桌面窗口也已登录。
+
+桌面壳的更新通过下载新版本安装包完成,Flatpak 通过重新安装新附件更新。这里的“自动更新”指**发布文档和下载矩阵自动生成**,并没有配置未签名的应用内自动更新器。
+
+## 本地构建
+
+桌面包独立于服务器 Cargo workspace,避免普通服务器或 CLI 测试被迫安装 GTK / WebKit 开发依赖。请先按 [Tauri 官方前置条件](https://v2.tauri.app/start/prerequisites/)安装当前系统所需工具。
+
+```sh
+cd desktop
+npm ci
+npm run tauri -- dev
+npm run tauri -- build
+```
+
+生成的原始包在桌面 crate 的 `target/.../release/bundle/`。统一发布文件名由 `scripts/release.py stage-desktop` 生成;GitHub CI 使用显式 Rust target,因此资产包含架构而不会互相覆盖。
+
+## CI 与版本发布
+
+发布目标由 [`release/targets.json`](../release/targets.json) 定义。CI 矩阵、预期包集合、Markdown、JSON 和 SVG 都读取这个目录,新增平台时不需要分别维护多张表。
+
+1. 在 `desktop/src-tauri/Cargo.toml`、`desktop/src-tauri/tauri.conf.json` 和 `desktop/package.json` 中保持桌面版本一致;更新相应锁文件。
+2. 提交源码,等待 [桌面发布工作流](../.github/workflows/desktop-release.yml) 的验证构建通过。手动运行可验证构建,不会把分支名称当作正式版本发布。
+3. 创建与桌面版本完全一致的 `desktop-v<版本>` 标签并推送。CLI 仍可通过原有 `cli-v<版本>` 标签单独发布。
+4. 各操作系统原生构建;Linux 另外生成 Flatpak。发布任务只在全部包成功后运行。
+5. 汇总脚本拒绝缺失平台、重复包、额外陈旧文件或 SHA-256 不匹配,生成完整的 `downloads.json`、`downloads.md`、`release-matrix.svg`、`VERSION` 和 `SHA256SUMS`。
+6. 资产先传入草稿 Release,确认上传完成后公开。已公开的版本不被覆盖,修复需提升版本。
+7. 桌面版和独立 CLI Release 均从已发布清单自动更新下载文档,按各自稳定版本保留最新条目;仓库分支保护可能要求通过 PR 接受文档变更,Release 自身仍包含准确的矩阵与完整清单。
+
+桌面 Release 使用 `--latest=false`,保留现有 CLI 一键安装器依赖的 GitHub `latest` 通道。用户从[下载矩阵](downloads.md)进入桌面 Release;不要用仓库通用 `/releases/latest` 代替桌面版下载入口。
+
+### 校验和再生成
+
+```sh
+python3 scripts/release.py matrix desktop
+python3 scripts/release.py matrix cli
+python3 scripts/release.py matrix flatpak
+# CLI 独立发布也生成经过验证的 JSON / Markdown / SVG
+python3 scripts/release.py assemble-cli artifacts/cli-release dist/cli-release --tag cli-v0.1.2
+python3 scripts/release.py docs
+python3 scripts/release.py docs --check
+python3 tests/release.test.py
+```
+
+发布后把经过验证的 `downloads.json` 传给文档生成器:
+
+```sh
+python3 scripts/release.py docs --manifest /path/to/downloads.json
+```
+
+该命令同时保存 `release/latest.json` 快照,包含发布时固定的目标目录、包清单和仓库地址。`docs --check` 按这个已发布目录验证并生成下载表;`matrix` 命令独立验证当前 `release/targets.json`。因此可以先提交新增架构或包格式的构建配置,已发布下载表仍保留原来真实存在的包;每个产品的稳定 Release 成功后自动合并其条目,无需删除或篡改历史发布记录。独立 CLI 发布不会移除桌面下载;较新的桌面包即使附带较旧 CLI,也不会把 CLI 行降级。清单保留各产品原始标签、下载地址与 `source_commits` 来源提交。
+
+### 签名与发布凭据
+
+Release 上传使用 GitHub Actions 自带的 `GITHUB_TOKEN`。最终发布任务和下载文档写回任务获得 `contents: write`;一般构建任务只读仓库。
+
+初始工作流不导入签名证书,也不配置代码签名 secrets。Windows 包未签名;macOS 仅采用构建工具默认的本地签名方式,不包含 Apple Developer ID 签名或公证。系统可能提示来源未验证,生成的包也不等于已完成商店审核。维护者可在获得自己的证书与账户凭据后添加签名步骤。配置 Apple 签名及公证方式见 [Tauri macOS 签名](https://v2.tauri.app/distribute/sign/macos/),Windows 见 [Tauri Windows 签名](https://v2.tauri.app/distribute/sign/windows/)。
+
+SHA-256 用于检测下载损坏和包清单不一致;它不替代平台代码签名。应用内更新签名另有独立密钥要求,当前流程不配置自动执行更新。
diff --git a/docs/downloads.md b/docs/downloads.md
new file mode 100644
index 0000000..156d32b
--- /dev/null
+++ b/docs/downloads.md
@@ -0,0 +1,44 @@
+
+# 聘渡 · 桌面版与 CLI 下载
+
+
+
+[安装与发布说明](desktop.md) · [CLI 使用指南](cli.md) · [所有 Releases](https://github.com/TokenNotIncluded/hireferry/releases)
+
+**当前展示 CI 配置的构建目标,尚未记录一次完整桌面 Release。** 这些条目不是实机验证记录;完整构建、校验并发布后,工作流会替换为实际下载链接、版本和包大小。
+
+
+| 客户端 | 系统 | 架构 | 软件包 | 下载体积 |
+| --- | --- | --- | --- | --- |
+| 桌面版 | Linux | x64 | .deb、.rpm、.AppImage | 发布后生成 |
+| 桌面版 | Linux | ARM64 | .deb、.rpm、.AppImage | 发布后生成 |
+| 桌面版 | macOS | x64 | .dmg、.app.tar.gz | 发布后生成 |
+| 桌面版 | macOS | ARM64 | .dmg、.app.tar.gz | 发布后生成 |
+| 桌面版 | Windows | x64 | NSIS .exe、.msi | 发布后生成 |
+| 桌面版 | Windows | ARM64 | NSIS .exe | 发布后生成 |
+| 桌面版 | Linux | x64 | .flatpak | 发布后生成 |
+| 桌面版 | Linux | ARM64 | .flatpak | 发布后生成 |
+| hfy CLI | Linux | x64 | .tar.gz | 发布后生成 |
+| hfy CLI | Linux | ARM64 | .tar.gz | 发布后生成 |
+| hfy CLI | macOS | x64 | .tar.gz | 发布后生成 |
+| hfy CLI | macOS | ARM64 | .tar.gz | 发布后生成 |
+| hfy CLI | Windows | x64 | .zip | 发布后生成 |
+| hfy CLI | Windows | ARM64 | .zip | 发布后生成 |
+
+## Linux 选包建议
+
+| 发行版系列 | 桌面版优先选择 | 适用范围 |
+| --- | --- | --- |
+| Ubuntu、Debian、Linux Mint、Pop!_OS、Zorin、elementary | `.deb`;依赖不匹配时用 Flatpak | Ubuntu 22.04 / Debian 12 基线及提供 WebKitGTK 4.1 的后续版本 |
+| Fedora、openSUSE、RHEL / Rocky / Alma 衍生版 | `.rpm`;缺少所需库时用 Flatpak | 安装器依赖检查通过的版本;旧企业版不承诺原生运行 |
+| Arch、Manjaro、EndeavourOS、Gentoo | AppImage 或 Flatpak | AppImage 仍受宿主 glibc 与图形环境限制 |
+| NixOS、不可变桌面、SteamOS | Flatpak | 先配置发行版支持的 Flatpak 服务、运行时和桌面 portal |
+| Alpine / musl 系统 | CLI 使用 musl 包;桌面优先 Flatpak | 原生 glibc 桌面包不适用;Flatpak 可用性取决于发行版配置 |
+
+Linux CLI 的静态 musl 包可用于 glibc 与 musl 发行版;桌面版需要图形会话。Flatpak 首次安装共享运行时需要额外空间,后续应用可复用。
+
+Windows 的 NSIS 包按需下载 WebView2;macOS 使用系统 WebKit。桌面版不附带服务器或数据库,业务页面连接所选 HireFerry 站点。
+
+**兼容范围不是“所有历史发行版本均实测”。** 架构、运行时和系统依赖满足要求才适用;具体要求、安装命令与签名说明见 [桌面版文档](desktop.md)。
+
+维护者:`release/targets.json` 定义下一次构建目标;已发布下载表固定使用 `release/latest.json` 内的目录快照。桌面版与 CLI 按各自的稳定版本更新,旧版本不会覆盖较新的产品条目;修改构建目标不会改写历史包。运行 `python3 scripts/release.py docs --check` 检查生成文件。
diff --git a/docs/i18n/README.en.md b/docs/i18n/README.en.md
index da1e7e0..993ead7 100644
--- a/docs/i18n/README.en.md
+++ b/docs/i18n/README.en.md
@@ -24,7 +24,7 @@
Website ·
Documentation ·
- Download CLI ·
+ Desktop / CLI downloads ·
Report an issue
@@ -96,6 +96,12 @@ Open **http://localhost:3000**. The development configuration listens only on th
Use [config.example.toml](../../config.example.toml) for production. The [self-hosting guide](../self-hosting.md) covers systemd, Caddy, Docker, administrator setup and backups.
+## Desktop downloads
+
+The Tauri 2 desktop client uses the system WebView and connects to the official or a self-hosted HireFerry site. Windows, macOS and Linux packages cover x64 and ARM64; the `hfy` CLI remains independently installable. Linux packaging includes deb, rpm, AppImage and Flatpak.
+
+[Generated download matrix](../downloads.md) · [Desktop installation and release guide](../desktop.md). The matrix shows configured targets until the first complete desktop release, then verified asset links and actual sizes.
+
## CLI and AI
diff --git a/docs/i18n/README.zh-TW.md b/docs/i18n/README.zh-TW.md
index d6734e4..a1741b9 100644
--- a/docs/i18n/README.zh-TW.md
+++ b/docs/i18n/README.zh-TW.md
@@ -24,7 +24,7 @@
官網 ·
文件 ·
- 下載 CLI ·
+ 下載桌面版 / CLI ·
問題回報
@@ -96,6 +96,12 @@ cargo run --locked -- --config config.toml serve
正式部署使用 [config.example.toml](../../config.example.toml)。systemd、Caddy、Docker、管理員初始化與備份請見[自行架設指南](../self-hosting.md)。
+## 桌面版下載
+
+Tauri 2 桌面版使用系統 WebView,連接官方或自架的 HireFerry 站點。Windows、macOS 和 Linux 提供 x64 與 ARM64 套件,`hfy` 使用者 CLI 可獨立安裝。Linux 提供 deb、rpm、AppImage 與 Flatpak。
+
+[自動產生的下載矩陣](../downloads.md) · [桌面安裝與發布說明](../desktop.md)。首次完整桌面 Release 前顯示建置目標,發布成功後顯示實際下載連結與檔案大小。
+
## CLI 與 AI
diff --git a/packaging/flatpak/best.lmm.hireferry.desktop b/packaging/flatpak/best.lmm.hireferry.desktop
new file mode 100644
index 0000000..82764d3
--- /dev/null
+++ b/packaging/flatpak/best.lmm.hireferry.desktop
@@ -0,0 +1,12 @@
+[Desktop Entry]
+Type=Application
+Name=HireFerry
+Comment=Connect with people and opportunities
+Comment[zh_CN]=连接人才与机会
+Comment[zh_TW]=連結人才與機會
+Exec=hireferry-desktop
+Icon=best.lmm.hireferry
+Terminal=false
+Categories=Office;Network;
+StartupNotify=true
+StartupWMClass=hireferry-desktop
diff --git a/packaging/flatpak/best.lmm.hireferry.metainfo.xml.in b/packaging/flatpak/best.lmm.hireferry.metainfo.xml.in
new file mode 100644
index 0000000..30be9e0
--- /dev/null
+++ b/packaging/flatpak/best.lmm.hireferry.metainfo.xml.in
@@ -0,0 +1,26 @@
+
+
+ best.lmm.hireferry
+ HireFerry
+ Connect with people and opportunities
+ 连接人才与机会
+ CC0-1.0
+ AGPL-3.0-or-later
+
+ HireFerry contributors
+
+
+ HireFerry brings your recruitment workspace to the desktop. Browse people
+ and jobs, manage conversations and work with your team through a lightweight
+ application connected to your HireFerry service.
+ The desktop application needs an internet connection or access to a
+ self-hosted HireFerry server. It does not include a local server.
+
+ best.lmm.hireferry.desktop
+ https://hireferry.lmm.best
+ https://github.com/TokenNotIncluded/hireferry/issues
+ https://github.com/TokenNotIncluded/hireferry
+ hireferry-desktop
+ always
+
+
diff --git a/release/targets.json b/release/targets.json
new file mode 100644
index 0000000..1449757
--- /dev/null
+++ b/release/targets.json
@@ -0,0 +1,151 @@
+{
+ "schema_version": 1,
+ "repository": "TokenNotIncluded/hireferry",
+ "desktop": [
+ {
+ "platform": "Linux",
+ "architecture": "x86_64",
+ "target": "x86_64-unknown-linux-gnu",
+ "bundles": "deb,rpm,appimage",
+ "formats": [
+ "deb",
+ "rpm",
+ "AppImage"
+ ],
+ "runner": "ubuntu-22.04"
+ },
+ {
+ "platform": "Linux",
+ "architecture": "aarch64",
+ "target": "aarch64-unknown-linux-gnu",
+ "bundles": "deb,rpm,appimage",
+ "formats": [
+ "deb",
+ "rpm",
+ "AppImage"
+ ],
+ "runner": "ubuntu-22.04-arm"
+ },
+ {
+ "platform": "macOS",
+ "architecture": "x86_64",
+ "target": "x86_64-apple-darwin",
+ "bundles": "dmg,app",
+ "formats": [
+ "dmg",
+ "app.tar.gz"
+ ],
+ "runner": "macos-15-intel"
+ },
+ {
+ "platform": "macOS",
+ "architecture": "aarch64",
+ "target": "aarch64-apple-darwin",
+ "bundles": "dmg,app",
+ "formats": [
+ "dmg",
+ "app.tar.gz"
+ ],
+ "runner": "macos-14"
+ },
+ {
+ "platform": "Windows",
+ "architecture": "x86_64",
+ "target": "x86_64-pc-windows-msvc",
+ "bundles": "nsis,msi",
+ "formats": [
+ "exe",
+ "msi"
+ ],
+ "runner": "windows-2022"
+ },
+ {
+ "platform": "Windows",
+ "architecture": "aarch64",
+ "target": "aarch64-pc-windows-msvc",
+ "bundles": "nsis",
+ "formats": [
+ "exe"
+ ],
+ "runner": "windows-11-arm"
+ }
+ ],
+ "flatpak": [
+ {
+ "platform": "Linux",
+ "architecture": "x86_64",
+ "target": "x86_64-unknown-linux-gnu",
+ "formats": [
+ "flatpak"
+ ],
+ "runner": "ubuntu-24.04",
+ "arch": "x86_64"
+ },
+ {
+ "platform": "Linux",
+ "architecture": "aarch64",
+ "target": "aarch64-unknown-linux-gnu",
+ "formats": [
+ "flatpak"
+ ],
+ "runner": "ubuntu-24.04-arm",
+ "arch": "aarch64"
+ }
+ ],
+ "cli": [
+ {
+ "platform": "Linux",
+ "architecture": "x86_64",
+ "target": "x86_64-unknown-linux-musl",
+ "formats": [
+ "tar.gz"
+ ],
+ "runner": "ubuntu-24.04"
+ },
+ {
+ "platform": "Linux",
+ "architecture": "aarch64",
+ "target": "aarch64-unknown-linux-musl",
+ "formats": [
+ "tar.gz"
+ ],
+ "runner": "ubuntu-24.04-arm"
+ },
+ {
+ "platform": "macOS",
+ "architecture": "x86_64",
+ "target": "x86_64-apple-darwin",
+ "formats": [
+ "tar.gz"
+ ],
+ "runner": "macos-15-intel"
+ },
+ {
+ "platform": "macOS",
+ "architecture": "aarch64",
+ "target": "aarch64-apple-darwin",
+ "formats": [
+ "tar.gz"
+ ],
+ "runner": "macos-14"
+ },
+ {
+ "platform": "Windows",
+ "architecture": "x86_64",
+ "target": "x86_64-pc-windows-msvc",
+ "formats": [
+ "zip"
+ ],
+ "runner": "windows-2022"
+ },
+ {
+ "platform": "Windows",
+ "architecture": "aarch64",
+ "target": "aarch64-pc-windows-msvc",
+ "formats": [
+ "zip"
+ ],
+ "runner": "windows-11-arm"
+ }
+ ]
+}
diff --git a/scripts/assemble-cli-release.py b/scripts/assemble-cli-release.py
index 5414dda..3f3385c 100644
--- a/scripts/assemble-cli-release.py
+++ b/scripts/assemble-cli-release.py
@@ -1,19 +1,18 @@
#!/usr/bin/env python3
"""Publish only a complete, checksum-verified set of CLI packages."""
import hashlib
+import json
from pathlib import Path
import re
import shutil
import sys
-TARGETS = (
- 'x86_64-unknown-linux-musl', 'aarch64-unknown-linux-musl',
- 'x86_64-apple-darwin', 'aarch64-apple-darwin',
- 'x86_64-pc-windows-msvc', 'aarch64-pc-windows-msvc',
-)
+ROOT = Path(__file__).resolve().parents[1]
+TARGETS = tuple(row['target'] for row in
+ json.loads((ROOT/'release/targets.json').read_text())['cli'])
def assemble(source, destination):
- root = Path(__file__).resolve().parents[1]
+ root = ROOT
version = re.search(r'^version = "([^"]+)"$', (root/'crates/hireferry-cli/Cargo.toml').read_text(), re.M)[1]
destination.mkdir(parents=True, exist_ok=False)
lines = []
@@ -32,7 +31,7 @@ def assemble(source, destination):
lines.append(actual+' '+name)
(destination/'SHA256SUMS').write_text('\n'.join(sorted(lines))+'\n')
(destination/'VERSION').write_text(version+'\n')
- print(f'Verified six platforms for hfy {version}')
+ print(f'Verified {len(TARGETS)} platforms for hfy {version}')
if __name__ == '__main__':
assemble(Path(sys.argv[1]), Path(sys.argv[2]))
diff --git a/scripts/package-flatpak.sh b/scripts/package-flatpak.sh
new file mode 100755
index 0000000..e32fac6
--- /dev/null
+++ b/scripts/package-flatpak.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# Package a native Tauri Debian build without installing it on the host.
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: package-flatpak.sh --deb FILE --version VERSION --arch x86_64|aarch64
+ [--out-dir DIR] [--smoke-test]
+
+Requires flatpak, dpkg-deb, Python, a host GdkPixbuf SVG loader (librsvg2-common
+on Debian/Ubuntu), and the installed org.gnome.Platform/Sdk 51 runtimes.
+Always checks dependencies and --version inside the end-user runtime. With
+--smoke-test, also launches the local UI under Xvfb; requires xvfb-run,
+dbus-run-session and timeout. No package, remote or application is installed.
+EOF
+}
+fail() { printf 'Flatpak: %s\n' "$*" >&2; exit 1; }
+deb=''
+version=''
+arch=''
+out_dir=''
+smoke_test=false
+while (($#)); do
+ case "$1" in
+ --deb|--version|--arch|--out-dir)
+ (($# >= 2)) && [[ -n $2 ]] || fail "$1 needs a value"
+ case "$1" in
+ --deb) deb=$2 ;;
+ --version) version=$2 ;;
+ --arch) arch=$2 ;;
+ --out-dir) out_dir=$2 ;;
+ esac
+ shift 2 ;;
+ --smoke-test) smoke_test=true; shift ;;
+ --help|-h) usage; exit 0 ;;
+ *) fail "Unknown option: $1" ;;
+ esac
+done
+[[ -n $deb && -n $version && -n $arch ]] || { usage >&2; exit 1; }
+[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?(\+[A-Za-z0-9.-]+)?$ ]] || fail 'Invalid version'
+case "$arch" in x86_64) deb_arch=amd64 ;; aarch64) deb_arch=arm64 ;; *) fail 'Unsupported architecture' ;; esac
+for command in flatpak dpkg-deb python3; do
+ command -v "$command" >/dev/null 2>&1 || fail "Required command is missing: $command"
+done
+if $smoke_test; then
+ for command in xvfb-run dbus-run-session timeout; do
+ command -v "$command" >/dev/null 2>&1 || fail "Required smoke-test command is missing: $command"
+ done
+fi
+[[ $arch == "$(flatpak --default-arch)" ]] || fail 'Use a native runner for this architecture'
+[[ -f $deb && ! -L $deb ]] || fail 'The Debian package must be a regular file'
+[[ $(dpkg-deb --field "$deb" Architecture) == "$deb_arch" ]] || fail 'Debian package architecture does not match'
+[[ $deb == /* ]] || deb=$PWD/$deb
+[[ -z $out_dir || $out_dir == /* ]] || out_dir=$PWD/$out_dir
+root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
+[[ -n $out_dir ]] || out_dir=$root/dist/flatpak
+[[ ! -L $out_dir ]] || fail 'Output directory must not be a symbolic link'
+mkdir -p -- "$out_dir"
+out_dir=$(cd -- "$out_dir" && pwd -P)
+name=hireferry-desktop-$version-linux-$arch.flatpak
+[[ ! -e $out_dir/$name && ! -L $out_dir/$name ]] || fail 'Output already exists; choose a fresh directory'
+stage=$(mktemp -d "$out_dir/.flatpak-package.XXXXXXXX")
+trap 'rm -rf -- "$stage"' EXIT
+app_id=best.lmm.hireferry
+runtime_version=51
+build=$stage/build
+
+flatpak build-init --arch="$arch" "$build" "$app_id" org.gnome.Sdk org.gnome.Platform "$runtime_version"
+dpkg-deb --extract "$deb" "$stage/deb"
+binary=$stage/deb/usr/bin/hireferry-desktop
+[[ -f $binary && ! -L $binary && -x $binary ]] || fail 'Package has no regular executable usr/bin/hireferry-desktop'
+install -Dm755 "$binary" "$build/files/bin/hireferry-desktop"
+# Tauri can place additional resources under /usr/lib/ or /usr/share.
+# Preserve their relative locations below /app while replacing desktop metadata.
+for directory in lib share; do
+ if [[ -d $stage/deb/usr/$directory ]]; then
+ cp -a -- "$stage/deb/usr/$directory" "$build/files/"
+ fi
+done
+mkdir -p -- "$build/files/share/applications" "$build/files/share/metainfo"
+# Export only our application ID; the Debian bundle uses a different basename.
+find "$build/files/share/applications" -maxdepth 1 -name '*.desktop' -delete
+install -m644 "$root/packaging/flatpak/$app_id.desktop" "$build/files/share/applications/$app_id.desktop"
+install -Dm644 "$root/desktop/src-tauri/icons/icon.svg" "$build/files/share/icons/hicolor/scalable/apps/$app_id.svg"
+install -Dm644 "$root/LICENSE" "$build/files/share/licenses/$app_id/LICENSE"
+python3 - "$root/packaging/flatpak/$app_id.metainfo.xml.in" "$build/files/share/metainfo/$app_id.metainfo.xml" "$version" "$root" <<'PY'
+from datetime import datetime, timezone
+import os
+from pathlib import Path
+import subprocess
+import sys
+
+source, destination, version, root = sys.argv[1:]
+epoch = os.environ.get('SOURCE_DATE_EPOCH') or subprocess.check_output(
+ ['git', '-C', root, 'show', '-s', '--format=%ct', 'HEAD'], text=True).strip()
+date = datetime.fromtimestamp(int(epoch), timezone.utc).date().isoformat()
+Path(destination).write_text(Path(source).read_text().replace('@VERSION@', version).replace('@DATE@', date))
+PY
+
+flatpak build-finish --command=hireferry-desktop \
+ --share=network --share=ipc --socket=wayland --socket=fallback-x11 \
+ --device=dri "$build"
+
+# Use the actual Platform, not the SDK: development packages must not conceal
+# missing end-user libraries. Version mode does not need a display or network.
+flatpak build --runtime --die-with-parent --build-dir=/app --nofilesystem=host --nofilesystem=home \
+ --unshare=network "$build" sh -ec '
+ dependencies=$(LD_TRACE_LOADED_OBJECTS=1 /app/bin/hireferry-desktop)
+ printf "%s\n" "$dependencies"
+ case "$dependencies" in *"not found"*) exit 1 ;; esac
+ reported_version=$(LD_BIND_NOW=1 /app/bin/hireferry-desktop --version)
+ printf "%s\n" "$reported_version"
+ test "$reported_version" = "hireferry-desktop $1"
+ ' sh "$version"
+if $smoke_test; then
+ # Software rendering is a CI-only adjustment. The WebKit sandbox stays on.
+ timeout --kill-after=5s 30s dbus-run-session -- xvfb-run -a \
+ flatpak build --runtime --die-with-parent --build-dir=/app --nofilesystem=host --nofilesystem=home \
+ --unshare=network --socket=x11 --share=ipc --device=dri \
+ --env=GDK_BACKEND=x11 --env=LIBGL_ALWAYS_SOFTWARE=1 \
+ --env=WEBKIT_DISABLE_DMABUF_RENDERER=1 \
+ --env=XDG_CONFIG_HOME=/tmp/hireferry-smoke/config \
+ --env=XDG_CACHE_HOME=/tmp/hireferry-smoke/cache \
+ --env=XDG_DATA_HOME=/tmp/hireferry-smoke/data \
+ "$build" /app/bin/hireferry-desktop --smoke-test
+fi
+
+flatpak build-export --arch="$arch" "$stage/repo" "$build" stable
+flatpak build-bundle --arch="$arch" \
+ --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \
+ "$stage/repo" "$stage/$name" "$app_id" stable
+ln -- "$stage/$name" "$out_dir/$name"
+printf 'Created: %s/%s\n' "$out_dir" "$name"
diff --git a/scripts/release.py b/scripts/release.py
new file mode 100644
index 0000000..cbe9447
--- /dev/null
+++ b/scripts/release.py
@@ -0,0 +1,484 @@
+#!/usr/bin/env python3
+"""One release catalog drives CI, verified assets, Markdown, JSON and SVG.
+
+Uses Python's standard library. Build jobs have no publishing privileges; only a
+complete, checksum-verified set can be assembled for the publishing job.
+"""
+import argparse
+import hashlib
+import html
+import json
+from pathlib import Path
+import re
+import shutil
+import sys
+import tarfile
+
+ROOT = Path(__file__).resolve().parents[1]
+CATALOG = ROOT / 'release/targets.json'
+SNAPSHOT = ROOT / 'release/latest.json'
+VERSION_RE = re.compile(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?')
+REPOSITORY_RE = re.compile(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+')
+ARCH_LABELS = {'x86_64':'x64', 'aarch64':'ARM64'}
+LABELS = {'exe':'NSIS .exe', 'msi':'.msi', 'dmg':'.dmg', 'app.tar.gz':'.app.tar.gz',
+ 'deb':'.deb', 'rpm':'.rpm', 'AppImage':'.AppImage', 'flatpak':'.flatpak',
+ 'tar.gz':'.tar.gz', 'zip':'.zip'}
+
+
+def version(value):
+ match = VERSION_RE.fullmatch(value) if isinstance(value, str) else None
+ if not match or any(p.isdigit() and len(p) > 1 and p[0] == '0' for p in (match[4] or '').split('.')):
+ raise ValueError(f'Invalid release version: {value!r}')
+ return value
+
+
+def repository(value):
+ if not isinstance(value, str) or not REPOSITORY_RE.fullmatch(value) or '..' in value:
+ raise ValueError('Invalid GitHub repository')
+ return value
+
+
+def validate_catalog(data):
+ """Validate both checked-in targets and lower-trust published snapshots."""
+ if not isinstance(data, dict) or data.get('schema_version') != 1:
+ raise ValueError('Unsupported release catalog schema')
+ if set(data) != {'schema_version', 'repository', 'desktop', 'flatpak', 'cli'}:
+ raise ValueError('Unexpected release catalog fields')
+ repository(data['repository'])
+ native_formats = {'Windows':{'exe','msi'}, 'macOS':{'dmg','app.tar.gz'},
+ 'Linux':{'deb','rpm','AppImage'}}
+ system_marker = {'Windows':'-pc-windows-', 'macOS':'-apple-darwin', 'Linux':'-unknown-linux-'}
+ bundle_name = {'exe':'nsis', 'msi':'msi', 'dmg':'dmg', 'app.tar.gz':'app',
+ 'deb':'deb', 'rpm':'rpm', 'AppImage':'appimage'}
+ for group in ('desktop', 'flatpak', 'cli'):
+ rows = data[group]
+ if not isinstance(rows, list):
+ raise ValueError('Catalog targets must be lists')
+ targets = set()
+ for row in rows:
+ required = {'runner', 'platform', 'architecture', 'target', 'formats'}
+ if group == 'desktop': required.add('bundles')
+ if group == 'flatpak': required.add('arch')
+ if not isinstance(row, dict) or set(row) != required:
+ raise ValueError('Unexpected target fields')
+ if not isinstance(row['platform'], str) or row['platform'] not in native_formats:
+ raise ValueError('Invalid target platform')
+ arch, target = row['architecture'], row['target']
+ if not isinstance(arch, str) or not re.fullmatch(r'[a-z0-9_]{1,32}', arch):
+ raise ValueError('Invalid target architecture')
+ if (not isinstance(target, str) or not re.fullmatch(r'[a-z0-9_]+(?:-[a-z0-9_]+)+', target)
+ or not target.startswith(arch + '-') or system_marker[row['platform']] not in target):
+ raise ValueError('Invalid target triple')
+ if target in targets:
+ raise ValueError(f'Duplicate {group} target')
+ targets.add(target)
+ if not isinstance(row['runner'], str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,79}', row['runner']):
+ raise ValueError('Invalid runner')
+ formats = row['formats']
+ if (not isinstance(formats, list) or not formats or
+ any(not isinstance(fmt, str) or fmt not in LABELS for fmt in formats) or
+ len(formats) != len(set(formats))):
+ raise ValueError('Unknown or duplicate package format')
+ allowed = native_formats[row['platform']] if group == 'desktop' else (
+ {'flatpak'} if group == 'flatpak' else {'zip' if row['platform'] == 'Windows' else 'tar.gz'})
+ if not set(formats) <= allowed:
+ raise ValueError('Package format does not match platform')
+ if group == 'flatpak' and (row['platform'] != 'Linux' or row['arch'] != arch):
+ raise ValueError('Invalid Flatpak architecture')
+ if group == 'desktop':
+ bundles = row['bundles']
+ if (not isinstance(bundles, str) or len(bundles.split(',')) != len(formats) or
+ set(bundles.split(',')) != {bundle_name[fmt] for fmt in formats}):
+ raise ValueError('Bundle list does not match formats')
+ return data
+
+
+def catalog():
+ return validate_catalog(json.loads(CATALOG.read_text()))
+
+
+def package_version(product):
+ path = ROOT / ('desktop/src-tauri/Cargo.toml' if product == 'desktop' else 'crates/hireferry-cli/Cargo.toml')
+ match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', path.read_text(), re.M)
+ if not match:
+ raise ValueError(f'Missing package version in {path}')
+ return version(match[1])
+
+
+def filename(product, release_version, target, fmt):
+ return f'hireferry-{product}-{version(release_version)}-{target}.{fmt}'
+
+
+def expected(data, versions, products=('desktop', 'cli')):
+ result = {}
+ for group in ('desktop', 'flatpak', 'cli'):
+ product = 'cli' if group == 'cli' else 'desktop'
+ if product not in products:
+ continue
+ for row in data[group]:
+ for fmt in row['formats']:
+ name = filename(product, versions[product], row['target'], fmt)
+ result[name] = {k:row[k] for k in ('platform', 'architecture', 'target')}
+ result[name].update(product=product, version=versions[product], format=fmt)
+ return result
+
+
+def digest(path):
+ result = hashlib.sha256()
+ with path.open('rb') as file:
+ for chunk in iter(lambda: file.read(1024 * 1024), b''):
+ result.update(chunk)
+ return result.hexdigest()
+
+
+def checksums(folder):
+ files = sorted(p for p in folder.iterdir() if p.is_file() and p.name != 'SHA256SUMS')
+ (folder / 'SHA256SUMS').write_text(''.join(f'{digest(p)} {p.name}\n' for p in files))
+
+
+def find_target(data, group, target):
+ return next((row for row in data[group] if row['target'] == target), None)
+
+
+def stage_desktop(args):
+ row = find_target(catalog(), 'desktop', args.target)
+ if row is None:
+ raise ValueError('Target is not in the desktop catalog')
+ release_version = version(args.version or package_version('desktop'))
+ sources = {}
+ for fmt in row['formats']:
+ pattern = '*.app' if fmt == 'app.tar.gz' else '*.' + fmt
+ found = list(args.bundle_dir.rglob(pattern))
+ if len(found) != 1 or found[0].is_symlink():
+ raise ValueError(f'Expected exactly one {fmt} bundle, got {found}')
+ source = found[0]
+ if (fmt == 'app.tar.gz' and not source.is_dir()) or (fmt != 'app.tar.gz' and (not source.is_file() or source.stat().st_size == 0)):
+ raise ValueError(f'Invalid bundle: {source}')
+ sources[fmt] = source
+ args.out.mkdir(parents=True, exist_ok=False)
+ for fmt, source in sources.items():
+ destination = args.out / filename('desktop', release_version, args.target, fmt)
+ if fmt == 'app.tar.gz':
+ with tarfile.open(destination, 'w:gz') as archive:
+ archive.add(source, arcname=source.name)
+ else:
+ shutil.copyfile(source, destination)
+ checksums(args.out)
+
+
+def stage_flatpak(args):
+ if not find_target(catalog(), 'flatpak', args.target):
+ raise ValueError('Target is not in the Flatpak catalog')
+ if not args.binary.is_file() or args.binary.is_symlink() or args.binary.stat().st_size == 0:
+ raise ValueError('Flatpak bundle must be a nonempty regular file')
+ release_version = version(args.version or package_version('desktop'))
+ args.out.mkdir(parents=True, exist_ok=False)
+ shutil.copyfile(args.binary, args.out / filename('desktop', release_version, args.target, 'flatpak'))
+ checksums(args.out)
+
+
+def verified_inputs(source, wanted):
+ """Reject missing/extra packages, duplicate assets, traversal and stale digests."""
+ found = {}
+ for checksum in sorted(source.rglob('SHA256SUMS')):
+ if checksum.is_symlink():
+ raise ValueError('Checksum manifest must be a regular file')
+ for line in checksum.read_text().splitlines():
+ match = re.fullmatch(r'([0-9a-fA-F]{64}) [ *]([^/\\]+)', line)
+ if not match:
+ raise ValueError(f'Malformed checksum in {checksum}')
+ name = match[2]
+ if name not in wanted or name in found:
+ raise ValueError(f'Unexpected or duplicate package: {name}')
+ package = checksum.parent / name
+ if not package.is_file() or package.is_symlink() or package.stat().st_size == 0:
+ raise ValueError(f'Missing/invalid package: {name}')
+ if digest(package) != match[1].lower():
+ raise ValueError(f'Checksum mismatch: {name}')
+ found[name] = package
+ declared = {p.name for p in found.values() if p.parent == checksum.parent}
+ actual = {p.name for p in checksum.parent.iterdir() if p.name != 'SHA256SUMS'}
+ if actual != declared:
+ raise ValueError(f'Unmanifested files in {checksum.parent}: {sorted(actual - declared)}')
+ if set(found) != set(wanted):
+ raise ValueError(f'Incomplete release; missing: {sorted(set(wanted) - set(found))}')
+ all_files = {p for p in source.rglob('*') if p.is_file() and p.name != 'SHA256SUMS'}
+ if all_files != set(found.values()):
+ raise ValueError('Unmanifested files in input artifact tree')
+ return found
+
+
+def make_manifest(data, wanted, files, tag, repo, commit=''):
+ published_catalog = json.loads(json.dumps(data))
+ published_catalog['repository'] = repository(repo)
+ validate_catalog(published_catalog)
+ products = {item['product'] for item in wanted.values()}
+ for group in ('desktop', 'flatpak', 'cli'):
+ if ('cli' if group == 'cli' else 'desktop') not in products:
+ published_catalog[group] = []
+ assets = []
+ for name, item in sorted(wanted.items()):
+ assets.append(dict(item, name=name, size=files[name].stat().st_size,
+ sha256=digest(files[name]), tag=tag,
+ url=f'https://github.com/{repo}/releases/download/{tag}/{name}'))
+ return {'schema_version':1, 'repository':repo, 'catalog':published_catalog, 'source_commit':commit,
+ 'source_commits':{product:commit for product in sorted(products)},
+ 'versions':{a['product']:a['version'] for a in assets},
+ 'releases':{a['product']:tag for a in assets}, 'assets':assets}
+
+
+def validate_manifest(manifest):
+ if not isinstance(manifest, dict) or manifest.get('schema_version') != 1:
+ raise ValueError('Unsupported download manifest schema')
+ repo = repository(manifest['repository'])
+ data = validate_catalog(manifest['catalog'])
+ if data['repository'] != repo:
+ raise ValueError('Snapshot catalog repository mismatch')
+ if not isinstance(manifest['versions'], dict) or not isinstance(manifest['releases'], dict):
+ raise ValueError('Invalid product metadata')
+ products = set(manifest['versions'])
+ if not products or products - {'desktop', 'cli'}:
+ raise ValueError('Unknown manifest products')
+ if set(manifest['releases']) != products:
+ raise ValueError('Release products differ from version products')
+ represented = {('cli' if group == 'cli' else 'desktop') for group in ('desktop', 'flatpak', 'cli') if data[group]}
+ if represented != products:
+ raise ValueError('Catalog products differ from published products')
+ commits = manifest.get('source_commits', {product:manifest.get('source_commit', '') for product in products})
+ if (not isinstance(commits, dict) or set(commits) != products or
+ any(not isinstance(commit, str) or not re.fullmatch(r'[0-9a-f]{0,64}', commit) for commit in commits.values())):
+ raise ValueError('Invalid per-product source commits')
+ for value in manifest['versions'].values():
+ version(value)
+ wanted = expected(data, manifest['versions'], products)
+ assets = manifest['assets']
+ if (not isinstance(assets, list) or any(not isinstance(a, dict) or not isinstance(a.get('name'), str) for a in assets) or
+ len(assets) != len(wanted) or {a.get('name') for a in assets} != set(wanted)):
+ raise ValueError('Manifest does not match the complete release catalog')
+ for asset in assets:
+ name = asset['name']
+ if any(asset.get(k) != v for k, v in wanted[name].items()):
+ raise ValueError(f'Incorrect asset metadata: {name}')
+ tag = asset['tag']
+ if not isinstance(tag, str) or not re.fullmatch(r'(desktop|cli)-v[^/]+', tag):
+ raise ValueError('Invalid release tag')
+ version(tag.split('-v', 1)[1])
+ if tag != manifest['releases'][asset['product']]:
+ raise ValueError('Release tag differs from product metadata')
+ if ((asset['product'] == 'desktop' and tag != 'desktop-v' + asset['version']) or
+ (asset['product'] == 'cli' and tag.startswith('cli-') and tag != 'cli-v' + asset['version'])):
+ raise ValueError('Release tag version mismatch')
+ if asset['url'] != f'https://github.com/{repo}/releases/download/{tag}/{name}':
+ raise ValueError('Invalid asset download URL')
+ if not isinstance(asset['size'], int) or isinstance(asset['size'], bool) or asset['size'] <= 0:
+ raise ValueError('Invalid package size')
+ if not isinstance(asset['sha256'], str) or not re.fullmatch(r'[0-9a-f]{64}', asset['sha256']):
+ raise ValueError('Invalid package checksum')
+ return manifest
+
+
+def stable_version(value):
+ if '-' in version(value):
+ raise ValueError('Only stable releases update the download documentation')
+ return tuple(map(int, value.split('.')))
+
+
+def merge_manifests(current, incoming):
+ """Keep the newest stable release of each independently versioned product."""
+ validate_manifest(incoming)
+ if current:
+ validate_manifest(current)
+ if current['repository'] != incoming['repository']:
+ raise ValueError('Cannot merge releases from different repositories')
+ selected = {}
+ for source in (current, incoming):
+ if source is None:
+ continue
+ for product, product_version in source['versions'].items():
+ candidate = stable_version(product_version)
+ if product not in selected or candidate > stable_version(selected[product]['versions'][product]):
+ selected[product] = source
+ repo = incoming['repository']
+ merged = {'schema_version':1, 'repository':repo,
+ 'catalog':{'schema_version':1, 'repository':repo, 'desktop':[], 'flatpak':[], 'cli':[]},
+ 'source_commit':'', 'source_commits':{}, 'versions':{}, 'releases':{}, 'assets':[]}
+ for product in ('desktop', 'cli'):
+ if product not in selected:
+ continue
+ source = selected[product]
+ merged['versions'][product] = source['versions'][product]
+ merged['releases'][product] = source['releases'][product]
+ merged['source_commits'][product] = source.get('source_commits', {}).get(product, source.get('source_commit', ''))
+ for group in (('desktop', 'flatpak') if product == 'desktop' else ('cli',)):
+ merged['catalog'][group] = source['catalog'][group]
+ merged['assets'].extend(asset for asset in source['assets'] if asset['product'] == product)
+ merged['assets'].sort(key=lambda asset: asset['name'])
+ commits = set(merged['source_commits'].values())
+ if len(commits) == 1:
+ merged['source_commit'] = next(iter(commits))
+ return validate_manifest(json.loads(json.dumps(merged)))
+
+
+def human_size(size):
+ return f'{size / 1048576:.2f} MiB' if size >= 1048576 else f'{size / 1024:.1f} KiB'
+
+
+def svg(data, manifest=None):
+ """Accessible, repository-native vector artwork; values come from the catalog."""
+ if manifest: data = manifest['catalog']
+ count = len(manifest['assets']) if manifest else sum(len(row['formats']) for group in ('desktop','flatpak','cli') for row in data[group])
+ subtitle = ('RELEASE ASSETS VERIFIED · ' + ' / '.join(dict.fromkeys(manifest['releases'][p] for p in ('desktop', 'cli') if p in manifest['releases']))) if manifest else 'BUILD TARGETS · DOWNLOADS APPEAR AFTER A COMPLETE RELEASE'
+ subtitle = html.escape(subtitle)
+ headline = 'HireFerry · Every desktop, one ferry.' if not manifest or 'desktop' in manifest['versions'] else 'HireFerry · One CLI. Every platform.'
+ cards = []
+ for i, (platform, symbol) in enumerate((('Windows','window'),('macOS','apple'),('Linux','terminal'))):
+ x = 36 + i * 302
+ if symbol == 'window':
+ mark = f' '
+ elif symbol == 'apple':
+ mark = f' '
+ else:
+ mark = f' '
+ desktop_rows = [row for group in ('desktop', 'flatpak') for row in data[group] if row['platform'] == platform]
+ cli_rows = [row for row in data['cli'] if row['platform'] == platform]
+ rows = desktop_rows or cli_rows
+ formats = ' · '.join(dict.fromkeys('Flatpak' if fmt == 'flatpak' else LABELS[fmt] for row in rows for fmt in row['formats'])) or 'No release assets'
+ architectures = ' + '.join(dict.fromkeys(ARCH_LABELS.get(row['architecture'], row['architecture']) for row in desktop_rows + cli_rows))
+ products = ' + '.join(label for label, present in (('Desktop', desktop_rows), ('hfy CLI', cli_rows)) if present) or 'No published packages'
+ cards.append(f' {mark}{platform} {html.escape(architectures)} {html.escape(formats)} {products} ')
+ return f'''
+HireFerry release matrix Package availability by operating system and architecture; {count} package variants. {'Published package checksums are verified.' if manifest else 'Configured build targets, not a claim of published or tested packages.'}
+
+
+
+
+{headline}
+{subtitle}
+{''.join(cards)}
+{count} package variants · SHA-256 verification · one release catalog
+
+'''
+
+
+def markdown(data, manifest=None, image='assets/release-matrix.svg', docs_url=''):
+ if manifest: data = manifest['catalog']
+ repo = manifest['repository'] if manifest else data['repository']
+ guide = docs_url + 'desktop.md'
+ cli_guide = docs_url + 'cli.md'
+ lines = ['',
+ '# 聘渡 · 桌面版与 CLI 下载', '', f'', '',
+ f'[安装与发布说明]({guide}) · [CLI 使用指南]({cli_guide}) · '
+ f'[所有 Releases](https://github.com/{repo}/releases)', '']
+ if manifest:
+ lines += ['下表来自已汇总校验的发布资产;大小为实际下载体积,SHA-256 见对应 Release 的 `SHA256SUMS`。', '']
+ if 'desktop' not in manifest['versions']:
+ lines += ['**尚未收录已发布的桌面安装包。** 当前下载表只包含 hfy CLI;桌面构建目标见 `release/targets.json`。', '']
+ for product in ('desktop','cli'):
+ if product in manifest['versions']:
+ tag = manifest['releases'][product]
+ lines.append(f"- **{'桌面版' if product == 'desktop' else 'hfy CLI'} {manifest['versions'][product]}**:[Release](https://github.com/{repo}/releases/tag/{tag}) · [校验文件](https://github.com/{repo}/releases/download/{tag}/SHA256SUMS)")
+ else:
+ lines += ['**当前展示 CI 配置的构建目标,尚未记录一次完整桌面 Release。** 这些条目不是实机验证记录;完整构建、校验并发布后,工作流会替换为实际下载链接、版本和包大小。', '']
+ lines += ['', '| 客户端 | 系统 | 架构 | 软件包 | 下载体积 |', '| --- | --- | --- | --- | --- |']
+ if manifest:
+ for a in sorted(manifest['assets'], key=lambda a:(a['product'],a['platform'],a['architecture'],a['format'])):
+ lines.append(f"| {'桌面版' if a['product'] == 'desktop' else 'hfy CLI'} | {a['platform']} | {ARCH_LABELS.get(a['architecture'], a['architecture'])} | [{LABELS[a['format']]}]({a['url']}) | {human_size(a['size'])} |")
+ else:
+ for group in ('desktop','flatpak','cli'):
+ for row in data[group]:
+ lines.append(f"| {'hfy CLI' if group == 'cli' else '桌面版'} | {row['platform']} | {ARCH_LABELS.get(row['architecture'], row['architecture'])} | {'、'.join(LABELS[f] for f in row['formats'])} | 发布后生成 |")
+ lines += ['', '## Linux 选包建议', '',
+ '| 发行版系列 | 桌面版优先选择 | 适用范围 |', '| --- | --- | --- |',
+ '| Ubuntu、Debian、Linux Mint、Pop!_OS、Zorin、elementary | `.deb`;依赖不匹配时用 Flatpak | Ubuntu 22.04 / Debian 12 基线及提供 WebKitGTK 4.1 的后续版本 |',
+ '| Fedora、openSUSE、RHEL / Rocky / Alma 衍生版 | `.rpm`;缺少所需库时用 Flatpak | 安装器依赖检查通过的版本;旧企业版不承诺原生运行 |',
+ '| Arch、Manjaro、EndeavourOS、Gentoo | AppImage 或 Flatpak | AppImage 仍受宿主 glibc 与图形环境限制 |',
+ '| NixOS、不可变桌面、SteamOS | Flatpak | 先配置发行版支持的 Flatpak 服务、运行时和桌面 portal |',
+ '| Alpine / musl 系统 | CLI 使用 musl 包;桌面优先 Flatpak | 原生 glibc 桌面包不适用;Flatpak 可用性取决于发行版配置 |',
+ '', 'Linux CLI 的静态 musl 包可用于 glibc 与 musl 发行版;桌面版需要图形会话。Flatpak 首次安装共享运行时需要额外空间,后续应用可复用。',
+ '', 'Windows 的 NSIS 包按需下载 WebView2;macOS 使用系统 WebKit。桌面版不附带服务器或数据库,业务页面连接所选 HireFerry 站点。',
+ '', f'**兼容范围不是“所有历史发行版本均实测”。** 架构、运行时和系统依赖满足要求才适用;具体要求、安装命令与签名说明见 [桌面版文档]({guide})。',
+ '', '维护者:`release/targets.json` 定义下一次构建目标;已发布下载表固定使用 `release/latest.json` 内的目录快照。桌面版与 CLI 按各自的稳定版本更新,旧版本不会覆盖较新的产品条目;修改构建目标不会改写历史包。运行 `python3 scripts/release.py docs --check` 检查生成文件。', '']
+ return '\n'.join(lines)
+
+
+def assemble(args, products=('desktop', 'cli')):
+ data = catalog()
+ versions = {product:package_version(product) for product in products}
+ primary = 'desktop' if 'desktop' in products else 'cli'
+ if args.tag != primary + '-v' + versions[primary]:
+ raise ValueError(f'Release tag must match the {primary} Cargo package version')
+ repo = repository(args.repository or data['repository'])
+ wanted = expected(data, versions, products)
+ files = verified_inputs(args.source, wanted)
+ manifest = validate_manifest(make_manifest(data, wanted, files, args.tag, repo, args.commit))
+ args.destination.mkdir(parents=True, exist_ok=False)
+ for name, path in files.items():
+ shutil.copyfile(path, args.destination/name)
+ (args.destination/'VERSION').write_text(versions['cli'] + '\n')
+ (args.destination/'downloads.json').write_text(json.dumps(manifest, indent=2) + '\n')
+ (args.destination/'downloads.md').write_text(markdown(data, manifest, f'https://github.com/{repo}/releases/download/{args.tag}/release-matrix.svg', f'https://github.com/{repo}/blob/{args.tag}/docs/'))
+ (args.destination/'release-matrix.svg').write_text(svg(data, manifest))
+ checksums(args.destination)
+ print(f'Verified {len(files)} package assets for {args.tag}')
+
+
+def assemble_cli(args):
+ assemble(args, ('cli',))
+
+
+def docs(args):
+ manifest_path = args.manifest or (SNAPSHOT if SNAPSHOT.exists() else None)
+ manifest = validate_manifest(json.loads(manifest_path.read_text())) if manifest_path else None
+ if args.manifest:
+ current = json.loads(SNAPSHOT.read_text()) if SNAPSHOT.exists() else None
+ manifest = merge_manifests(current, manifest)
+ data = manifest['catalog'] if manifest else catalog()
+ outputs = {ROOT/'docs/downloads.md':markdown(data, manifest), ROOT/'docs/assets/release-matrix.svg':svg(data, manifest)}
+ if args.manifest:
+ outputs[SNAPSHOT] = json.dumps(manifest, indent=2) + '\n'
+ stale = []
+ for path, content in outputs.items():
+ if args.check:
+ if not path.exists() or path.read_text() != content:
+ stale.append(str(path.relative_to(ROOT)))
+ else:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+ if stale:
+ raise ValueError('Regenerate release docs: ' + ', '.join(stale))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest='command', required=True)
+ matrix = commands.add_parser('matrix', help='Emit a GitHub Actions strategy matrix')
+ matrix.add_argument('product', choices=('desktop','cli','flatpak'))
+ matrix.set_defaults(run=lambda a: print(json.dumps({'include':catalog()[a.product]}, separators=(',',':'))))
+ for command, fn in (('stage-desktop',stage_desktop),('stage-flatpak',stage_flatpak)):
+ stage = commands.add_parser(command)
+ stage.add_argument('--target', required=True)
+ stage.add_argument('--version')
+ stage.add_argument('--out', type=Path, required=True)
+ stage.add_argument('--bundle-dir' if command == 'stage-desktop' else '--binary', type=Path, required=True)
+ stage.set_defaults(run=fn)
+ for command, fn in (('assemble', assemble), ('assemble-cli', assemble_cli)):
+ build = commands.add_parser(command)
+ build.add_argument('source', type=Path)
+ build.add_argument('destination', type=Path)
+ build.add_argument('--tag', required=True)
+ build.add_argument('--repository')
+ build.add_argument('--commit', default='')
+ build.set_defaults(run=fn)
+ generate = commands.add_parser('docs')
+ generate.add_argument('--manifest', type=Path)
+ generate.add_argument('--check', action='store_true')
+ generate.set_defaults(run=docs)
+ args = parser.parse_args()
+ try:
+ args.run(args)
+ except (ValueError, KeyError, OSError) as error:
+ parser.exit(1, f'release: {error}\n')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tests/cli-scripts.test.py b/tests/cli-scripts.test.py
index 2f1e1ee..c41516b 100644
--- a/tests/cli-scripts.test.py
+++ b/tests/cli-scripts.test.py
@@ -178,6 +178,9 @@ def setUp(self):
self.root = Path(self.temp.name)
(self.root / "scripts").mkdir()
shutil.copy2(REPO / "scripts/assemble-cli-release.py", self.root / "scripts")
+ (self.root / "release").mkdir()
+ shutil.copy2(REPO / "release/targets.json", self.root / "release/targets.json")
+ self.output = self.root / "assembled"
manifest = self.root / "crates/hireferry-cli/Cargo.toml"
manifest.parent.mkdir(parents=True)
manifest.write_text('version = "1.2.3"\n')
@@ -199,13 +202,13 @@ def tearDown(self):
def assemble(self):
return subprocess.run(["python3", str(self.root / "scripts/assemble-cli-release.py"),
- str(self.packages), str(self.root / "release")],
+ str(self.packages), str(self.output)],
capture_output=True, text=True, timeout=15)
def test_native_text_and_binary_checksum_formats_publish_one_canonical_manifest(self):
result = self.assemble()
self.assertEqual(result.returncode, 0, result.stderr)
- output = self.root / "release"
+ output = self.output
self.assertEqual((output / "VERSION").read_text(), "1.2.3\n")
lines = (output / "SHA256SUMS").read_text().splitlines()
self.assertEqual(len(lines), 6)
@@ -219,8 +222,8 @@ def test_tampered_archive_prevents_a_publishable_release(self):
result = self.assemble()
self.assertNotEqual(result.returncode, 0)
self.assertIn("Invalid checksums", result.stderr)
- self.assertFalse((self.root / "release/VERSION").exists())
- self.assertFalse((self.root / "release/SHA256SUMS").exists())
+ self.assertFalse((self.output / "VERSION").exists())
+ self.assertFalse((self.output / "SHA256SUMS").exists())
if __name__ == "__main__":
diff --git a/tests/release.test.py b/tests/release.test.py
new file mode 100644
index 0000000..41ea59e
--- /dev/null
+++ b/tests/release.test.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Exercise the release gate with fake package bytes; no platform builds required."""
+import argparse
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+import xml.etree.ElementTree as ET
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location('release', ROOT/'scripts/release.py')
+release = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(release)
+
+
+class ReleaseTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ self.data = release.catalog()
+ self.versions = {'desktop':'0.1.0','cli':'0.1.2'}
+ self.wanted = release.expected(self.data, self.versions)
+ self.source = self.root/'artifacts'
+ for i, name in enumerate(self.wanted):
+ folder = self.source/f'job-{i}'
+ folder.mkdir(parents=True)
+ (folder/name).write_bytes(('real bytes for '+name).encode())
+ release.checksums(folder)
+
+ def test_complete_release_has_exact_links_hashes_and_sizes(self):
+ files = release.verified_inputs(self.source, self.wanted)
+ self.assertEqual(len(files), 21)
+ manifest = release.make_manifest(self.data, self.wanted, files, 'desktop-v0.1.0', self.data['repository'])
+ self.assertEqual(release.validate_manifest(manifest), manifest)
+ output = release.markdown(self.data, manifest)
+ self.assertNotIn('发布后生成', output)
+ for item in manifest['assets']:
+ self.assertIn(item['url'], output)
+ self.assertEqual(item['size'], files[item['name']].stat().st_size)
+ self.assertEqual(item['sha256'], release.digest(files[item['name']]))
+ ET.fromstring(release.svg(self.data, manifest))
+
+ def test_missing_platform_cannot_publish(self):
+ folder = next(self.source.iterdir())
+ for path in folder.iterdir(): path.unlink()
+ folder.rmdir()
+ with self.assertRaisesRegex(ValueError, 'Incomplete release'):
+ release.verified_inputs(self.source, self.wanted)
+
+ def test_corrupted_package_cannot_publish(self):
+ next(p for p in self.source.rglob('*') if p.is_file() and p.name!='SHA256SUMS').write_bytes(b'corrupt')
+ with self.assertRaisesRegex(ValueError, 'Checksum mismatch'):
+ release.verified_inputs(self.source, self.wanted)
+
+ def test_unmanifested_file_cannot_publish(self):
+ (self.source/'leftover.zip').write_bytes(b'old release')
+ with self.assertRaisesRegex(ValueError, 'Unmanifested files'):
+ release.verified_inputs(self.source, self.wanted)
+
+ def test_duplicate_package_cannot_publish(self):
+ folder = next(self.source.iterdir())
+ duplicate = self.source/'duplicate'
+ duplicate.mkdir()
+ for p in folder.iterdir(): (duplicate/p.name).write_bytes(p.read_bytes())
+ with self.assertRaisesRegex(ValueError, 'duplicate package'):
+ release.verified_inputs(self.source, self.wanted)
+
+ def test_traversal_checksum_is_rejected(self):
+ (next(self.source.iterdir())/'SHA256SUMS').write_text('0'*64+' ../bad.zip\n')
+ with self.assertRaisesRegex(ValueError, 'Malformed checksum'):
+ release.verified_inputs(self.source, self.wanted)
+
+ def test_windows_binary_mode_checksums_are_supported(self):
+ for file in self.source.rglob('SHA256SUMS'):
+ file.write_text(file.read_text().replace(' ',' *'))
+ self.assertEqual(len(release.verified_inputs(self.source, self.wanted)),21)
+
+ def test_bad_manifest_url_and_metadata_are_rejected(self):
+ files = release.verified_inputs(self.source,self.wanted)
+ base = release.make_manifest(self.data,self.wanted,files,'desktop-v0.1.0',self.data['repository'])
+ for key, value in (('url','https://example.test/fake'),('architecture','riscv64'),('size',0),('tag','desktop-v9.0.0')):
+ manifest = json.loads(json.dumps(base))
+ manifest['assets'][0][key] = value
+ with self.subTest(key=key), self.assertRaises(ValueError):
+ release.validate_manifest(manifest)
+
+ def test_failed_assembly_writes_no_partial_release(self):
+ args=argparse.Namespace(source=self.source,destination=self.root/'out',tag='desktop-v0.1.0',repository=None,commit='abc')
+ next(self.source.rglob('SHA256SUMS')).write_text('bad')
+ with patch.object(release,'package_version',side_effect=lambda product:self.versions[product]), self.assertRaises(ValueError):
+ release.assemble(args)
+ self.assertFalse(args.destination.exists())
+
+ def test_assembly_preserves_cli_version_and_hashes_metadata(self):
+ args=argparse.Namespace(source=self.source,destination=self.root/'out',tag='desktop-v0.1.0',repository=None,commit='abc')
+ with patch.object(release,'package_version',side_effect=lambda product:self.versions[product]): release.assemble(args)
+ self.assertEqual((args.destination/'VERSION').read_text(),'0.1.2\n')
+ sums=(args.destination/'SHA256SUMS').read_text()
+ for name in ('VERSION','downloads.md','downloads.json','release-matrix.svg'):
+ self.assertIn(release.digest(args.destination/name)+' '+name,sums)
+ self.assertEqual(json.loads((args.destination/'downloads.json').read_text())['source_commit'],'abc')
+
+ def test_macos_app_archive_is_created_without_updater(self):
+ bundles=self.root/'bundle'; app=bundles/'macos/HireFerry.app/Contents/MacOS'
+ app.mkdir(parents=True); (app/'hireferry-desktop').write_bytes(b'binary')
+ (bundles/'HireFerry.dmg').write_bytes(b'dmg')
+ args=argparse.Namespace(target='aarch64-apple-darwin',version='0.1.0',bundle_dir=bundles,out=self.root/'stage')
+ release.stage_desktop(args)
+ archive=next(args.out.glob('*.app.tar.gz'))
+ with release.tarfile.open(archive) as contents:
+ self.assertIn('HireFerry.app/Contents/MacOS/hireferry-desktop',contents.getnames())
+
+ def test_published_catalog_survives_new_planned_formats_and_targets(self):
+ files = release.verified_inputs(self.source, self.wanted)
+ manifest = release.make_manifest(self.data, self.wanted, files, 'desktop-v0.1.0', self.data['repository'])
+ planned = json.loads(json.dumps(self.data))
+ arm_windows = next(row for row in planned['desktop'] if row['target'] == 'aarch64-pc-windows-msvc')
+ arm_windows['formats'].append('msi')
+ arm_windows['bundles'] += ',msi'
+ planned['cli'].append(dict(planned['cli'][0], target='riscv64gc-unknown-linux-musl', architecture='riscv64gc'))
+ release.validate_catalog(planned)
+ self.assertEqual(len(release.expected(planned, self.versions)), 23)
+ self.assertEqual(release.validate_manifest(manifest), manifest)
+ published_markdown = release.markdown(self.data, manifest)
+ self.assertEqual(release.markdown(planned, manifest), published_markdown)
+ self.assertEqual(release.svg(planned, manifest), release.svg(self.data, manifest))
+ self.assertNotIn('riscv64gc', published_markdown)
+ fixture = self.root/'docs-fixture'
+ fixture.mkdir()
+ snapshot = fixture/'latest.json'
+ snapshot.write_text(json.dumps(manifest))
+ current = fixture/'targets.json'
+ current.write_text(json.dumps(planned))
+ with patch.object(release, 'ROOT', fixture), patch.object(release, 'SNAPSHOT', snapshot), patch.object(release, 'CATALOG', current):
+ release.docs(argparse.Namespace(manifest=None, check=False))
+ release.docs(argparse.Namespace(manifest=None, check=True))
+ self.assertEqual(release.catalog(), planned)
+ self.assertEqual((fixture/'docs/downloads.md').read_text(), published_markdown)
+
+ def test_embedded_catalog_rejects_injection_and_inconsistent_metadata(self):
+ files = release.verified_inputs(self.source, self.wanted)
+ base = release.make_manifest(self.data, self.wanted, files, 'desktop-v0.1.0', self.data['repository'])
+ for key, value in (('platform', '[evil](https://evil.test)'),
+ ('architecture', 'x64 | injected'), ('target', 'x64)\n'),
+ ('formats', ['[download](url)']), ('runner', 'ubuntu; echo injected'),
+ ('bundles', 'deb,rpm,appimage;true')):
+ manifest = json.loads(json.dumps(base))
+ manifest['catalog']['desktop'][0][key] = value
+ with self.subTest(key=key), self.assertRaises(ValueError):
+ release.validate_manifest(manifest)
+ base['catalog']['repository'] = 'another/repository'
+ with self.assertRaisesRegex(ValueError, 'repository mismatch'):
+ release.validate_manifest(base)
+
+ def test_manifest_catalog_uses_effective_repository_and_is_frozen(self):
+ files = release.verified_inputs(self.source, self.wanted)
+ manifest = release.make_manifest(self.data, self.wanted, files, 'desktop-v0.1.0', 'example/fork')
+ self.assertEqual(manifest['catalog']['repository'], 'example/fork')
+ self.assertEqual(release.validate_manifest(manifest), manifest)
+ self.data['desktop'][0]['formats'].clear()
+ self.assertEqual(manifest['catalog']['desktop'][0]['formats'], ['deb','rpm','AppImage'])
+
+ def released_fixture(self, versions, tag, data=None, commit='abc'):
+ data = data or self.data
+ wanted = release.expected(data, versions, tuple(versions))
+ source = Path(tempfile.mkdtemp(dir=self.root, prefix='published-'))
+ for i, name in enumerate(wanted):
+ folder = source/f'job-{i}'
+ folder.mkdir()
+ (folder/name).write_bytes(('package '+name).encode())
+ release.checksums(folder)
+ files = release.verified_inputs(source, wanted)
+ return release.make_manifest(data, wanted, files, tag, data['repository'], commit), source
+
+ def test_standalone_cli_assembly_has_six_assets_and_no_desktop_claim(self):
+ _, source = self.released_fixture({'cli':'0.1.2'}, 'cli-v0.1.2')
+ args = argparse.Namespace(source=source, destination=self.root/'cli-release',
+ tag='cli-v0.1.2', repository=None, commit='ccc')
+ with patch.object(release, 'package_version', return_value='0.1.2'):
+ release.assemble_cli(args)
+ manifest = release.validate_manifest(json.loads((args.destination/'downloads.json').read_text()))
+ self.assertEqual(len(manifest['assets']), 6)
+ self.assertEqual(manifest['versions'], {'cli':'0.1.2'})
+ self.assertEqual(manifest['source_commits'], {'cli':'ccc'})
+ self.assertEqual(manifest['catalog']['desktop'], [])
+ self.assertEqual(manifest['catalog']['flatpak'], [])
+ artwork = (args.destination/'release-matrix.svg').read_text()
+ self.assertIn('6 package variants', artwork)
+ self.assertNotIn('Desktop + hfy CLI', artwork)
+ self.assertNotIn('Every desktop', artwork)
+ self.assertNotIn('NSIS', artwork)
+ ET.fromstring(artwork)
+ self.assertIn('尚未收录已发布的桌面安装包', (args.destination/'downloads.md').read_text())
+ sums = (args.destination/'SHA256SUMS').read_text()
+ self.assertIn(release.digest(args.destination/'downloads.json')+' downloads.json', sums)
+
+ def test_cli_first_then_desktop_keeps_newer_cli_and_frozen_catalog(self):
+ future = json.loads(json.dumps(self.data))
+ future['cli'].append(dict(future['cli'][0], target='riscv64gc-unknown-linux-musl', architecture='riscv64gc'))
+ cli, _ = self.released_fixture({'cli':'0.1.10'}, 'cli-v0.1.10', future, 'ccc')
+ desktop, _ = self.released_fixture({'desktop':'0.2.0','cli':'0.1.9'}, 'desktop-v0.2.0', commit='ddd')
+ fixture = self.root/'merged-docs'
+ fixture.mkdir()
+ snapshot = fixture/'latest.json'
+ incoming = fixture/'incoming.json'
+ with patch.object(release, 'ROOT', fixture), patch.object(release, 'SNAPSHOT', snapshot):
+ incoming.write_text(json.dumps(cli))
+ release.docs(argparse.Namespace(manifest=incoming, check=False))
+ self.assertEqual(json.loads(snapshot.read_text())['versions'], {'cli':'0.1.10'})
+ incoming.write_text(json.dumps(desktop))
+ release.docs(argparse.Namespace(manifest=incoming, check=False))
+ release.docs(argparse.Namespace(manifest=None, check=True))
+ merged = release.validate_manifest(json.loads(snapshot.read_text()))
+ self.assertEqual(merged['versions'], {'desktop':'0.2.0','cli':'0.1.10'})
+ self.assertEqual(merged['releases'], {'desktop':'desktop-v0.2.0','cli':'cli-v0.1.10'})
+ self.assertEqual(merged['source_commits'], {'desktop':'ddd','cli':'ccc'})
+ self.assertEqual(len(merged['catalog']['cli']), 7)
+ self.assertEqual(len(merged['assets']), 22)
+ self.assertEqual([a for a in merged['assets'] if a['product']=='cli'], cli['assets'])
+ before = snapshot.read_text()
+ release.docs(argparse.Namespace(manifest=incoming, check=False))
+ self.assertEqual(snapshot.read_text(), before)
+ self.assertIn('22 package variants', (fixture/'docs/assets/release-matrix.svg').read_text())
+
+ def test_retained_cli_can_come_from_an_older_desktop_tag(self):
+ old, _ = self.released_fixture({'desktop':'0.1.0','cli':'0.1.10'}, 'desktop-v0.1.0', commit='aaa')
+ incoming, _ = self.released_fixture({'desktop':'0.2.0','cli':'0.1.9'}, 'desktop-v0.2.0', commit='bbb')
+ merged = release.merge_manifests(old, incoming)
+ self.assertEqual(merged['versions'], {'desktop':'0.2.0','cli':'0.1.10'})
+ self.assertEqual(merged['releases']['cli'], 'desktop-v0.1.0')
+ self.assertEqual(merged['source_commits'], {'desktop':'bbb','cli':'aaa'})
+ self.assertEqual(release.validate_manifest(merged), merged)
+ for asset in merged['assets']:
+ if asset['product'] == 'cli': self.assertIn('/desktop-v0.1.0/', asset['url'])
+
+ def test_newer_cli_updates_without_replacing_desktop(self):
+ desktop, _ = self.released_fixture({'desktop':'0.1.0','cli':'0.1.2'}, 'desktop-v0.1.0')
+ newer, _ = self.released_fixture({'cli':'0.1.10'}, 'cli-v0.1.10', commit='ddd')
+ older, _ = self.released_fixture({'cli':'0.1.9'}, 'cli-v0.1.9', commit='eee')
+ merged = release.merge_manifests(desktop, newer)
+ self.assertEqual(merged['versions'], {'desktop':'0.1.0','cli':'0.1.10'})
+ self.assertEqual(release.merge_manifests(merged, older), merged)
+ self.assertEqual([a for a in merged['assets'] if a['product']=='desktop'],
+ [a for a in desktop['assets'] if a['product']=='desktop'])
+
+ def test_documentation_merge_rejects_prereleases_and_cross_repository(self):
+ stable, _ = self.released_fixture({'cli':'0.1.2'}, 'cli-v0.1.2')
+ prerelease, _ = self.released_fixture({'cli':'0.1.3-rc.1'}, 'cli-v0.1.3-rc.1')
+ with self.assertRaisesRegex(ValueError, 'Only stable releases'):
+ release.merge_manifests(stable, prerelease)
+ fork = json.loads(json.dumps(self.data)); fork['repository'] = 'example/fork'
+ another, _ = self.released_fixture({'cli':'0.1.3'}, 'cli-v0.1.3', fork)
+ with self.assertRaisesRegex(ValueError, 'different repositories'):
+ release.merge_manifests(stable, another)
+
+ def test_invalid_versions_rejected(self):
+ for value in ('v0.1.0','0.1','01.2.3','0.1.0/evil','0.1.0-01','0.1.0+build','0.1.0;echo'):
+ with self.subTest(value=value),self.assertRaises(ValueError): release.version(value)
+ for value in ('0.1.0','1.2.3-rc.1'): self.assertEqual(release.version(value),value)
+
+
+if __name__=='__main__': unittest.main()