diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5592118 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run -p xtask --" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..965ab04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every +# main state that gets a run is checked to the end. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + # egui/winit/wgpu headers. The tray (ksni) and notifications (notify-rust over zbus) are + # pure-Rust D-Bus clients, so no GTK, appindicator, xdo or libdbus packages are needed. + - name: Install Linux system dependencies (egui/winit) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - uses: Swatinem/rust-cache@v2 + + - name: Format + if: runner.os == 'Linux' + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: Test + run: cargo test --workspace --locked + + wasm: + name: wasm32 check + trunk build + runs-on: ubuntu-latest + env: + TRUNK_VERSION: 0.21.14 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + key: wasm32 + - name: Check wasm-safe crates + run: cargo xtask wasm + - name: Clippy (wasm32) + run: cargo clippy -p artcraft-launcher-core -p artcraft-launcher-web --target wasm32-unknown-unknown --locked -- -D warnings + - name: Install trunk + run: | + curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ + | tar -xz -C "$HOME/.cargo/bin" trunk + trunk --version + # The same build and zip the release makes (size gate and relative-URL check included). + - name: Build and zip the web app + run: packaging/web/package.sh diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml new file mode 100644 index 0000000..868fccf --- /dev/null +++ b/.github/workflows/freebsd.yml @@ -0,0 +1,59 @@ +# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner +# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script the +# release job runs, and checks the tarball, so a broken FreeBSD package fails here before it can +# fail a release. Nothing is uploaded and no secrets are used. +name: FreeBSD + +# The VM build is slow, so it runs nightly on main rather than on every push: a drifted +# dependency still shows up within a day. PRs that change the FreeBSD packaging still run it. +on: + schedule: + - cron: "23 2 * * *" + pull_request: + paths: + - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh + workflow_dispatch: + +concurrency: + group: freebsd-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + freebsd: + name: FreeBSD 14 (build + test + package) + runs-on: ubuntu-latest + timeout-minutes: 240 + steps: + - uses: actions/checkout@v4 + - name: Build, test and package in a FreeBSD VM + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + with: + release: "14.3" + usesh: true + cpu: 4 + mem: 12288 + # Nothing to bring back; copying the target dir out of the VM fails on large builds. + copyback: false + # egui/winit/wgpu libraries. The tray (ksni) and notifications (notify-rust over zbus) + # are pure-Rust D-Bus clients: no gtk3, libxdo, appindicator or libdbus needed. + prepare: | + pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2 + curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal + run: | + set -e + . "$HOME/.cargo/env" + # The checkout is synced in under another owner; let tests that call git read it. + git config --global --add safe.directory "*" + rustc --version + CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked + CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked -- --test-threads=1 + # The release package, as release.yml builds it. Free the debug build's disk first. + rm -rf target/debug + CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh + tar -tzf dist/release/artcraft-launcher-*-freebsd-x86_64.tar.gz \ + | grep -E '/bin/artcraft-launcher$|/share/applications/ai\.storyteller\.artcraft-launcher\.desktop$|/share/doc/artcraft-launcher/LICENSE-MIT$' diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml new file mode 100644 index 0000000..7e3ce9d --- /dev/null +++ b/.github/workflows/packaging-lint.yml @@ -0,0 +1,78 @@ +# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, +# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. +name: Packaging lint + +on: + push: + branches: [main, release] + paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"] + pull_request: + paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: packaging-lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + env: + ACTIONLINT_VERSION: 1.7.12 + steps: + - uses: actions/checkout@v4 + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml + curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP" actionlint + - name: actionlint + run: | + "$RUNNER_TEMP/actionlint" -color + - name: shellcheck + run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun + - name: AppImage AppRun desktop integration + run: bash packaging/linux/apprun-test.sh + - name: FreeBSD package layout (dry run) + run: bash packaging/freebsd/package.sh --dry-run + - name: PowerShell + shell: pwsh + run: | + foreach ($f in Get-ChildItem packaging/windows/*.ps1) { + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null + if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } + Write-Output "ok $($f.Name)" + } + ./packaging/windows/check-icons.ps1 + - name: XML, desktop entry, AppStream, Flatpak manifest + run: | + xmllint --noout packaging/windows/artcraft-launcher.wxs packaging/macos/*.plist* packaging/linux/*.xml* + if grep -q LSUIElement packaging/macos/Info.plist.in; then echo "::error::Info.plist.in must not set LSUIElement"; exit 1; fi + desktop-file-validate packaging/linux/ai.storyteller.artcraft-launcher.desktop + sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.artcraft-launcher.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.artcraft-launcher.metainfo.xml" + appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.artcraft-launcher.metainfo.xml" + python3 - <<'PY' + import yaml + src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml")) + bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.artcraft-launcher.bundle.yml")) + for m in (src, bundle): + assert m["id"] == "ai.storyteller.artcraft-launcher" and m["command"] == "artcraft-launcher", m["id"] + for key in ("runtime", "runtime-version", "sdk", "finish-args"): + assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" + print("Flatpak manifests ok") + PY + - name: Icons the app embeds exist + run: | + for f in assets/app-icon/artcraft-launcher.ico assets/app-icon/artcraft-launcher.icns \ + assets/app-icon/artcraft-launcher-1024.png assets/app-icon/tray/tray-template.png \ + assets/app-icon/tray/tray-color.png assets/app-icon/hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg; do + test -s "$f" || { echo "::error::missing $f"; exit 1; } + done + for s in 16 24 32 48 64 128 256 512; do + test -s "assets/app-icon/hicolor/${s}x${s}/apps/ai.storyteller.artcraft-launcher.png" || { echo "::error::missing ${s}px icon"; exit 1; } + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6d5016e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,406 @@ +# Release pipeline: every push to `release` builds installers for every platform and creates or +# updates the draft GitHub Release "ArtCraft Launcher v" with SHA256SUMS.txt. +# Recipe: craftrules release/playbook.md (photocraft is the reference implementation). +# +# Only the jobs that read secrets run in the `release` environment (restricted to the `release` +# branch): macOS and Windows (signing) and the draft-release job. The rest read no secrets, so a +# branch dry run (`gh workflow run release.yml --ref `) still builds every unsigned +# platform, while the environment refuses the signed jobs and so the draft release. Every secret +# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. +name: Release + +on: + push: + branches: [release] + workflow_dispatch: + inputs: + version: + description: "Version override for a test run, e.g. 0.2.0-rc.1 (empty: Cargo.toml)" + type: string + default: "" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + ARTCRAFT_LAUNCHER_BUILD_SHA: ${{ github.sha }} + INPUT_VERSION: ${{ inputs.version }} + +jobs: + version: + name: version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + date: ${{ steps.v.outputs.date }} + prerelease: ${{ steps.v.outputs.prerelease }} + steps: + - uses: actions/checkout@v4 + - id: v + name: Resolve version + run: | + . packaging/env.sh + v="${INPUT_VERSION#v}" + v="${v:-$VERSION}" + if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then + echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" + exit 1 + fi + case "$v" in *-*) pre=true ;; *) pre=false ;; esac + { + echo "version=$v" + echo "date=$(date -u +%Y-%m-%d)" + echo "prerelease=$pre" + } >>"$GITHUB_OUTPUT" + echo "### ArtCraft Launcher v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" + + macos: + name: macOS universal + needs: version + runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) + environment: release + env: + ARTCRAFT_LAUNCHER_VERSION: ${{ needs.version.outputs.version }} + ARTCRAFT_LAUNCHER_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin, x86_64-apple-darwin + - uses: Swatinem/rust-cache@v2 + with: + key: release-macos-universal + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$ARTCRAFT_LAUNCHER_VERSION" + - name: Import signing certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: packaging/macos/import-cert.sh + - name: Build, sign, notarize + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: packaging/macos/package.sh --arch universal + # Mounts the DMG and checks the app users get: both architectures, no LSUIElement, + # Developer ID, hardened runtime, timestamp, stapled tickets and Gatekeeper's assessment. + # Strict when the signing secrets are present, warnings otherwise. + - name: Verify signatures and notarization + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: packaging/macos/verify.sh --arch universal + - uses: actions/upload-artifact@v4 + with: + name: release-macos + path: dist/release/* + if-no-files-found: error + - name: Remove temporary keychain + if: always() + run: | + if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi + + windows: + name: Windows ${{ matrix.arch }} + needs: version + runs-on: windows-latest + environment: release + strategy: + fail-fast: false + matrix: + include: + - { arch: x64, target: x86_64-pc-windows-msvc } + - { arch: x86, target: i686-pc-windows-msvc } + # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. + # .github/workflows/windows-arm64.yml installs and checks it on ARM64 hardware. + - { arch: arm64, target: aarch64-pc-windows-msvc } + env: + ARTCRAFT_LAUNCHER_VERSION: ${{ needs.version.outputs.version }} + ARTCRAFT_LAUNCHER_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: release-windows-${{ matrix.arch }} + - name: Apply version override + if: inputs.version != '' + shell: bash + run: cargo xtask version set "$ARTCRAFT_LAUNCHER_VERSION" + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Build, sign, package + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} + - uses: actions/upload-artifact@v4 + with: + name: release-windows-${{ matrix.arch }} + path: dist/release/* + if-no-files-found: error + + linux: + name: Linux ${{ matrix.arch }} + needs: version + # The oldest GitHub-hosted image, so the binary only needs glibc >= 2.35 (Ubuntu 22.04+, + # Debian 12+, Fedora 36+, RHEL 10). + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } + - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } + env: + ARTCRAFT_LAUNCHER_VERSION: ${{ needs.version.outputs.version }} + ARTCRAFT_LAUNCHER_BUILD_DATE: ${{ needs.version.outputs.date }} + NFPM_VERSION: 2.47.0 + steps: + - uses: actions/checkout@v4 + # egui/winit/wgpu headers. The tray (ksni) and notifications (notify-rust over zbus) are + # pure-Rust D-Bus clients, so no GTK, appindicator, xdo or libdbus packages are needed. + - name: Install build and packaging tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ + desktop-file-utils appstream python3-yaml zsync + curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ + "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" + sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: release-linux-${{ matrix.arch }} + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$ARTCRAFT_LAUNCHER_VERSION" + - name: Build and package (AppImage, deb, rpm, tar.gz) + run: packaging/linux/package.sh + - name: Check the packages + run: | + dpkg-deb --info dist/release/*.deb + dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/artcraft-launcher$|metainfo|applications' + ldd target/release/artcraft-launcher + python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml')); assert m['id'] == 'ai.storyteller.artcraft-launcher'" + for appimage in dist/release/*.AppImage; do + chmod +x "$appimage" + # Update information is embedded (repo craft-launcher, files artcraft-launcher-*), and + # the .zsync for delta updates sits beside it. + "$appimage" --appimage-updateinformation \ + | grep -Fx "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|artcraft-launcher-*-linux-${{ matrix.arch }}.AppImage.zsync" + test -s "$appimage.zsync" + done + - uses: actions/upload-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/release/* + if-no-files-found: error + + flatpak: + name: Flatpak ${{ matrix.arch }} + needs: [version, linux] + # Repackages the Linux job's tarball (the same binary as the AppImage/deb/rpm); no Rust + # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. + runs-on: ${{ matrix.runner }} + # No `environment: release`: this job signs nothing, so it gets no release secrets. + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04 } + - { arch: aarch64, runner: ubuntu-24.04-arm } + env: + ARTCRAFT_LAUNCHER_VERSION: ${{ needs.version.outputs.version }} + ARTCRAFT_LAUNCHER_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/linux + - name: Install flatpak-builder + run: | + sudo apt-get update + # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's + # `appstreamcli compose`, which renders the scalable icon and fails without it. + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common + # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Build the Flatpak bundle + run: packaging/linux/flatpak-bundle.sh "dist/linux/artcraft-launcher-$ARTCRAFT_LAUNCHER_VERSION-linux-${{ matrix.arch }}.tar.gz" + - uses: actions/upload-artifact@v4 + with: + name: release-flatpak-${{ matrix.arch }} + path: dist/release/*.flatpak + if-no-files-found: error + + freebsd: + name: FreeBSD x86_64 + needs: version + # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as + # .github/workflows/freebsd.yml. + runs-on: ubuntu-latest + timeout-minutes: 180 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + ARTCRAFT_LAUNCHER_VERSION: ${{ needs.version.outputs.version }} + ARTCRAFT_LAUNCHER_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - name: Build and package in a FreeBSD VM (tar.gz) + # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc + with: + release: "14.3" + usesh: true + cpu: 4 + mem: 12288 + # Passed into the VM by name; never templated into the script below. + envs: "ARTCRAFT_LAUNCHER_VERSION ARTCRAFT_LAUNCHER_BUILD_DATE ARTCRAFT_LAUNCHER_BUILD_SHA INPUT_VERSION" + # The FreeBSD CI packages, plus bash for the packaging script. + prepare: | + pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2 + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable + run: | + set -e + . "$HOME/.cargo/env" + git config --global --add safe.directory "$PWD" + # Build outside the checkout so only dist/ is copied back out of the VM. + export CARGO_TARGET_DIR=/var/tmp/artcraft-launcher-target CARGO_BUILD_JOBS=4 + if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$ARTCRAFT_LAUNCHER_VERSION"; fi + bash packaging/freebsd/package.sh + tar -tzf dist/release/artcraft-launcher-*-freebsd-x86_64.tar.gz \ + | grep -E '/bin/artcraft-launcher$|/share/applications/ai\.storyteller\.artcraft-launcher\.desktop$|/share/doc/artcraft-launcher/LICENSE-MIT$' + - uses: actions/upload-artifact@v4 + with: + name: release-freebsd-x86_64 + path: dist/release/* + if-no-files-found: error + + web: + name: Web (wasm) + needs: version + runs-on: ubuntu-latest + env: + ARTCRAFT_LAUNCHER_VERSION: ${{ needs.version.outputs.version }} + ARTCRAFT_LAUNCHER_BUILD_DATE: ${{ needs.version.outputs.date }} + TRUNK_VERSION: 0.21.14 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + key: release-web + - name: Install trunk + run: | + curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ + | tar -xz -C "$HOME/.cargo/bin" trunk + trunk --version + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$ARTCRAFT_LAUNCHER_VERSION" + - name: Build and zip + run: packaging/web/package.sh + - uses: actions/upload-artifact@v4 + with: + name: release-web + path: dist/release/* + if-no-files-found: error + + release: + name: Draft GitHub Release + needs: [version, macos, windows, linux, flatpak, freebsd, web] + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + env: + VERSION: ${{ needs.version.outputs.version }} + PRERELEASE: ${{ needs.version.outputs.prerelease }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/download-artifact@v4 + with: + pattern: release-* + path: artifacts + merge-multiple: true + - name: Check the asset names + working-directory: artifacts + run: | + expected=( + "artcraft-launcher-$VERSION-macos-universal.dmg" + "artcraft-launcher-$VERSION-freebsd-x86_64.tar.gz" + "artcraft-launcher-web-$VERSION.zip" + ) + for a in x64 x86 arm64; do + expected+=("artcraft-launcher-$VERSION-windows-$a.msi" "artcraft-launcher-$VERSION-windows-$a-portable.zip") + done + for a in x86_64 aarch64; do + for ext in AppImage AppImage.zsync deb rpm tar.gz flatpak; do + expected+=("artcraft-launcher-$VERSION-linux-$a.$ext") + done + done + missing=0 + for f in "${expected[@]}"; do + if [ ! -f "$f" ]; then echo "::error::missing release asset $f"; missing=1; fi + done + ls -l + exit "$missing" + - name: Checksums + working-directory: artifacts + run: | + sha256sum -- * | tee SHA256SUMS.txt + - name: Create or update the draft release + working-directory: artifacts + run: | + tag="v$VERSION" + title="ArtCraft Launcher v$VERSION" + pre=() + if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi + if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then + if [ "$draft" != "true" ]; then + echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." + exit 1 + fi + echo "Updating draft $tag: replacing its assets" + gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do + gh release delete-asset "$tag" "$asset" --yes + done + gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" + gh release upload "$tag" --clobber -- * + else + echo "Creating draft $tag" + gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * + fi + { + echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" + echo + sed 's/^/ /' SHA256SUMS.txt + } >>"$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 0000000..cb623dd --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,89 @@ +# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 +# runner, unsigned here), then install that per-user MSI on a Windows 11 ARM64 runner, check the +# installed binary is ARM64 and the Start Menu shortcut exists, and uninstall. The launcher is a +# GUI app, so it isn't run here. +name: Windows ARM64 + +# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that +# change the Windows packaging itself. +on: + pull_request: + paths: + - .github/workflows/windows-arm64.yml + - packaging/windows/** + schedule: + - cron: "47 4 * * *" + workflow_dispatch: + +concurrency: + group: windows-arm64-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + package: + name: package (cross-compiled on x64) + runs-on: windows-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + with: + targets: aarch64-pc-windows-msvc + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: windows-arm64-package + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Build and package (unsigned) + shell: pwsh + env: + DIST: ${{ github.workspace }}\dist\arm64 + run: ./packaging/windows/package.ps1 -Arch arm64 + - uses: actions/upload-artifact@v4 + with: + name: artcraft-launcher-windows-arm64 + path: dist/arm64/* + if-no-files-found: error + retention-days: 7 + + install: + name: install on ARM64 + needs: package + runs-on: windows-11-arm + timeout-minutes: 30 + steps: + - uses: actions/download-artifact@v4 + with: + name: artcraft-launcher-windows-arm64 + path: dist + - name: Install the MSI, check it, uninstall it + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName + $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } + # Per-user install: %LOCALAPPDATA%\Programs\ArtCraft Launcher. + $dir = Join-Path $env:LOCALAPPDATA 'Programs\ArtCraft Launcher' + $path = Join-Path $dir 'artcraft-launcher.exe' + if (-not (Test-Path $path)) { Get-Content install.log -Tail 60; throw "artcraft-launcher.exe not installed in $dir" } + $bytes = [System.IO.File]::ReadAllBytes($path) + $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) + if ($machine -ne 0xAA64) { throw "artcraft-launcher.exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } + Write-Output "ok $path (ARM64)" + $lnk = Join-Path ([Environment]::GetFolderPath('Programs')) 'ArtCraft Launcher.lnk' + if (-not (Test-Path $lnk)) { throw "Start Menu shortcut missing: $lnk" } + Write-Output "ok $lnk" + $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } + if (Test-Path $path) { throw 'uninstall left artcraft-launcher.exe behind' } + if (Test-Path $lnk) { throw 'uninstall left the Start Menu shortcut behind' } + Write-Output 'ok uninstalled' diff --git a/.gitignore b/.gitignore index 0afa602..f37514d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ *~ plan/ plans/ - +/target +target/ +/dist +/temp +/craft-fonts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..98fae7f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,128 @@ +# AGENTS.md: guide for AI agents and contributors + +ArtCraft Launcher installs, updates and launches the **Craft App Suite** (PhotoCraft, LightCraft, +FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, +CADCraft, SoundCraft). It is written in **Rust only** (no JavaScript or TypeScript): the desktop +app is native egui/eframe, and the web build is the same Rust compiled to WebAssembly (trunk + +wasm-bindgen). **No Tauri, Electron or webview shells**, ever. + +The product name is always **ArtCraft Launcher** in user-facing text: UI, window titles, About, +tray menu, notifications, installers, release names, docs prose. The suite is the **Craft App +Suite**. Machine names stay lowercase: crates and binaries (`artcraft-launcher`, +`artcraft-launcher-*`), file names, and the app id `ai.storyteller.artcraft-launcher`. Note the +GitHub repo is `storytold/craft-launcher`, while every file and package is `artcraft-launcher-*`. + +Standards and learnings shared across the Craft apps live in `../craftrules` (read its +`README.md` and `AGENTS.md`). Contribute reusable learnings there, never code. + +## 1. Workspace map + +```text +crates/ + core artcraft-launcher-core pure and wasm-safe: the app catalog, versions, GitHub + release parsing, picking the right asset per platform + native artcraft-launcher-native desktop only: HTTP, detecting installed apps, download, + verify and install + ui-egui artcraft-launcher-ui-egui the shared egui UI (desktop and web) +apps/ + artcraft-launcher desktop shell (eframe): window, tray icon, notifications + artcraft-launcher-web the same UI in the browser (trunk; catalog and downloads only) +xtask/ cargo xtask ci | wasm | version | ico +packaging/ macOS, Windows, Linux, FreeBSD and web packaging scripts +assets/app-icon/ the launcher's own icon (master SVG, renders, tray icons) +assets/app-icons/ the Craft apps' icons, copied from each app's repo +assets/fonts/ Inter (OFL-1.1) +``` + +Layering: `core` depends on no other workspace crate and must build for `wasm32-unknown-unknown`. +`native` may use `core`. `ui-egui` may use `core` (and `native` only behind +`cfg(not(target_arch = "wasm32"))` or through a trait the desktop shell implements). Nothing below +the apps opens windows, tray icons or notifications. + +## 2. Golden rules + +### Never crash (outranks feature work) + +A bad network response, a malformed release, a half-downloaded file, a full disk or a corrupt +settings file must produce an error the user can act on, never a panic. Shared standard: +`../craftrules/standards/never-crash.md`. + +- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or + `unimplemented!`. Return the crate's error type and propagate with `?`. +- Every production crate root (`lib.rs`, each binary's `main.rs`, and `xtask`) carries + `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. +- **Everything from the network is hostile.** Use `get()` instead of indexing, checked arithmetic + for sizes and offsets, and cap downloads and JSON sizes. + +### Installing other people's software safely + +- Download only from `https://github.com/storytold//releases` (and GitHub's asset CDN). +- Verify each download against the release's `SHA256SUMS.txt` before running or installing it. +- Never run an installer with elevated rights silently; tell the user what is about to happen. +- Never delete a user's files outside what the launcher itself installed. + +### Other rules + +1. **Assets need attribution.** Every bundled asset gets a row in `ATTRIBUTION.md` (path, title, + author, source, license) in the same change. The ArtCraft logos in `docs/brand/` are not open + source (`docs/brand/LICENSE-brand.txt`). +2. **Icons are generated.** Edit `assets/app-icon/artcraft-launcher.svg` or `assets/app-icon/tray/*.svg`, + then run `packaging/icons.sh` (needs `resvg`; `iconutil` on macOS) and commit the outputs. + `assets/app-icons/` is copied verbatim from each app's repo (`assets/app-icon/hicolor/128x128/apps/ai.storyteller..png`); + refresh it from there, never redraw. +3. **Never break wasm.** `cargo xtask wasm` must pass. File-system, process and OS code is + `cfg(not(target_arch = "wasm32"))`. +4. **Tests are the gate.** Release parsing and asset picking in `core` get unit tests with real + GitHub release JSON fixtures (small, committed under `crates/core/tests/fixtures/`). +5. When fetching anything, use a generic `ArtCraft-Launcher/` User-Agent and never put + a person's name, email or other personal details in requests. + +## 3. Before you finish a task + +```sh +cargo xtask ci # fmt --check, clippy -D warnings, test, wasm check +``` + +Or piecemeal: `cargo test -p `, `cargo clippy -p --all-targets -- -D warnings`, +`cargo xtask wasm`. If you touched packaging: `shellcheck packaging/*.sh packaging/*/*.sh +packaging/linux/AppRun` and `actionlint`. + +## 4. Releasing + +The version lives only in `[workspace.package] version` in the root `Cargo.toml` +(`cargo xtask version`, `cargo xtask version set X.Y.Z[-pre]`). Pushing to the `release` branch runs +`.github/workflows/release.yml`, which builds every platform and creates the draft GitHub Release +**ArtCraft Launcher v** with `SHA256SUMS.txt`. The canonical recipe is +`../craftrules/release/playbook.md`; cutting a release is `../craftrules/release/cut-a-release.md`. + +Release assets (keep these names exact; the launcher's own self-update and the README rely on them): + +| Platform | Files | +|---|---| +| Windows | `artcraft-launcher--windows-{x64,x86,arm64}.msi`, `…-windows-{x64,x86,arm64}-portable.zip` | +| macOS | `artcraft-launcher--macos-universal.dmg` | +| Linux | `artcraft-launcher--linux-{x86_64,aarch64}.{AppImage,AppImage.zsync,deb,rpm,tar.gz,flatpak}` | +| FreeBSD | `artcraft-launcher--freebsd-x86_64.tar.gz` | +| Web | `artcraft-launcher-web-.zip` | + +Packaging facts the code relies on: + +- The Windows MSI is **per-user** (`%LOCALAPPDATA%\Programs\ArtCraft Launcher`, no UAC prompt). + Its Start Menu shortcut sets AppUserModelID `ai.storyteller.artcraft-launcher`, which toast + notifications need. `portable.txt` beside the exe switches on portable mode + (`ArtCraftLauncherData\` next to the exe). +- The macOS bundle is `ArtCraft Launcher.app` (executable `Contents/MacOS/ArtCraft Launcher`), a + normal Dock app that can also sit in the menu bar: `LSUIElement` must never be set. +- Linux: the tray uses StatusNotifierItem over D-Bus and notifications use + `org.freedesktop.Notifications`, both in pure Rust (no GTK, appindicator or libdbus). +- `build.rs` reads `ARTCRAFT_LAUNCHER_BUILD_SHA` / `ARTCRAFT_LAUNCHER_BUILD_DATE` and, on Windows, + embeds `assets/app-icon/artcraft-launcher.ico`, failing the build when + `ARTCRAFT_LAUNCHER_REQUIRE_WINRES=1` and it can't. + +## 5. Parallel agents + +- Use your own target dir (`CARGO_TARGET_DIR=target/agent-`) and edit only the files you own. +- Keep every `Cargo.toml` valid at all times: the `crates/*` and `apps/*` globs mean one broken + manifest breaks everyone's build. Write manifests to a temp file and `mv` them into place. +- If someone else's in-progress edit breaks the build, wait and retry; don't fix their files. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 0000000..f7083aa --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,42 @@ +# Attribution + +Every non-code asset in this repository, with its author, source and license. ArtCraft +Launcher's own code and original assets are MIT OR Apache-2.0 (see [`LICENSE-MIT`](LICENSE-MIT), +[`LICENSE-APACHE`](LICENSE-APACHE) and [`NOTICE`](NOTICE)). When you add an asset, add a row here +in the same change; third-party assets must be permissively licensed and keep their license file +next to them. + +## Bundled in the app + +| Path | Title | Author | Source | License | +|---|---|---|---|---| +| `assets/app-icon/` (all files) | ArtCraft Launcher app icon and tray icons (grid of app-coloured tiles with a launch arrow) | ArtCraft Launcher contributors | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | +| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | (copied from PhotoCraft's `assets/fonts/`) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | + +### Craft App Suite icons + +The launcher shows each Craft app with that app's own icon. They are copied unmodified from each +app's repository; their designs belong to the ArtCraft Team and each app's contributors and are +licensed like those apps. Refresh them from the source path, never redraw them. + +| Path | Title | Author | Source | License | +|---|---|---|---|---| +| `assets/app-icons/photocraft.png` | PhotoCraft app icon (nine-tailed kitsune), 128 px | ArtCraft Team and the PhotoCraft contributors | [`storytold/photocraft`](https://github.com/storytold/photocraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.photocraft.png`, copied unmodified | MIT OR Apache-2.0 (PhotoCraft's [`LICENSE-MIT`](https://github.com/storytold/photocraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/photocraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/lightcraft.png` | LightCraft app icon (lynx), 128 px | ArtCraft Team and the LightCraft contributors | [`storytold/lightcraft`](https://github.com/storytold/lightcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.lightcraft.png`, copied unmodified | MIT OR Apache-2.0 (LightCraft's [`LICENSE-MIT`](https://github.com/storytold/lightcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/lightcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/filmcraft.png` | FilmCraft app icon (owl with ear tufts), 128 px | ArtCraft Team and the FilmCraft contributors | [`storytold/filmcraft`](https://github.com/storytold/filmcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.filmcraft.png`, copied unmodified | MIT OR Apache-2.0 (FilmCraft's [`LICENSE-MIT`](https://github.com/storytold/filmcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/filmcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/designcraft.png` | DesignCraft app icon (cat in a polka-dot scarf), 128 px | ArtCraft Team and the DesignCraft contributors | [`storytold/designcraft`](https://github.com/storytold/designcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.designcraft.png`, copied unmodified | MIT OR Apache-2.0 (DesignCraft's [`LICENSE-MIT`](https://github.com/storytold/designcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/designcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/vectorcraft.png` | VectorCraft app icon (dragon), 128 px | ArtCraft Team and the VectorCraft contributors | [`storytold/vectorcraft`](https://github.com/storytold/vectorcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.vectorcraft.png`, copied unmodified | MIT OR Apache-2.0 (VectorCraft's [`LICENSE-MIT`](https://github.com/storytold/vectorcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/vectorcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/pdfcraft.png` | PdfCraft app icon (lion), 128 px | ArtCraft Team and the PdfCraft contributors | [`storytold/pdfcraft`](https://github.com/storytold/pdfcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png`, copied unmodified | MIT OR Apache-2.0 (PdfCraft's [`LICENSE-MIT`](https://github.com/storytold/pdfcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/pdfcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/effectcraft.png` | EffectCraft app icon (unicorn), 128 px | ArtCraft Team and the EffectCraft contributors | [`storytold/effectcraft`](https://github.com/storytold/effectcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png`, copied unmodified | MIT OR Apache-2.0 (EffectCraft's [`LICENSE-MIT`](https://github.com/storytold/effectcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/effectcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/wordcraft.png` | WordCraft app icon (placeholder icon), 128 px | ArtCraft Team and the WordCraft contributors | [`storytold/wordcraft`](https://github.com/storytold/wordcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.wordcraft.png`, copied unmodified | MIT OR Apache-2.0 (WordCraft's [`LICENSE-MIT`](https://github.com/storytold/wordcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/wordcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/deckcraft.png` | DeckCraft app icon (placeholder icon (presentation board)), 128 px | ArtCraft Team and the DeckCraft contributors | [`storytold/deckcraft`](https://github.com/storytold/deckcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.deckcraft.png`, copied unmodified | MIT OR Apache-2.0 (DeckCraft's [`LICENSE-MIT`](https://github.com/storytold/deckcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/deckcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/gridcraft.png` | GridCraft app icon (app icon), 128 px | ArtCraft Team and the GridCraft contributors | [`storytold/gridcraft`](https://github.com/storytold/gridcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.gridcraft.png`, copied unmodified | MIT OR Apache-2.0 (GridCraft's [`LICENSE-MIT`](https://github.com/storytold/gridcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/gridcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/cadcraft.png` | CADCraft app icon (app icon), 128 px | ArtCraft Team and the CADCraft contributors | [`storytold/cadcraft`](https://github.com/storytold/cadcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png`, copied unmodified | MIT OR Apache-2.0 (CADCraft's [`LICENSE-MIT`](https://github.com/storytold/cadcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/cadcraft/blob/main/LICENSE-APACHE)) | +| `assets/app-icons/soundcraft.png` | SoundCraft app icon (nightingale (placeholder)), 128 px | ArtCraft Team and the SoundCraft contributors | [`storytold/soundcraft`](https://github.com/storytold/soundcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.soundcraft.png`, copied unmodified | MIT OR Apache-2.0 (SoundCraft's [`LICENSE-MIT`](https://github.com/storytold/soundcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/soundcraft/blob/main/LICENSE-APACHE)) | + +## Documentation and README + +| Path | Title | Author | Source | License | +|---|---|---|---|---| +| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com (via `craftrules/assets/brand/`) | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | +| README app icons (linked, not copied) | Each Craft app's 64 px icon | ArtCraft Team and each app's contributors | `raw.githubusercontent.com/storytold//main/assets/app-icon/hicolor/64x64/apps/ai.storyteller..png` | MIT OR Apache-2.0 (each app's license) | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eedd917 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +Read [AGENTS.md](AGENTS.md) first: it is the guide for every agent and contributor working in this repository. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c8abb7a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5570 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "serde", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "artcraft-launcher" +version = "0.1.0" +dependencies = [ + "artcraft-launcher-core", + "artcraft-launcher-native", + "artcraft-launcher-ui-egui", + "eframe", + "egui", + "env_logger", + "image", + "log", + "notify-rust", + "tray-icon", + "winresource", +] + +[[package]] +name = "artcraft-launcher-core" +version = "0.1.0" +dependencies = [ + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "artcraft-launcher-native" +version = "0.1.0" +dependencies = [ + "artcraft-launcher-core", + "dirs 6.0.0", + "flate2", + "log", + "plist", + "rustls-native-certs", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "winreg", +] + +[[package]] +name = "artcraft-launcher-ui-egui" +version = "0.1.0" +dependencies = [ + "artcraft-launcher-core", + "egui", + "egui_extras", + "egui_kittest", + "image", + "log", + "serde", +] + +[[package]] +name = "artcraft-launcher-web" +version = "0.1.0" +dependencies = [ + "artcraft-launcher-core", + "artcraft-launcher-ui-egui", + "eframe", + "egui", + "js-sys", + "log", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading 0.8.9", +] + +[[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 1.1.5", + "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 1.1.5", +] + +[[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.6", +] + +[[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 1.1.5", + "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.6", +] + +[[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 = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[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.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[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 = "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" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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 = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[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 = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.52.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +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 = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +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-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +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 = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +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 = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", + "serde", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster", + "profiling", + "raw-window-handle", + "ron", + "serde", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "ron", + "serde", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "serde", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +dependencies = [ + "ahash", + "egui", + "enum-map", + "image", + "itertools", + "log", + "profiling", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster", + "serde", + "tempfile", + "toml 1.1.8+spec-1.1.0", + "wgpu", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", + "serde", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "serde", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[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 = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", + "serde", +] + +[[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.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[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.6", +] + +[[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-macro", + "futures-task", + "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 = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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 = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[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 = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading 0.8.9", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[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 = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[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 = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[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 = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[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", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[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 = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[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 = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[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 = "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 = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "ksni" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b" +dependencies = [ + "async-executor", + "async-io", + "async-lock", + "futures-channel", + "futures-lite", + "futures-util", + "pastey", + "serde", + "task-local", + "zbus", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "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 = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[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 = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "notify-rust" +version = "4.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4587364a9a0074333429b3df75a30a205340c56a536ca3eb6ca0e59b87bbf8af" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[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", + "libm", +] + +[[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 = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[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", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[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 0.6.5", +] + +[[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 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[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 0.6.2", + "libc", + "objc2 0.6.5", + "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 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[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 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[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", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[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_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" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.42.0", + "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 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[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 = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[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 = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[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 = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[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_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +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 = "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 = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[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 = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[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 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[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_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.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +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.6", +] + +[[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 = "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 = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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 = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "task-local" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.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.6", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.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 = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[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", + "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", + "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", + "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", + "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 = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.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 7.0.0", + "ksni", + "libappindicator", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[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-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[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 = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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.6", + "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 = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.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 = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[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 = "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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml 1.1.8+spec-1.1.0", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xtask" +version = "0.1.0" + +[[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 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "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.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow 1.0.4", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[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 = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "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.6", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dfd8d1b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +resolver = "3" +members = ["crates/*", "apps/*", "xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.90" +repository = "https://github.com/storytold/craft-launcher" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" + +[workspace.dependencies] +artcraft-launcher-core = { path = "crates/core" } +artcraft-launcher-ui-egui = { path = "crates/ui-egui" } +artcraft-launcher-native = { path = "crates/native" } +egui = "0.36" +eframe = { version = "0.36", default-features = false } +egui_extras = { version = "0.36", default-features = false } +egui_kittest = "0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +semver = { version = "1", features = ["serde"] } +log = "0.4" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +lto = "thin" +codegen-units = 1 + +[profile.wasm-release] +inherits = "release" +opt-level = "s" +lto = "fat" +strip = "debuginfo" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..9982da4 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArtCraft Team and the ArtCraft Launcher contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..0bec0e7 --- /dev/null +++ b/NOTICE @@ -0,0 +1,18 @@ +ArtCraft Launcher +Copyright (c) 2026 ArtCraft Team and the ArtCraft Launcher contributors. +Licensed under the MIT License or the Apache License, Version 2.0, at your option +(LICENSE-MIT, LICENSE-APACHE). + +The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are +not open source and are not covered by the license above; their terms are in +docs/brand/LICENSE-brand.txt. + +Third-party material + +This product bundles third-party material under its own licenses, listed below. The per-file +list (author, source, license) is in ATTRIBUTION.md. + +Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. +Craft App Suite app icons (assets/app-icons/*.png) — from each app's repository +(github.com/storytold/), MIT OR Apache-2.0, Copyright (c) 2026 ArtCraft Team and the +contributors. diff --git a/README.md b/README.md index 933a67c..59cfff8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,237 @@ -Craft Launcher -============== +

+ + + + ArtCraft + + +

-The unified Craft App launcher. +

ArtCraft Launcher

-This lists all Craft Apps, can install them for your platform, and can update them for you. +

+ One place to install, update and open every app in the Craft App Suite, rebuilt in pure Rust.
+ PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft,
+ DeckCraft, GridCraft, CADCraft and SoundCraft: the right build for your computer, kept up to date. +

+

+ 100% Rust + macOS · Windows · Linux · FreeBSD · Web + License: MIT OR Apache-2.0 + Status: early alpha +

+

+ Join the ArtCraft community on Discord +

+ +

+ Download ArtCraft Launcher · + ArtCraft · + The Craft App Suite +

+ +> [!NOTE] +> **ArtCraft is a community of artists from all walks of life.** Painters, photographers, +> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil +> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).** + +

+ What it does · + Features · + Downloads · + Build from source · + Craft App Suite · + Discord +

+ +## What it does + +The Craft apps each ship their own builds for every platform. ArtCraft Launcher saves you from +hunting through a dozen release pages: it lists every app in the suite, shows which ones you have +and which version, installs the right build for your operating system and processor, and tells +you when an update is out. It is a small native app that can sit in your menu bar or system tray. + +## Features + + + + + + + +
+

📦 Install in one click

+ Picks the right download for your OS and CPU (DMG, MSI, AppImage, .deb, .rpm and more), checks it against the release's SHA-256 checksums and installs it. +
+

🔄 Stay up to date

+ Checks each app's GitHub releases and updates the ones you have. A notification tells you when there's something new. +
+

🧭 Always at hand

+ Opens any Craft app from one window, or from the tray and menu-bar icon. Native egui, no Electron, no web view. +
+ +The web version shows the same catalog in the browser, with the latest download for each app. + +## Where apps are installed + +ArtCraft Launcher installs each app where your system normally keeps applications, using the +app's official release package, so an app installed by the launcher is the same as one you +installed by hand (and the launcher finds apps you installed yourself): + +| System | Where | How | +|---|---|---| +| macOS | `/Applications/.app` | Copied from the signed, notarized release DMG | +| Windows | `C:\Program Files\` | The release MSI, through Windows Installer (with Start menu entry and Apps & features entry) | +| Linux | `/usr/bin`, `/usr/share/applications` | The release `.deb` (apt) or `.rpm` (dnf, zypper), with your system's password prompt. Without those, a Flatpak, or an AppImage in `~/Applications` | +| FreeBSD | `/usr/local` | The release tree, with a password prompt (polkit); `~/.local` when polkit isn't installed | + +Updates use the same format an app was installed with. Every download is checked against the +release's published SHA-256 before it is installed, and a running app is never replaced. + +## Downloads + +Download the build for your computer from the [latest release](https://github.com/storytold/craft-launcher/releases/latest), or browse [all releases](https://github.com/storytold/craft-launcher/releases). Every release has a `SHA256SUMS.txt` to check the files against. `` is the version number, for example `0.1.0`. + +**Windows** (10 and 11) + +| File | What it is | +|---|---| +| `artcraft-launcher--windows-x64.msi` | Installer for most PCs (Intel and AMD, 64-bit). Installs for the current user, no admin rights needed | +| `artcraft-launcher--windows-arm64.msi` | Installer for Windows on Arm (Snapdragon and other Arm PCs) | +| `artcraft-launcher--windows-x86.msi` | Installer for 32-bit Windows | +| `artcraft-launcher--windows-{x64,arm64,x86}-portable.zip` | No install: unzip and run `artcraft-launcher.exe`. Keeps its settings in the folder | + +**macOS** (11 Big Sur or later) + +| File | What it is | +|---|---| +| `artcraft-launcher--macos-universal.dmg` | Universal app for Apple silicon and Intel Macs, signed and notarized. Drag ArtCraft Launcher to Applications | + +**Linux** (x86_64 and aarch64; glibc 2.35 or newer, e.g. Ubuntu 22.04+, Debian 12+, Fedora 36+) + +| File | What it is | +|---|---| +| `artcraft-launcher--linux-{x86_64,aarch64}.AppImage` | Runs on almost any distro without installing; `chmod +x` and run. The `.AppImage.zsync` beside it lets AppImageUpdate fetch only what changed | +| `artcraft-launcher--linux-{x86_64,aarch64}.deb` | Debian, Ubuntu, Mint, Pop!_OS: `sudo apt install ./artcraft-launcher-…deb` | +| `artcraft-launcher--linux-{x86_64,aarch64}.rpm` | Fedora, openSUSE, RHEL: `sudo dnf install ./artcraft-launcher-…rpm` | +| `artcraft-launcher--linux-{x86_64,aarch64}.flatpak` | Flatpak bundle: `flatpak install --user artcraft-launcher-…flatpak`. Inside the sandbox the launcher can download apps but has limited power to install them on the host; prefer the AppImage, .deb or .rpm | +| `artcraft-launcher--linux-{x86_64,aarch64}.tar.gz` | Plain tree (`bin/`, `share/`) for everyone else and for scripts | + +The tray icon uses the StatusNotifierItem standard: KDE, Xfce, Cinnamon and most other desktops show it; on GNOME install the AppIndicator extension. + +**FreeBSD** (14, x86_64) + +| File | What it is | +|---|---| +| `artcraft-launcher--freebsd-x86_64.tar.gz` | A tree laid out like `/usr/local` | + +```sh +pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2 +tar -xzf artcraft-launcher--freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local +artcraft-launcher +``` + +**Web (WebAssembly)** + +| File | What it is | +|---|---| +| `artcraft-launcher-web-.zip` | The catalog as a static site you can host anywhere; see [`packaging/web/README.md`](packaging/web/README.md) | + +## Build from source + +You need a recent stable Rust (1.90 or newer). On Linux, install the windowing headers first +(Debian/Ubuntu: `sudo apt install libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev`). + +```sh +git clone https://github.com/storytold/craft-launcher +cd craft-launcher +cargo run --release -p artcraft-launcher # the desktop app +cargo xtask ci # fmt, clippy, tests and the wasm check +``` + +The web version builds with [trunk](https://trunkrs.dev/): + +```sh +rustup target add wasm32-unknown-unknown +cd apps/artcraft-launcher-web && trunk serve +``` + +New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md). Release packaging lives in +[`packaging/`](packaging/) and [`.github/workflows/release.yml`](.github/workflows/release.yml). + +> [!IMPORTANT] +> **Status:** ArtCraft Launcher is new and in early alpha. Expect rough edges, and please file +> issues (include your OS and which app you were installing) or tell us on +> [Discord](https://discord.gg/artcraft). + +## The Craft App Suite + +ArtCraft Launcher installs the **Craft App Suite**: free, open-source creative tools from the +[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to +stand on its own. + +| | App | What it's for | Code | Learn more | +|:-:|---|---|---|---| +| | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | +| | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | +| | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | +| | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | +| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | +| | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | +| | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Releases](https://github.com/storytold/wordcraft/releases) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Releases](https://github.com/storytold/deckcraft/releases) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Releases](https://github.com/storytold/cadcraft/releases) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Releases](https://github.com/storytold/soundcraft/releases) | + +And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. + +
+ +

+ Join the ArtCraft community on Discord +

+ +

Come make things with us

+ +

+ Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film, + set type, and people still figuring out what they like to make. Share what you're working on, + ask for help, tell us what's broken, or tell us what you wish these tools could do. + Whatever your medium and however long you've been at it, you're welcome here. +

+ +

+ discord.gg/artcraft · + getartcraft.com · + The Craft App Suite · + ArtCraft Launcher +

+ +--- + +## License and credits + +ArtCraft Launcher is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option. +Copyright (c) 2026 ArtCraft Team and the ArtCraft Launcher contributors. Required notices are in [NOTICE](NOTICE). + +Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed +with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). + +The app icons of the Craft apps (`assets/app-icons/`) are each app's own icon, copied unmodified +from its repository and licensed like that app (MIT OR Apache-2.0). + +The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the +ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as +part of this repository and ArtCraft Launcher, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). +Forks and modified versions must remove them. + +Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. ArtCraft Launcher is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. + +

+ ArtCraft
+ Made by the ArtCraft team and community. +

diff --git a/apps/artcraft-launcher-web/Cargo.toml b/apps/artcraft-launcher-web/Cargo.toml new file mode 100644 index 0000000..ec1bcbe --- /dev/null +++ b/apps/artcraft-launcher-web/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "artcraft-launcher-web" +description = "ArtCraft Launcher in the browser: the Craft App Suite's latest releases, with downloads for the visitor's system" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[[bin]] +name = "artcraft-launcher-web" +path = "src/main.rs" + +[dependencies] +artcraft-launcher-core = { workspace = true } +artcraft-launcher-ui-egui = { workspace = true } +egui = { workspace = true } +log = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] } diff --git a/apps/artcraft-launcher-web/Trunk.toml b/apps/artcraft-launcher-web/Trunk.toml new file mode 100644 index 0000000..98e4fcf --- /dev/null +++ b/apps/artcraft-launcher-web/Trunk.toml @@ -0,0 +1,10 @@ +# Build with: trunk build --release --cargo-profile wasm-release (from apps/artcraft-launcher-web) +[build] +target = "index.html" +dist = "../../dist/web" +public_url = "./" +filehash = true + +[serve] +addresses = ["127.0.0.1"] +port = 8766 diff --git a/apps/artcraft-launcher-web/index.html b/apps/artcraft-launcher-web/index.html new file mode 100644 index 0000000..ea64bfe --- /dev/null +++ b/apps/artcraft-launcher-web/index.html @@ -0,0 +1,21 @@ + + + + + + ArtCraft Launcher + + + + + + +
Loading ArtCraft Launcher…
+ + diff --git a/apps/artcraft-launcher-web/src/main.rs b/apps/artcraft-launcher-web/src/main.rs new file mode 100644 index 0000000..e4642b5 --- /dev/null +++ b/apps/artcraft-launcher-web/src/main.rs @@ -0,0 +1,20 @@ +//! ArtCraft Launcher in the browser. +//! +//! A browser can't install apps, so this build lists the Craft App Suite with each app's latest +//! release (read straight from the GitHub API, which allows cross-origin requests) and offers the +//! right download for the visitor's system. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +#[cfg(target_arch = "wasm32")] +mod web; + +#[cfg(target_arch = "wasm32")] +fn main() { + web::start(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("artcraft-launcher-web is the browser build: run `trunk serve` in apps/artcraft-launcher-web."); +} diff --git a/apps/artcraft-launcher-web/src/web.rs b/apps/artcraft-launcher-web/src/web.rs new file mode 100644 index 0000000..0e95cb6 --- /dev/null +++ b/apps/artcraft-launcher-web/src/web.rs @@ -0,0 +1,195 @@ +//! The browser backend and entry point. + +use std::cell::RefCell; +use std::rc::Rc; + +use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Source}; +use artcraft_launcher_core::release::{self, Release}; +use artcraft_launcher_core::{Arch, Os, Platform, SUITE, catalog, version}; +use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings}; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; + +const SETTINGS_KEY: &str = "settings"; + +pub fn start() { + eframe::WebLogger::init(log::LevelFilter::Warn).ok(); + wasm_bindgen_futures::spawn_local(async { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let Some(canvas) = document.get_element_by_id("launcher_canvas").and_then(|c| c.dyn_into::().ok()) else { + log::error!("no canvas"); + return; + }; + let result = eframe::WebRunner::new() + .start( + canvas, + eframe::WebOptions::default(), + Box::new(|cc| { + let ctx = cc.egui_ctx.clone(); + let settings: Settings = cc.storage.and_then(|s| eframe::get_value(s, SETTINGS_KEY)).unwrap_or_default(); + let backend = WebBackend::new(ctx.clone()); + Ok(Box::new(WebApp { ui: LauncherUi::new(&ctx, Box::new(backend), settings) })) + }), + ) + .await; + if let Some(loading) = document.get_element_by_id("launcher_loading") { + loading.remove(); + } + if let Err(e) = result { + log::error!("couldn't start: {e:?}"); + } + }); +} + +struct NoHost; +impl Host for NoHost {} + +struct WebApp { + ui: LauncherUi, +} + +impl eframe::App for WebApp { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.ui.logic(ctx, &mut NoHost); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.ui.ui(ui, &mut NoHost); + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + eframe::set_value(storage, SETTINGS_KEY, &self.ui.settings); + } +} + +struct WebBackend { + events: Rc>>, + ctx: egui::Context, + platform: Option, + checking: Rc>, + prereleases: bool, +} + +impl WebBackend { + fn new(ctx: egui::Context) -> Self { + Self { events: Rc::default(), ctx, platform: visitor_platform(), checking: Rc::default(), prereleases: false } + } + + fn check(&self) { + if self.checking.replace(true) { + return; + } + let events = self.events.clone(); + let ctx = self.ctx.clone(); + let checking = self.checking.clone(); + let pre = self.prereleases; + wasm_bindgen_futures::spawn_local(async move { + events.borrow_mut().push(Event::CheckStarted); + let mut limited = None; + for app in &SUITE { + let latest = latest(app.repo, pre).await; + if let Err(Some(reset)) = &latest { + limited = Some(*reset); + } + let latest = latest.unwrap_or_else(|e| { + LatestRelease::Failed(if e.is_some() { "GitHub's hourly limit was reached".into() } else { "couldn't reach GitHub".into() }) + }); + events.borrow_mut().push(Event::Latest { app: app.id.to_string(), latest }); + ctx.request_repaint(); + } + if let Ok(LatestRelease::Found(r)) = latest(catalog::LAUNCHER_REPO, false).await + && let (Some(l), Some(c)) = (r.version.as_ref(), version::parse(env!("CARGO_PKG_VERSION"))) + && version::is_newer(l, &c) + { + events.borrow_mut().push(Event::LauncherUpdate(r)); + } + let at = (js_sys::Date::now() / 1000.0) as i64; + events.borrow_mut().push(Event::CheckFinished { at, source: Source::GitHubApi, rate_limited_until: limited }); + *checking.borrow_mut() = false; + ctx.request_repaint(); + }); + } +} + +/// The latest release; `Err(Some(reset))` when rate-limited, `Err(None)` on other failures. +async fn latest(repo: &str, prereleases: bool) -> Result> { + let url = if prereleases { + format!("https://api.github.com/repos/{repo}/releases?per_page=30") + } else { + format!("https://api.github.com/repos/{repo}/releases/latest") + }; + let window = web_sys::window().ok_or(None)?; + let init = web_sys::RequestInit::new(); + init.set_method("GET"); + init.set_mode(web_sys::RequestMode::Cors); + let request = web_sys::Request::new_with_str_and_init(&url, &init).map_err(|_| None)?; + let _ = request.headers().set("Accept", "application/vnd.github+json"); + let resp: web_sys::Response = JsFuture::from(window.fetch_with_request(&request)).await.map_err(|_| None)?.dyn_into().map_err(|_| None)?; + match resp.status() { + 200 => {} + 404 => return Ok(LatestRelease::None), + 403 | 429 => { + let reset = resp.headers().get("x-ratelimit-reset").ok().flatten().and_then(|r| r.parse().ok()); + return Err(Some(reset.unwrap_or(0))); + } + _ => return Err(None), + } + let text = JsFuture::from(resp.text().map_err(|_| None)?).await.map_err(|_| None)?.as_string().ok_or(None)?; + let found: Option = if prereleases { + release::parse_github_release_list(&text, repo, true).map_err(|_| None)? + } else { + Some(release::parse_github_release(&text, repo).map_err(|_| None)?) + }; + Ok(found.map_or(LatestRelease::None, LatestRelease::Found)) +} + +/// The visitor's OS and CPU, as far as the browser says. +fn visitor_platform() -> Option { + let nav = web_sys::window()?.navigator(); + let ua = nav.user_agent().ok()?.to_lowercase(); + let os = if ua.contains("windows") { + Os::Windows + } else if ua.contains("mac os") || ua.contains("macintosh") { + Os::MacOs + } else if ua.contains("freebsd") { + Os::FreeBsd + } else if ua.contains("linux") && !ua.contains("android") { + Os::Linux + } else { + return None; + }; + let arch = if ua.contains("aarch64") || ua.contains("arm64") || ua.contains("armv8") { Arch::Arm64 } else { Arch::X64 }; + Some(Platform { os, arch }) +} + +impl Backend for WebBackend { + fn send(&mut self, command: Command) { + match command { + Command::CheckForUpdates { .. } => self.check(), + Command::Configure(o) => { + let changed = o.prereleases != self.prereleases; + self.prereleases = o.prereleases; + if changed { + self.check(); + } + } + _ => {} + } + } + + fn poll(&mut self) -> Vec { + std::mem::take(&mut *self.events.borrow_mut()) + } + + fn capabilities(&self) -> Capabilities { + Capabilities { manage_apps: false, reveal_label: "" } + } + + fn platform(&self) -> Option { + self.platform + } + + fn now(&self) -> i64 { + (js_sys::Date::now() / 1000.0) as i64 + } +} diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml new file mode 100644 index 0000000..3b00c58 --- /dev/null +++ b/apps/artcraft-launcher/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "artcraft-launcher" +description = "ArtCraft Launcher: install, update and open the Craft App Suite" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +default-run = "artcraft-launcher" +build = "build.rs" +publish = false + +[lints] +workspace = true + +[dependencies] +artcraft-launcher-core = { workspace = true } +artcraft-launcher-ui-egui = { workspace = true } +artcraft-launcher-native = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] } +egui = { workspace = true } +log = { workspace = true } +env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } +image = { version = "0.25", default-features = false, features = ["png"] } +notify-rust = "4" + +# The tray (menu bar) icon. On Linux and the BSDs the StatusNotifierItem backend talks D-Bus +# directly, so no GTK or libappindicator is needed. +[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] +tray-icon = "0.25" + +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies] +tray-icon = { version = "0.25", default-features = false, features = ["ksni"] } + +[build-dependencies] +# Icon + VERSIONINFO for artcraft-launcher.exe; build.rs only uses it when targeting Windows. +winresource = "0.1" diff --git a/apps/artcraft-launcher/build.rs b/apps/artcraft-launcher/build.rs new file mode 100644 index 0000000..10ea99d --- /dev/null +++ b/apps/artcraft-launcher/build.rs @@ -0,0 +1,29 @@ +//! Windows only: embed the app icon and version info (VERSIONINFO) into `artcraft-launcher.exe`. +//! +//! On every other target this does nothing. A missing resource compiler is a warning, so a +//! cross-compile from macOS or Linux still links, unless `ARTCRAFT_LAUNCHER_REQUIRE_WINRES=1` (set +//! by the release packaging) turns it into an error. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/app-icon/artcraft-launcher.ico"); + println!("cargo:rerun-if-env-changed=ARTCRAFT_LAUNCHER_REQUIRE_WINRES"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/app-icon/artcraft-launcher.ico") + .set("ProductName", "ArtCraft Launcher") + .set("FileDescription", "ArtCraft Launcher") + .set("CompanyName", "Learning Machines LLC") + .set("LegalCopyright", "Copyright (c) the ArtCraft Launcher authors. MIT OR Apache-2.0.") + .set("OriginalFilename", "artcraft-launcher.exe") + .set("InternalName", "artcraft-launcher"); + if let Err(e) = res.compile() { + if std::env::var_os("ARTCRAFT_LAUNCHER_REQUIRE_WINRES").is_some() { + println!("cargo:warning=embedding Windows resources failed: {e}"); + std::process::exit(1); + } + println!("cargo:warning=artcraft-launcher.exe built without icon/version resources: {e}"); + } +} diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs new file mode 100644 index 0000000..0148001 --- /dev/null +++ b/apps/artcraft-launcher/src/main.rs @@ -0,0 +1,199 @@ +//! ArtCraft Launcher: install, update and open the Craft App Suite. +//! +//! The window can close to the tray (menu bar) and keep checking for updates, or quit entirely. + +#![cfg_attr(windows, windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod tray; + +use std::process::ExitCode; +use std::sync::mpsc::Receiver; +use std::sync::{Arc, OnceLock}; + +use artcraft_launcher_native::single_instance::{self, Instance}; +use artcraft_launcher_native::{NativeBackend, login}; +use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings}; + +const APP_ID: &str = "ai.storyteller.artcraft-launcher"; +const SETTINGS_KEY: &str = "settings"; + +type Wake = Arc>>; + +fn main() -> ExitCode { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("artcraft-launcher {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + let hidden = args.iter().any(|a| a == login::HIDDEN_FLAG); + + let wake: Wake = Arc::new(OnceLock::new()); + let show_requests = match single_instance::claim(wake.clone()) { + Instance::Secondary => return ExitCode::SUCCESS, + Instance::Primary(rx) => rx, + }; + + let icon = + eframe::icon_data::from_png_bytes(include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png")).ok(); + let mut viewport = egui::ViewportBuilder::default() + .with_title("ArtCraft Launcher") + .with_app_id(APP_ID) + .with_inner_size([1120.0, 760.0]) + .with_min_inner_size([720.0, 500.0]) + .with_visible(!hidden); + if let Some(icon) = icon { + viewport = viewport.with_icon(icon); + } + let options = eframe::NativeOptions { viewport, persist_window: true, ..Default::default() }; + let result = eframe::run_native( + "ArtCraft Launcher", + options, + Box::new(move |cc| { + let ctx = cc.egui_ctx.clone(); + let _ = wake.set(Box::new(move || ctx.request_repaint())); + Ok(Box::new(Shell::new(cc, show_requests, hidden))) + }), + ); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + log::error!("{e}"); + ExitCode::FAILURE + } + } +} + +struct DesktopHost { + tray: bool, + quit: bool, +} + +impl Host for DesktopHost { + fn notify(&mut self, title: &str, body: &str) { + let (title, body) = (title.to_string(), body.to_string()); + // Some platforms block while the notification is delivered. + std::thread::spawn(move || { + let mut n = notify_rust::Notification::new(); + n.summary(&title).body(&body).appname("ArtCraft Launcher"); + #[cfg(windows)] + n.app_id(APP_ID); + #[cfg(all(unix, not(target_os = "macos")))] + n.icon(APP_ID); + if let Err(e) = n.show() { + log::warn!("notification: {e}"); + } + }); + } + + fn has_tray(&self) -> bool { + self.tray + } + + fn login_item(&self) -> Option { + Some(login::is_enabled()) + } + + fn set_login_item(&mut self, on: bool) -> Result<(), String> { + login::set_enabled(on) + } + + fn quit(&mut self) { + self.quit = true; + } +} + +struct Shell { + ui: LauncherUi, + tray: Option, + host: DesktopHost, + show_requests: Receiver<()>, + visible: bool, + quitting: bool, +} + +impl Shell { + fn new(cc: &eframe::CreationContext<'_>, show_requests: Receiver<()>, hidden: bool) -> Self { + let ctx = cc.egui_ctx.clone(); + let settings: Settings = cc.storage.and_then(|s| eframe::get_value(s, SETTINGS_KEY)).unwrap_or_default(); + let waker_ctx = ctx.clone(); + let backend = NativeBackend::new(move || waker_ctx.request_repaint()); + let tray_ctx = ctx.clone(); + let tray = match tray::Tray::new(move || tray_ctx.request_repaint()) { + Ok(t) => Some(t), + Err(e) => { + log::warn!("no tray icon: {e}"); + None + } + }; + let host = DesktopHost { tray: tray.is_some(), quit: false }; + // Started hidden at login but there's no tray to come back from: show the window. + let visible = !hidden || tray.is_none(); + if !visible { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + } else if hidden { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + } + Self { ui: LauncherUi::new(&ctx, Box::new(backend), settings), tray, host, show_requests, visible, quitting: false } + } + + fn show(&mut self, ctx: &egui::Context) { + self.visible = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + + fn hide(&mut self, ctx: &egui::Context) { + self.visible = false; + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + } + + fn quit(&mut self, ctx: &egui::Context) { + self.quitting = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } +} + +impl eframe::App for Shell { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.ui.logic(ctx, &mut self.host); + if self.show_requests.try_iter().count() > 0 { + self.show(ctx); + } + let actions = self.tray.as_ref().map(tray::Tray::actions).unwrap_or_default(); + for action in actions { + match action { + tray::TrayAction::Toggle if self.visible => self.hide(ctx), + tray::TrayAction::Toggle | tray::TrayAction::Show => self.show(ctx), + tray::TrayAction::Check => self.ui.check_now(), + tray::TrayAction::Quit => self.quit(ctx), + } + } + if let Some(t) = self.tray.as_mut() { + t.set_updates(self.ui.update_count()); + } + if self.host.quit { + self.host.quit = false; + self.quit(ctx); + } + // The close button hides to the tray unless the user chose to quit. + if ctx.input(|i| i.viewport().close_requested()) && !self.quitting && self.tray.is_some() && self.ui.settings.close_to_tray { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.hide(ctx); + } + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.ui.ui(ui, &mut self.host); + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + eframe::set_value(storage, SETTINGS_KEY, &self.ui.settings); + } + + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + egui::Color32::from_rgb(0x0e, 0x10, 0x14).to_normalized_gamma_f32() + } +} diff --git a/apps/artcraft-launcher/src/tray.rs b/apps/artcraft-launcher/src/tray.rs new file mode 100644 index 0000000..b8efe66 --- /dev/null +++ b/apps/artcraft-launcher/src/tray.rs @@ -0,0 +1,101 @@ +//! The tray (menu bar) icon: show the window, check for updates, quit. + +use std::sync::mpsc::{Receiver, Sender, channel}; + +use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem}; +use tray_icon::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; + +pub enum TrayAction { + Toggle, + Show, + Check, + Quit, +} + +pub struct Tray { + icon: TrayIcon, + show: MenuId, + check: MenuId, + quit: MenuId, + rx: Receiver, + tooltip: String, +} + +fn load_icon() -> Result { + // macOS draws a black "template" glyph in the menu bar's own colours; elsewhere a colour icon. + let bytes: &[u8] = if cfg!(target_os = "macos") { + include_bytes!("../../../assets/app-icon/tray/tray-template.png") + } else { + include_bytes!("../../../assets/app-icon/tray/tray-color.png") + }; + let img = image::load_from_memory_with_format(bytes, image::ImageFormat::Png).map_err(|e| e.to_string())?.into_rgba8(); + let (w, h) = img.dimensions(); + tray_icon::Icon::from_rgba(img.into_raw(), w, h).map_err(|e| e.to_string()) +} + +impl Tray { + /// Creates the tray icon. `wake` repaints the UI so it sees the action, even when hidden. + pub fn new(wake: impl Fn() + Send + Sync + Clone + 'static) -> Result { + let show = MenuItem::new("Show ArtCraft Launcher", true, None); + let check = MenuItem::new("Check for Updates", true, None); + let quit = MenuItem::new("Quit ArtCraft Launcher", true, None); + let menu = Menu::new(); + menu.append_items(&[&show, &check, &PredefinedMenuItem::separator(), &quit]).map_err(|e| e.to_string())?; + let icon = TrayIconBuilder::new() + .with_menu(Box::new(menu)) + .with_icon(load_icon()?) + .with_icon_as_template(cfg!(target_os = "macos")) + .with_menu_on_left_click(cfg!(target_os = "macos")) + .with_tooltip("ArtCraft Launcher") + .build() + .map_err(|e| e.to_string())?; + let (tx, rx): (Sender, Receiver) = channel(); + let (show_id, check_id, quit_id) = (show.id().clone(), check.id().clone(), quit.id().clone()); + { + let tx = tx.clone(); + let wake = wake.clone(); + let ids = (show_id.clone(), check_id.clone(), quit_id.clone()); + MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + let action = if e.id == ids.0 { + TrayAction::Show + } else if e.id == ids.1 { + TrayAction::Check + } else if e.id == ids.2 { + TrayAction::Quit + } else { + return; + }; + let _ = tx.send(action); + wake(); + })); + } + TrayIconEvent::set_event_handler(Some(move |e: TrayIconEvent| { + if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = e { + let _ = tx.send(TrayAction::Toggle); + wake(); + } + })); + Ok(Self { icon, show: show_id, check: check_id, quit: quit_id, rx, tooltip: String::new() }) + } + + pub fn actions(&self) -> Vec { + self.rx.try_iter().collect() + } + + pub fn set_updates(&mut self, count: usize) { + let tip = match count { + 0 => "ArtCraft Launcher".to_string(), + 1 => "ArtCraft Launcher · 1 update available".to_string(), + n => format!("ArtCraft Launcher · {n} updates available"), + }; + if tip != self.tooltip { + let _ = self.icon.set_tooltip(Some(&tip)); + self.tooltip = tip; + } + } + + #[allow(dead_code)] + pub fn ids(&self) -> (&MenuId, &MenuId, &MenuId) { + (&self.show, &self.check, &self.quit) + } +} diff --git a/assets/app-icon/LICENSE.txt b/assets/app-icon/LICENSE.txt new file mode 100644 index 0000000..98ffa38 --- /dev/null +++ b/assets/app-icon/LICENSE.txt @@ -0,0 +1,14 @@ +ArtCraft Launcher app icon (artcraft-launcher.svg, tray/*.svg and every file derived from them in +this directory: artcraft-launcher-1024.png, artcraft-launcher.icns, artcraft-launcher.ico, +hicolor/, tray/*.png). + +Original artwork made for this project: a grid of rounded tiles in the Craft apps' colours with a +launch arrow, drawn directly as SVG. No third-party material. Licensed like the rest of +ArtCraft Launcher, under either of + + * Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0) + * MIT license (https://opensource.org/licenses/MIT) + +at your option. + +Regenerate the derived files with packaging/icons.sh. diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md new file mode 100644 index 0000000..412a375 --- /dev/null +++ b/assets/app-icon/README.md @@ -0,0 +1,39 @@ +# ArtCraft Launcher app icon + +A dark rounded tile holding a 3×3 grid of smaller tiles in the Craft apps' colours: the suite of +apps. The middle tile is paper-coloured with an upward launch arrow. It is a plain geometric mark +rather than an engraved mascot (`../craftrules/standards/icon-design.md`), because the launcher +stands for the whole suite rather than one app. + +## Palette + +| Colour | Hex | Used for | +|---|---|---| +| Ink | `#0b0b0c` → `#26282e` | the tile (vertical gradient) and the arrow | +| Paper | `#efe9dc` | the centre tile | +| PhotoCraft, VectorCraft, FilmCraft, EffectCraft | `#2f7bf5`, `#e8573f`, `#8b5cf6`, `#e0368f` | top row, middle left | +| LightCraft, DesignCraft, GridCraft, PdfCraft | `#f2a516`, `#7bb51c`, `#1f9d55`, `#12a58a` | middle right, bottom row | + +## Geometry + +A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`. Inner tiles are 104 +units with 24-unit gaps (`rx=26`). The macOS renders pad it onto Apple's 824/1024 icon grid; the +Windows and Linux renders keep the whole tile with a hairline margin. + +## Files + +- `artcraft-launcher.svg`: canonical master; also the hicolor scalable icon. +- `artcraft-launcher-1024.png`: 1024 px render on the macOS grid. +- `artcraft-launcher.icns`: macOS bundle icon (`CFBundleIconFile`). +- `artcraft-launcher.ico`: Windows icon, 16–256 px, embedded in the `.exe` by + `apps/artcraft-launcher/build.rs` and used by the MSI. +- `hicolor//apps/ai.storyteller.artcraft-launcher.png` (16–512) and + `hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg`: Linux icon theme. +- `tray/tray-template.svg` → `tray/tray-template.png` (44×44, black on transparent): the macOS + menu-bar template image (22 pt @2x). Three squares and an arrow. +- `tray/tray-color.svg` → `tray/tray-color.png` (64×64): the tray icon on Windows, Linux and FreeBSD. + +## Regenerate + +Edit the SVGs, then run `packaging/icons.sh`. It needs `resvg`, plus `iconutil` on macOS; the +`.ico` is packed by `cargo xtask ico`. diff --git a/assets/app-icon/artcraft-launcher-1024.png b/assets/app-icon/artcraft-launcher-1024.png new file mode 100644 index 0000000..228f4bd Binary files /dev/null and b/assets/app-icon/artcraft-launcher-1024.png differ diff --git a/assets/app-icon/artcraft-launcher.icns b/assets/app-icon/artcraft-launcher.icns new file mode 100644 index 0000000..602877e Binary files /dev/null and b/assets/app-icon/artcraft-launcher.icns differ diff --git a/assets/app-icon/artcraft-launcher.ico b/assets/app-icon/artcraft-launcher.ico new file mode 100644 index 0000000..c11bd2f Binary files /dev/null and b/assets/app-icon/artcraft-launcher.ico differ diff --git a/assets/app-icon/artcraft-launcher.svg b/assets/app-icon/artcraft-launcher.svg new file mode 100644 index 0000000..95b71d2 --- /dev/null +++ b/assets/app-icon/artcraft-launcher.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.artcraft-launcher.png new file mode 100644 index 0000000..a17c5ed Binary files /dev/null and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.artcraft-launcher.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.artcraft-launcher.png new file mode 100644 index 0000000..d33e8c8 Binary files /dev/null and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.artcraft-launcher.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.artcraft-launcher.png new file mode 100644 index 0000000..076e7a2 Binary files /dev/null and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.artcraft-launcher.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png new file mode 100644 index 0000000..8f6117d Binary files /dev/null and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.artcraft-launcher.png new file mode 100644 index 0000000..45328ff Binary files /dev/null and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.artcraft-launcher.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.artcraft-launcher.png new file mode 100644 index 0000000..2d7d7c5 Binary files /dev/null and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.artcraft-launcher.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.artcraft-launcher.png new file mode 100644 index 0000000..d27f98d Binary files /dev/null and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.artcraft-launcher.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png new file mode 100644 index 0000000..6330581 Binary files /dev/null and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png differ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg new file mode 100644 index 0000000..95b71d2 --- /dev/null +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/app-icon/tray/tray-color.png b/assets/app-icon/tray/tray-color.png new file mode 100644 index 0000000..bd696de Binary files /dev/null and b/assets/app-icon/tray/tray-color.png differ diff --git a/assets/app-icon/tray/tray-color.svg b/assets/app-icon/tray/tray-color.svg new file mode 100644 index 0000000..29d40f5 --- /dev/null +++ b/assets/app-icon/tray/tray-color.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/app-icon/tray/tray-template.png b/assets/app-icon/tray/tray-template.png new file mode 100644 index 0000000..7780179 Binary files /dev/null and b/assets/app-icon/tray/tray-template.png differ diff --git a/assets/app-icon/tray/tray-template.svg b/assets/app-icon/tray/tray-template.svg new file mode 100644 index 0000000..a3b212b --- /dev/null +++ b/assets/app-icon/tray/tray-template.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/app-icons/cadcraft.png b/assets/app-icons/cadcraft.png new file mode 100644 index 0000000..d687516 Binary files /dev/null and b/assets/app-icons/cadcraft.png differ diff --git a/assets/app-icons/deckcraft.png b/assets/app-icons/deckcraft.png new file mode 100644 index 0000000..3dc9d11 Binary files /dev/null and b/assets/app-icons/deckcraft.png differ diff --git a/assets/app-icons/designcraft.png b/assets/app-icons/designcraft.png new file mode 100644 index 0000000..02b2b78 Binary files /dev/null and b/assets/app-icons/designcraft.png differ diff --git a/assets/app-icons/effectcraft.png b/assets/app-icons/effectcraft.png new file mode 100644 index 0000000..e41b16b Binary files /dev/null and b/assets/app-icons/effectcraft.png differ diff --git a/assets/app-icons/filmcraft.png b/assets/app-icons/filmcraft.png new file mode 100644 index 0000000..3c21731 Binary files /dev/null and b/assets/app-icons/filmcraft.png differ diff --git a/assets/app-icons/gridcraft.png b/assets/app-icons/gridcraft.png new file mode 100644 index 0000000..2951436 Binary files /dev/null and b/assets/app-icons/gridcraft.png differ diff --git a/assets/app-icons/lightcraft.png b/assets/app-icons/lightcraft.png new file mode 100644 index 0000000..f9da957 Binary files /dev/null and b/assets/app-icons/lightcraft.png differ diff --git a/assets/app-icons/pdfcraft.png b/assets/app-icons/pdfcraft.png new file mode 100644 index 0000000..20f4ca4 Binary files /dev/null and b/assets/app-icons/pdfcraft.png differ diff --git a/assets/app-icons/photocraft.png b/assets/app-icons/photocraft.png new file mode 100644 index 0000000..a28597f Binary files /dev/null and b/assets/app-icons/photocraft.png differ diff --git a/assets/app-icons/soundcraft.png b/assets/app-icons/soundcraft.png new file mode 100644 index 0000000..17d2540 Binary files /dev/null and b/assets/app-icons/soundcraft.png differ diff --git a/assets/app-icons/vectorcraft.png b/assets/app-icons/vectorcraft.png new file mode 100644 index 0000000..2057053 Binary files /dev/null and b/assets/app-icons/vectorcraft.png differ diff --git a/assets/app-icons/wordcraft.png b/assets/app-icons/wordcraft.png new file mode 100644 index 0000000..35bb5e0 Binary files /dev/null and b/assets/app-icons/wordcraft.png differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/OFL-Inter.txt b/assets/fonts/OFL-Inter.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/assets/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..4b6c79f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..4806c1c --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "artcraft-launcher-core" +description = "ArtCraft Launcher core: the Craft App Suite catalog, versions, release parsing and asset choice (pure, wasm-safe)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +semver = { workspace = true } diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs new file mode 100644 index 0000000..f74ca30 --- /dev/null +++ b/crates/core/src/catalog.rs @@ -0,0 +1,175 @@ +//! The Craft App Suite: the twelve apps the launcher knows about. +//! +//! The list is data so a later version can fetch it from an API; [`SUITE`] is the built-in copy. + +use serde::{Deserialize, Serialize}; + +/// Which generation of the suite an app belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AppGroup { + /// The seven original apps, each with a page on getartcraft.com. + Classic, + /// The five apps added after them. + New, +} + +impl AppGroup { + pub fn label(self) -> &'static str { + match self { + Self::Classic => "Classic", + Self::New => "New", + } + } +} + +/// One app in the suite. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CraftApp { + /// Machine name: repo, binary, package and asset stem (`photocraft`). + pub id: &'static str, + /// Display name (`PhotoCraft`). + pub name: &'static str, + /// What it's for, in a few words. + pub tagline: &'static str, + /// The familiar app it reimplements, for orientation. + pub like: &'static str, + pub group: AppGroup, + /// Brand colour (sRGB). + pub color: [u8; 3], + /// GitHub `owner/repo`. + pub repo: &'static str, + /// Older names its release assets may carry (PdfCraft shipped as PrintCraft until 0.2.1). + pub former_ids: &'static [&'static str], +} + +impl CraftApp { + /// `ai.storyteller.`: macOS bundle id, Flatpak id and Linux desktop-file id. + pub fn bundle_id(&self) -> String { + format!("ai.storyteller.{}", self.id) + } + + pub fn releases_url(&self) -> String { + format!("https://github.com/{}/releases", self.repo) + } + + pub fn repo_url(&self) -> String { + format!("https://github.com/{}", self.repo) + } + + /// The app's page on getartcraft.com (classic apps only). + pub fn website(&self) -> Option { + (self.group == AppGroup::Classic).then(|| format!("https://getartcraft.com/apps/{}", self.id)) + } + + /// Asset stems this app's releases may use, current first. + pub fn stems(&self) -> impl Iterator + '_ { + std::iter::once(self.id).chain(self.former_ids.iter().copied()) + } + + pub fn by_id(id: &str) -> Option<&'static CraftApp> { + SUITE.iter().find(|a| a.id == id) + } +} + +const fn app( + id: &'static str, + name: &'static str, + tagline: &'static str, + like: &'static str, + group: AppGroup, + color: [u8; 3], + repo: &'static str, +) -> CraftApp { + CraftApp { id, name, tagline, like, group, color, repo, former_ids: &[] } +} + +/// The Craft App Suite, in display order: the seven classic apps, then the five new ones. +pub const SUITE: [CraftApp; 12] = [ + app("photocraft", "PhotoCraft", "Image editing", "Adobe Photoshop", AppGroup::Classic, [0x2f, 0x7b, 0xf5], "storytold/photocraft"), + app("vectorcraft", "VectorCraft", "Vector illustration", "Adobe Illustrator", AppGroup::Classic, [0xe8, 0x57, 0x3f], "storytold/vectorcraft"), + app( + "filmcraft", + "FilmCraft", + "Video editing, color and sound", + "Adobe Premiere Pro", + AppGroup::Classic, + [0x8b, 0x5c, 0xf6], + "storytold/filmcraft", + ), + app( + "lightcraft", + "LightCraft", + "Photo library and raw development", + "Adobe Lightroom", + AppGroup::Classic, + [0xf2, 0xa5, 0x16], + "storytold/lightcraft", + ), + CraftApp { + former_ids: &["printcraft"], + ..app( + "pdfcraft", + "PdfCraft", + "Reading, organizing and protecting PDFs", + "Adobe Acrobat", + AppGroup::Classic, + [0x12, 0xa5, 0x8a], + "storytold/pdfcraft", + ) + }, + app( + "effectcraft", + "EffectCraft", + "Motion graphics and visual effects", + "Adobe After Effects", + AppGroup::Classic, + [0xe0, 0x36, 0x8f], + "storytold/effectcraft", + ), + app("designcraft", "DesignCraft", "Page layout and publishing", "Adobe InDesign", AppGroup::Classic, [0x7b, 0xb5, 0x1c], "storytold/designcraft"), + app("wordcraft", "WordCraft", "Writing and document design", "Microsoft Word", AppGroup::New, [0x3f, 0x5f, 0xe0], "storytold/wordcraft"), + app("deckcraft", "DeckCraft", "Presentations and slide shows", "Microsoft PowerPoint", AppGroup::New, [0xf0, 0x7a, 0x1e], "storytold/deckcraft"), + app("gridcraft", "GridCraft", "Spreadsheets and data", "Microsoft Excel", AppGroup::New, [0x1f, 0x9d, 0x55], "storytold/gridcraft"), + app("cadcraft", "CADCraft", "Computer-aided design and drafting", "Autodesk AutoCAD", AppGroup::New, [0x3e, 0xa6, 0xe8], "storytold/cadcraft"), + app( + "soundcraft", + "SoundCraft", + "Recording, editing and mixing audio", + "Avid Pro Tools", + AppGroup::New, + [0x1f, 0xa5, 0xc9], + "storytold/soundcraft", + ), +]; + +/// The launcher's own repo, checked so it can say when a newer launcher is out. +pub const LAUNCHER_REPO: &str = "storytold/craft-launcher"; +/// The launcher's own asset stem. +pub const LAUNCHER_ID: &str = "artcraft-launcher"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suite_has_seven_classic_and_five_new_apps_with_unique_ids() { + assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::Classic).count(), 7); + assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::New).count(), 5); + let mut ids: Vec<_> = SUITE.iter().map(|a| a.id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), SUITE.len()); + for a in &SUITE { + assert_eq!(a.repo, format!("storytold/{}", a.id)); + assert_eq!(a.name.to_lowercase(), a.id); + } + } + + #[test] + fn pdfcraft_also_answers_to_printcraft() { + let pdf = CraftApp::by_id("pdfcraft").unwrap(); + assert_eq!(pdf.stems().collect::>(), ["pdfcraft", "printcraft"]); + assert_eq!(pdf.website().as_deref(), Some("https://getartcraft.com/apps/pdfcraft")); + assert_eq!(CraftApp::by_id("wordcraft").unwrap().website(), None); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..6885d2f --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,20 @@ +//! ArtCraft Launcher core: everything that is pure data and logic. +//! +//! No I/O lives here, so the desktop shell, the web build and the tests share it unchanged: +//! the Craft App Suite catalog, version handling, GitHub release parsing, choosing the right +//! download for a platform, and turning "what's installed" plus "what's released" into a status. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod catalog; +pub mod platform; +pub mod protocol; +pub mod release; +pub mod status; +pub mod time; +pub mod version; + +pub use catalog::{AppGroup, CraftApp, SUITE}; +pub use platform::{Arch, Os, PackageKind, Platform}; +pub use release::{Asset, Release}; +pub use status::{AppStatus, InstallSource, Installed, Latest}; diff --git a/crates/core/src/platform.rs b/crates/core/src/platform.rs new file mode 100644 index 0000000..85b6a44 --- /dev/null +++ b/crates/core/src/platform.rs @@ -0,0 +1,276 @@ +//! Platforms, package formats, and choosing the right release asset. +//! +//! Every Craft App release names its files `---[-portable].`, for +//! example `photocraft-0.5.0-windows-x64.msi` or `photocraft-0.5.0-linux-aarch64.AppImage`. + +use serde::{Deserialize, Serialize}; + +use crate::release::{Asset, Release}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Os { + Windows, + MacOs, + Linux, + FreeBsd, +} + +impl Os { + pub fn label(self) -> &'static str { + match self { + Self::Windows => "Windows", + Self::MacOs => "macOS", + Self::Linux => "Linux", + Self::FreeBsd => "FreeBSD", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Arch { + X64, + X86, + Arm64, +} + +impl Arch { + pub fn label(self) -> &'static str { + match self { + Self::X64 => "x64", + Self::X86 => "x86", + Self::Arm64 => "ARM64", + } + } +} + +/// A package format a release ships. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PackageKind { + /// Windows Installer package (installs for all users into Program Files). + Msi, + /// Windows zip with the exe at the top level. + PortableZip, + /// macOS disk image with the `.app` bundle. + Dmg, + AppImage, + Deb, + Rpm, + Flatpak, + /// Linux or FreeBSD tarball (`bin/`, `share/…`). + TarGz, +} + +impl PackageKind { + pub fn label(self) -> &'static str { + match self { + Self::Msi => "Installer (.msi)", + Self::PortableZip => "Portable (.zip)", + Self::Dmg => "Disk image (.dmg)", + Self::AppImage => "AppImage", + Self::Deb => "Debian package (.deb)", + Self::Rpm => "RPM package (.rpm)", + Self::Flatpak => "Flatpak bundle", + Self::TarGz => "Tarball (.tar.gz)", + } + } + + fn suffix(self) -> &'static str { + match self { + Self::Msi => ".msi", + Self::PortableZip => "-portable.zip", + Self::Dmg => ".dmg", + Self::AppImage => ".AppImage", + Self::Deb => ".deb", + Self::Rpm => ".rpm", + Self::Flatpak => ".flatpak", + Self::TarGz => ".tar.gz", + } + } +} + +/// Where the launcher is running (or, on the web, the visitor's machine). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Platform { + pub os: Os, + pub arch: Arch, +} + +impl Platform { + /// The platform this binary was built for, or `None` for wasm and unsupported targets. + pub fn current() -> Option { + let os = if cfg!(target_os = "windows") { + Os::Windows + } else if cfg!(target_os = "macos") { + Os::MacOs + } else if cfg!(target_os = "linux") { + Os::Linux + } else if cfg!(target_os = "freebsd") { + Os::FreeBsd + } else { + return None; + }; + let arch = if cfg!(target_arch = "x86_64") { + Arch::X64 + } else if cfg!(target_arch = "x86") { + Arch::X86 + } else if cfg!(target_arch = "aarch64") { + Arch::Arm64 + } else { + return None; + }; + Some(Self { os, arch }) + } + + pub fn label(self) -> String { + match self.os { + Os::MacOs => "macOS".into(), + os => format!("{} {}", os.label(), self.arch.label()), + } + } + + /// Asset platform tokens to try, best first. Windows on ARM and 64-bit Windows can fall back + /// to emulated builds; macOS releases are universal. + fn tokens(self) -> &'static [&'static str] { + match (self.os, self.arch) { + (Os::Windows, Arch::X64) => &["windows-x64", "windows-x86"], + (Os::Windows, Arch::X86) => &["windows-x86"], + (Os::Windows, Arch::Arm64) => &["windows-arm64", "windows-x64", "windows-x86"], + (Os::MacOs, _) => &["macos-universal", "macos-aarch64", "macos-arm64", "macos-x86_64"], + (Os::Linux, Arch::X64) => &["linux-x86_64"], + (Os::Linux, Arch::Arm64) => &["linux-aarch64"], + (Os::Linux, Arch::X86) => &[], + (Os::FreeBsd, Arch::X64) => &["freebsd-x86_64"], + (Os::FreeBsd, _) => &[], + } + } + + /// Package formats the launcher installs on this OS, in order of preference. + pub fn default_kinds(self) -> &'static [PackageKind] { + match self.os { + Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip], + Os::MacOs => &[PackageKind::Dmg], + Os::Linux => &[PackageKind::AppImage, PackageKind::TarGz], + Os::FreeBsd => &[PackageKind::TarGz], + } + } + + /// Every format offered as a manual download on this OS (the web build's download menu). + pub fn download_kinds(self) -> &'static [PackageKind] { + match self.os { + Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip], + Os::MacOs => &[PackageKind::Dmg], + Os::Linux => &[PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz], + Os::FreeBsd => &[PackageKind::TarGz], + } + } +} + +/// What an asset name says about itself. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetName { + pub stem: String, + pub version: String, + pub token: String, + pub kind: PackageKind, +} + +/// Reads `---[-portable].`; `None` for checksums, zsync files, CLI +/// and web bundles, or anything else. +pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option { + const KINDS: [PackageKind; 8] = [ + PackageKind::PortableZip, + PackageKind::TarGz, + PackageKind::Msi, + PackageKind::Dmg, + PackageKind::AppImage, + PackageKind::Deb, + PackageKind::Rpm, + PackageKind::Flatpak, + ]; + let (kind, base) = KINDS.iter().find_map(|k| name.strip_suffix(k.suffix()).map(|b| (*k, b)))?; + for stem in stems { + let Some(rest) = base.strip_prefix(stem).and_then(|r| r.strip_prefix('-')) else { continue }; + // rest = --; os and arch never contain '-', versions may. + let mut parts = rest.rsplitn(3, '-'); + let (arch, os, version) = (parts.next()?, parts.next()?, parts.next()?); + if crate::version::parse(version).is_none() || !version.starts_with(|c: char| c.is_ascii_digit()) { + continue; + } + return Some(AssetName { stem: (*stem).to_string(), version: version.to_string(), token: format!("{os}-{arch}"), kind }); + } + None +} + +/// The best asset of `release` for `platform`, trying `kinds` in order and, within a kind, the +/// platform's tokens in order (native before emulated). +pub fn pick_asset<'a>(release: &'a Release, stems: &[&str], platform: Platform, kinds: &[PackageKind]) -> Option<(&'a Asset, PackageKind)> { + let parsed: Vec<(&Asset, AssetName)> = release.assets.iter().filter_map(|a| parse_asset_name(&a.name, stems).map(|n| (a, n))).collect(); + for kind in kinds { + for token in platform.tokens() { + if let Some((asset, _)) = parsed.iter().find(|(_, n)| n.kind == *kind && n.token == *token) { + return Some((asset, *kind)); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(names: &[&str]) -> Release { + Release { + assets: names.iter().map(|n| Asset { name: (*n).into(), size: 1, url: format!("https://x/{n}"), sha256: None }).collect(), + ..Release::default() + } + } + + const PHOTO: &[&str] = &[ + "photocraft-0.5.0-freebsd-x86_64.tar.gz", + "photocraft-0.5.0-linux-aarch64.AppImage", + "photocraft-0.5.0-linux-aarch64.AppImage.zsync", + "photocraft-0.5.0-linux-aarch64.deb", + "photocraft-0.5.0-linux-x86_64.AppImage", + "photocraft-0.5.0-linux-x86_64.tar.gz", + "photocraft-0.5.0-macos-universal.dmg", + "photocraft-0.5.0-windows-x64-portable.zip", + "photocraft-0.5.0-windows-x64.msi", + "photocraft-0.5.0-windows-x86.msi", + "photocraft-cli-0.5.0-macos-universal.zip", + "photocraft-web-0.5.0.zip", + "SHA256SUMS.txt", + ]; + + #[test] + fn reads_asset_names() { + let n = parse_asset_name("photocraft-0.2.0-rc.1-windows-arm64-portable.zip", &["photocraft"]).unwrap(); + assert_eq!((n.version.as_str(), n.token.as_str(), n.kind), ("0.2.0-rc.1", "windows-arm64", PackageKind::PortableZip)); + let n = parse_asset_name("printcraft-0.2.1-linux-x86_64.tar.gz", &["pdfcraft", "printcraft"]).unwrap(); + assert_eq!((n.stem.as_str(), n.kind), ("printcraft", PackageKind::TarGz)); + for other in + ["photocraft-cli-0.5.0-macos-universal.zip", "photocraft-web-0.5.0.zip", "SHA256SUMS.txt", "photocraft-0.5.0-linux-x86_64.AppImage.zsync"] + { + assert_eq!(parse_asset_name(other, &["photocraft"]), None, "{other}"); + } + } + + #[test] + fn picks_the_native_build_first_and_falls_back_to_emulation() { + let r = release(PHOTO); + let pick = |os, arch, kinds: &[PackageKind]| pick_asset(&r, &["photocraft"], Platform { os, arch }, kinds).map(|(a, _)| a.name.clone()); + assert_eq!(pick(Os::MacOs, Arch::Arm64, &[PackageKind::Dmg]).as_deref(), Some("photocraft-0.5.0-macos-universal.dmg")); + assert_eq!(pick(Os::Windows, Arch::X64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi")); + // No arm64 MSI in this release: Windows on ARM runs the x64 build. + assert_eq!(pick(Os::Windows, Arch::Arm64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi")); + assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::AppImage]).as_deref(), Some("photocraft-0.5.0-linux-aarch64.AppImage")); + assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::TarGz]), None); + assert_eq!(pick(Os::FreeBsd, Arch::X64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-freebsd-x86_64.tar.gz")); + // Preference order across kinds. + assert_eq!( + pick(Os::Windows, Arch::X64, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(), + Some("photocraft-0.5.0-windows-x64-portable.zip") + ); + assert_eq!(pick(Os::Windows, Arch::X86, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x86.msi")); + } +} diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs new file mode 100644 index 0000000..6ab155d --- /dev/null +++ b/crates/core/src/protocol.rs @@ -0,0 +1,148 @@ +//! How the UI talks to a backend: the desktop backend installs apps; the web backend can only +//! look up releases and hand out download links. +//! +//! The UI sends [`Command`]s and drains [`Event`]s once per frame, so backends do their work on +//! threads (desktop) or futures (web) and never block a frame. + +use serde::{Deserialize, Serialize}; + +use crate::platform::{PackageKind, Platform}; +use crate::release::Release; +use crate::status::Installed; + +/// Asks the backend to do something. Results arrive later as [`Event`]s. +#[derive(Clone, Debug, PartialEq)] +pub enum Command { + /// Look up the latest releases. `force` skips the freshness window (the user pressed Refresh). + CheckForUpdates { + force: bool, + }, + /// Look again at what's installed. + Detect, + /// Install or update an app to its latest release. + Install { + app: String, + }, + Uninstall { + app: String, + }, + Launch { + app: String, + }, + /// Show the installed app in the file manager. + Reveal { + app: String, + }, + /// Stop a running download. + Cancel { + app: String, + }, + /// Change how the backend behaves. + Configure(BackendOptions), +} + +/// Settings the backend needs. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendOptions { + /// Package formats to install, best first (empty: the platform default). + pub kinds: Vec, + /// Offer pre-releases (`0.6.0-rc.1`) as updates. + pub prereleases: bool, +} + +/// The answer to "what's the latest release of this app?". +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LatestRelease { + Found(Release), + /// The repo exists but has no published release. + None, + /// Couldn't find out (offline, rate-limited, unreadable answer). + Failed(String), +} + +/// What a running job is doing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JobPhase { + Downloading { + done: u64, + total: u64, + }, + Verifying, + /// Waiting on the OS installer (MSI) or copying files. + Installing, + Removing, +} + +/// Where release information came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Source { + /// The GitHub REST API. + GitHubApi, + /// github.com release pages (no API quota; used when the API is rate-limited). + GitHubPages, + /// Saved answers from an earlier check. + Cache, +} + +impl Source { + pub fn label(self) -> &'static str { + match self { + Self::GitHubApi => "GitHub", + Self::GitHubPages => "GitHub (release pages)", + Self::Cache => "saved results", + } + } +} + +/// Something the backend wants the UI to know. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + Latest { + app: String, + latest: LatestRelease, + }, + /// The latest ArtCraft Launcher release, when it's newer than this one. + LauncherUpdate(Release), + Detected { + app: String, + installed: Option, + }, + Progress { + app: String, + phase: JobPhase, + }, + /// A job ended: `Ok` carries a short success message, `Err` what went wrong. + JobFinished { + app: String, + result: Result, + }, + CheckStarted, + CheckFinished { + /// Unix seconds. + at: i64, + source: Source, + /// GitHub refused more API calls until this time (Unix seconds). + rate_limited_until: Option, + }, +} + +/// What a backend can do on this platform. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Capabilities { + /// Install, update, remove and launch apps (desktop). Without it the UI offers downloads. + pub manage_apps: bool, + /// What "show in file manager" is called here (`Show in Finder`). + pub reveal_label: &'static str, +} + +/// The UI's view of a backend. +pub trait Backend { + fn send(&mut self, command: Command); + /// Events since the last call, oldest first. + fn poll(&mut self) -> Vec; + fn capabilities(&self) -> Capabilities; + /// The machine apps would be installed on (on the web: the visitor's, best guess). + fn platform(&self) -> Option; + /// Unix seconds now. + fn now(&self) -> i64; +} diff --git a/crates/core/src/release.rs b/crates/core/src/release.rs new file mode 100644 index 0000000..1f2998d --- /dev/null +++ b/crates/core/src/release.rs @@ -0,0 +1,264 @@ +//! GitHub releases: reading the API's answer and `SHA256SUMS.txt`. +//! +//! Everything here is untrusted input. Sizes are bounded by the caller, download links must point +//! at the app's own release downloads, and checksums must look like checksums. + +use serde::{Deserialize, Serialize}; + +use crate::version::{self, Version}; + +/// One downloadable file of a release. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Asset { + pub name: String, + /// Bytes; 0 when unknown. + pub size: u64, + /// `https://github.com//releases/download//`. + pub url: String, + /// Lowercase hex SHA-256, when GitHub or `SHA256SUMS.txt` told us. + pub sha256: Option, +} + +/// A published release of one app. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Release { + pub tag: String, + pub version: Option, + /// `PhotoCraft v0.5.0` + pub name: String, + /// Unix seconds; `None` when only the tag is known. + pub published_at: Option, + pub html_url: String, + /// Release notes (Markdown), capped. + pub notes: String, + pub prerelease: bool, + pub assets: Vec, +} + +impl Release { + pub fn version_label(&self) -> String { + self.version.as_ref().map_or_else(|| self.tag.clone(), ToString::to_string) + } + + /// Fills in checksums from a `SHA256SUMS.txt` for assets that don't have one. + pub fn apply_sums(&mut self, sums: &[(String, String)]) { + for asset in &mut self.assets { + if asset.sha256.is_none() { + asset.sha256 = sums.iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h.clone()); + } + } + } + + pub fn sums_asset(&self) -> Option<&Asset> { + self.assets.iter().find(|a| a.name == "SHA256SUMS.txt") + } +} + +const MAX_NOTES: usize = 64 * 1024; + +fn is_hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// The prefix every download of `repo` must start with. +pub fn download_prefix(repo: &str) -> String { + format!("https://github.com/{repo}/releases/download/") +} + +/// Reads `GET /repos//releases/latest` (or one element of `/releases`). +pub fn parse_github_release(json: &str, repo: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?; + release_from_value(&v, repo) +} + +/// Reads `GET /repos//releases` and returns the newest stable (or any, with +/// `allow_prerelease`) published release. +pub fn parse_github_release_list(json: &str, repo: &str, allow_prerelease: bool) -> Result, String> { + let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?; + let list = v.as_array().ok_or("unexpected answer from GitHub")?; + let mut best: Option = None; + for item in list { + if item["draft"].as_bool().unwrap_or(false) { + continue; + } + let Ok(r) = release_from_value(item, repo) else { continue }; + let Some(ver) = r.version.as_ref() else { continue }; + if !allow_prerelease && (r.prerelease || !version::is_stable(ver)) { + continue; + } + let newer = best.as_ref().and_then(|b| b.version.as_ref()).is_none_or(|b| version::is_newer(ver, b)); + if newer { + best = Some(r); + } + } + Ok(best) +} + +fn release_from_value(v: &serde_json::Value, repo: &str) -> Result { + if let Some(msg) = v["message"].as_str() { + return Err(if msg == "Not Found" { "no releases yet".into() } else { format!("GitHub says: {}", truncate(msg, 200)) }); + } + let tag = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string(); + let releases = format!("https://github.com/{repo}/releases"); + let html_url = v["html_url"] + .as_str() + .filter(|u| u.strip_prefix(&releases).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\']))) + .map_or_else(|| releases.clone(), str::to_string); + let prefix = download_prefix(repo); + let assets = v["assets"] + .as_array() + .map(|list| { + list.iter() + .filter_map(|a| { + let name = a["name"].as_str().filter(|n| valid_file_name(n))?.to_string(); + let url = a["browser_download_url"].as_str().filter(|u| u.starts_with(&prefix) && !u.contains(['?', '#', '\\']))?.to_string(); + let sha256 = a["digest"].as_str().and_then(|d| d.strip_prefix("sha256:")).filter(|h| is_hex64(h)).map(str::to_ascii_lowercase); + Some(Asset { name, size: a["size"].as_u64().unwrap_or(0), url, sha256 }) + }) + .collect() + }) + .unwrap_or_default(); + Ok(Release { + version: version::parse(&tag), + name: v["name"].as_str().map(|n| truncate(n, 120)).filter(|n| !n.is_empty()).unwrap_or_else(|| tag.clone()), + tag, + published_at: v["published_at"].as_str().and_then(crate::time::parse_rfc3339), + html_url, + notes: v["body"].as_str().map(|b| truncate(b, MAX_NOTES)).unwrap_or_default(), + prerelease: v["prerelease"].as_bool().unwrap_or(false), + assets, + }) +} + +/// A release known only from its tag and `SHA256SUMS.txt` (the quota-free route: the +/// `releases/latest` redirect names the tag, and the sums list every file). +pub fn release_from_sums(repo: &str, tag: &str, sums: &[(String, String)]) -> Release { + let prefix = download_prefix(repo); + let mut assets: Vec = sums + .iter() + .map(|(hash, name)| Asset { name: name.clone(), size: 0, url: format!("{prefix}{tag}/{name}"), sha256: Some(hash.clone()) }) + .collect(); + assets.push(Asset { name: "SHA256SUMS.txt".into(), size: 0, url: format!("{prefix}{tag}/SHA256SUMS.txt"), sha256: None }); + Release { + tag: tag.to_string(), + version: version::parse(tag), + name: tag.to_string(), + published_at: None, + html_url: format!("https://github.com/{repo}/releases/tag/{tag}"), + notes: String::new(), + prerelease: false, + assets, + } +} + +/// The tag in a `https://github.com//releases/tag/` redirect target. +pub fn tag_from_redirect(location: &str, repo: &str) -> Option { + let rest = location.strip_prefix(&format!("https://github.com/{repo}/releases/tag/"))?; + (!rest.is_empty() && rest.len() <= 64 && rest.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))).then(|| rest.to_string()) +} + +/// Reads `sha256sum` output: ` ` (or ` *`) per line. +pub fn parse_sha256sums(text: &str) -> Vec<(String, String)> { + text.lines() + .filter_map(|line| { + let (hash, rest) = line.trim().split_once(char::is_whitespace)?; + let name = rest.trim_start().trim_start_matches('*'); + (is_hex64(hash) && valid_file_name(name)).then(|| (hash.to_ascii_lowercase(), name.to_string())) + }) + .collect() +} + +/// A bare file name we'd be willing to save: no paths, no control characters. +pub fn valid_file_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 200 + && name != "." + && name != ".." + && !name.starts_with('.') + && !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*')) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REPO: &str = "storytold/photocraft"; + const HASH: &str = "8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1"; + + fn api_json(url: &str) -> String { + format!( + r#"{{"tag_name":"v0.5.0","name":"PhotoCraft v0.5.0","html_url":"https://github.com/storytold/photocraft/releases/tag/v0.5.0", + "published_at":"2026-10-08T14:52:50Z","prerelease":false,"body":"Notes", + "assets":[{{"name":"photocraft-0.5.0-macos-universal.dmg","size":123,"browser_download_url":"{url}","digest":"sha256:{HASH}"}}]}}"# + ) + } + + #[test] + fn reads_the_latest_release() { + let r = parse_github_release( + &api_json("https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg"), + REPO, + ) + .unwrap(); + assert_eq!(r.version, Some(Version::new(0, 5, 0))); + assert_eq!(r.name, "PhotoCraft v0.5.0"); + assert_eq!(r.published_at, Some(1_791_471_170)); + assert_eq!(r.assets.len(), 1); + assert_eq!(r.assets[0].sha256.as_deref(), Some(HASH)); + assert_eq!(r.assets[0].size, 123); + } + + #[test] + fn drops_downloads_that_point_elsewhere() { + for url in ["https://evil.example/photocraft.dmg", "https://github.com/storytold/other/releases/download/v1/x.dmg", "javascript:alert(1)"] { + let r = parse_github_release(&api_json(url), REPO).unwrap(); + assert!(r.assets.is_empty(), "{url}"); + } + assert_eq!(parse_github_release(r#"{"message":"Not Found"}"#, REPO).unwrap_err(), "no releases yet"); + assert!(parse_github_release("", REPO).is_err()); + } + + #[test] + fn list_picks_the_newest_stable_release() { + let json = r#"[ + {"tag_name":"v0.6.0-rc.1","prerelease":false,"draft":false,"assets":[]}, + {"tag_name":"v0.5.1","prerelease":true,"draft":false,"assets":[]}, + {"tag_name":"v0.7.0","draft":true,"assets":[]}, + {"tag_name":"v0.5.0","prerelease":false,"draft":false,"assets":[]}, + {"tag_name":"v0.4.0","prerelease":false,"draft":false,"assets":[]}]"#; + assert_eq!(parse_github_release_list(json, REPO, false).unwrap().unwrap().tag, "v0.5.0"); + assert_eq!(parse_github_release_list(json, REPO, true).unwrap().unwrap().tag, "v0.6.0-rc.1"); + assert_eq!(parse_github_release_list("[]", REPO, false).unwrap(), None); + } + + #[test] + fn reads_checksums_and_redirects() { + let sums = parse_sha256sums(&format!("{HASH} photocraft-0.5.0-macos-universal.dmg\n{HASH} *b.zip\nnot a line\n{HASH} ../escape\n")); + assert_eq!(sums, [(HASH.to_string(), "photocraft-0.5.0-macos-universal.dmg".to_string()), (HASH.to_string(), "b.zip".to_string())]); + assert_eq!(tag_from_redirect("https://github.com/storytold/photocraft/releases/tag/v0.5.0", REPO).as_deref(), Some("v0.5.0")); + assert_eq!(tag_from_redirect("https://github.com/storytold/photocraft/releases", REPO), None); + assert_eq!(tag_from_redirect("https://evil/storytold/photocraft/releases/tag/v1", REPO), None); + let r = release_from_sums(REPO, "v0.5.0", &sums); + assert_eq!(r.assets[0].url, "https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg"); + assert_eq!(r.version, Some(Version::new(0, 5, 0))); + } + + #[test] + fn file_names_cannot_escape() { + for bad in ["", "..", "a/b", "a\\b", ".hidden", "c:x", "x\u{0}"] { + assert!(!valid_file_name(bad), "{bad:?}"); + } + assert!(valid_file_name("photocraft-0.5.0-linux-x86_64.AppImage")); + } +} diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs new file mode 100644 index 0000000..a2efd27 --- /dev/null +++ b/crates/core/src/status.rs @@ -0,0 +1,139 @@ +//! What's installed plus what's released → what to tell the user. + +use serde::{Deserialize, Serialize}; + +use crate::version::{self, Version}; + +/// How an installed copy got onto the machine, which decides how to update and remove it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum InstallSource { + /// Installed by this launcher (it keeps a record and can update or remove it). + Launcher, + /// A macOS `.app` the user put in Applications. + MacApp, + /// A Windows Installer (MSI) install. + Msi, + /// A Debian package. + Deb, + /// An RPM package. + Rpm, + /// A Flatpak. + Flatpak, + /// An AppImage file found on disk. + AppImage, + /// A binary found on `PATH` or in a system prefix. + Binary, +} + +impl InstallSource { + pub fn label(self) -> &'static str { + match self { + Self::Launcher => "ArtCraft Launcher", + Self::MacApp => "Applications folder", + Self::Msi => "Windows Installer", + Self::Deb => "Debian package", + Self::Rpm => "RPM package", + Self::Flatpak => "Flatpak", + Self::AppImage => "AppImage", + Self::Binary => "System binary", + } + } + + /// Whether the launcher may replace or remove this copy itself. Packages owned by a system + /// package manager (deb, rpm, Flatpak) are updated through that manager instead. + pub fn launcher_can_update(self) -> bool { + matches!(self, Self::Launcher | Self::MacApp | Self::Msi | Self::AppImage) + } +} + +/// An installed copy of an app. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Installed { + /// `None` when the copy is there but its version couldn't be read. + pub version: Option, + pub source: InstallSource, + /// Where it lives: the `.app`, the exe, the AppImage, or the package name. + pub location: String, +} + +/// The status shown on an app's card or row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AppStatus { + /// Still waiting for the first answer. + Checking, + /// Released and not installed. + Available, + /// Installed, and the installed version is the latest. + UpToDate, + /// Installed, and a newer version is out. + UpdateAvailable, + /// Installed, but the latest release couldn't be checked (offline, rate-limited). + Installed, + /// Not installed and nothing is published for this platform. + Unavailable, + /// The app has no releases yet. + NoRelease, +} + +impl AppStatus { + pub fn is_installed(&self) -> bool { + matches!(self, Self::UpToDate | Self::UpdateAvailable | Self::Installed) + } +} + +/// What the latest-release lookup produced for an app. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Latest<'a> { + Pending, + Failed, + NoRelease, + Found { version: Option<&'a Version>, has_asset: bool }, +} + +pub fn status(installed: Option<&Installed>, latest: Latest<'_>) -> AppStatus { + match (installed, latest) { + (None, Latest::Pending) => AppStatus::Checking, + (None, Latest::NoRelease) => AppStatus::NoRelease, + (None, Latest::Failed) => AppStatus::Unavailable, + (None, Latest::Found { has_asset, .. }) => { + if has_asset { + AppStatus::Available + } else { + AppStatus::Unavailable + } + } + (Some(_), Latest::Pending | Latest::Failed | Latest::NoRelease) => AppStatus::Installed, + (Some(inst), Latest::Found { version: latest, .. }) => match (latest, inst.version.as_ref()) { + (Some(l), Some(i)) if version::is_update(l, i) => AppStatus::UpdateAvailable, + (Some(_), Some(_)) => AppStatus::UpToDate, + // Version unknown on one side: we can't promise "up to date". + _ => AppStatus::Installed, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inst(v: &str) -> Installed { + Installed { version: version::parse(v), source: InstallSource::Launcher, location: String::new() } + } + + #[test] + fn status_covers_every_case() { + let v5 = Version::new(0, 5, 0); + let found = |has_asset| Latest::Found { version: Some(&v5), has_asset }; + assert_eq!(status(None, Latest::Pending), AppStatus::Checking); + assert_eq!(status(None, found(true)), AppStatus::Available); + assert_eq!(status(None, found(false)), AppStatus::Unavailable); + assert_eq!(status(None, Latest::NoRelease), AppStatus::NoRelease); + assert_eq!(status(Some(&inst("0.4.0")), found(true)), AppStatus::UpdateAvailable); + assert_eq!(status(Some(&inst("0.5.0")), found(true)), AppStatus::UpToDate); + // Newer than the latest release (a local build): not an update, still current. + assert_eq!(status(Some(&inst("0.6.0")), found(true)), AppStatus::UpToDate); + assert_eq!(status(Some(&inst("0.4.0")), Latest::Failed), AppStatus::Installed); + assert_eq!(status(Some(&inst("garbage")), found(true)), AppStatus::Installed); + assert!(AppStatus::UpdateAvailable.is_installed() && !AppStatus::Available.is_installed()); + } +} diff --git a/crates/core/src/time.rs b/crates/core/src/time.rs new file mode 100644 index 0000000..7a648c9 --- /dev/null +++ b/crates/core/src/time.rs @@ -0,0 +1,111 @@ +//! Just enough calendar to read GitHub's timestamps and show them, without a date crate. + +/// Seconds since the Unix epoch for an RFC 3339 UTC timestamp such as `2026-10-08T14:52:50Z`. +pub fn parse_rfc3339(text: &str) -> Option { + let b = text.trim().as_bytes(); + if b.len() < 19 || b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') || !matches!(b.get(10), Some(b'T' | b't' | b' ')) { + return None; + } + let num = |from: usize, to: usize| -> Option { + let s = std::str::from_utf8(b.get(from..to)?).ok()?; + if !s.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + s.parse().ok() + }; + let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + let (h, mi, s) = (num(11, 13)?, num(14, 16)?, num(17, 19)?); + if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || s > 60 { + return None; + } + // Offsets other than Z: GitHub always sends Z, but honour ±hh:mm if present. + let tail = std::str::from_utf8(b.get(19..)?).ok()?; + let tail = tail.strip_prefix('.').map_or(tail, |t| t.trim_start_matches(|c: char| c.is_ascii_digit())); + let offset = match tail { + "" | "Z" | "z" => 0, + t if t.len() == 6 && (t.starts_with('+') || t.starts_with('-')) => { + let sign = if t.starts_with('-') { -1 } else { 1 }; + let oh: i64 = t.get(1..3)?.parse().ok()?; + let om: i64 = t.get(4..6)?.parse().ok()?; + sign * (oh * 3600 + om * 60) + } + _ => return None, + }; + Some(days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s - offset) +} + +/// Days since 1970-01-01 (Howard Hinnant's algorithm). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// (year, month, day) for a Unix time. +pub fn civil_from_unix(secs: i64) -> (i64, u32, u32) { + let z = secs.div_euclid(86_400) + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + (y, u32::try_from(m).unwrap_or(1), u32::try_from(d).unwrap_or(1)) +} + +const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/// `Oct 8, 2026` +pub fn format_date(secs: i64) -> String { + let (y, m, d) = civil_from_unix(secs); + let month = MONTHS.get(m.saturating_sub(1) as usize).copied().unwrap_or("?"); + format!("{month} {d}, {y}") +} + +/// `just now`, `5 min ago`, `3 h ago`, `yesterday`, `4 days ago`, or the date. +pub fn format_ago(secs: i64, now: i64) -> String { + let dt = now.saturating_sub(secs); + match dt { + i64::MIN..60 => "just now".into(), + 60..3600 => format!("{} min ago", dt / 60), + 3600..86_400 => format!("{} h ago", dt / 3600), + 86_400..172_800 => "yesterday".into(), + 172_800..2_592_000 => format!("{} days ago", dt / 86_400), + _ => format_date(secs), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_github_timestamps() { + assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!(parse_rfc3339("2026-10-08T14:52:50Z"), Some(1_791_471_170)); + assert_eq!(parse_rfc3339("2026-10-08T16:52:50+02:00"), Some(1_791_471_170)); + assert_eq!(parse_rfc3339("2026-10-08T14:52:50.123Z"), Some(1_791_471_170)); + for bad in ["", "2026-10-08", "2026-13-08T00:00:00Z", "2026-10-08T14:52:50Q", "20x6-10-08T14:52:50Z"] { + assert_eq!(parse_rfc3339(bad), None, "{bad}"); + } + } + + #[test] + fn shows_dates_people_read() { + assert_eq!(format_date(1_791_471_170), "Oct 8, 2026"); + assert_eq!(civil_from_unix(951_782_400), (2000, 2, 29)); + let now = 1_791_471_170; + assert_eq!(format_ago(now - 5, now), "just now"); + assert_eq!(format_ago(now - 600, now), "10 min ago"); + assert_eq!(format_ago(now - 7200, now), "2 h ago"); + assert_eq!(format_ago(now - 90_000, now), "yesterday"); + assert_eq!(format_ago(now - 5 * 86_400, now), "5 days ago"); + assert_eq!(format_ago(now - 90 * 86_400, now), "Jul 10, 2026"); + } +} diff --git a/crates/core/src/version.rs b/crates/core/src/version.rs new file mode 100644 index 0000000..9bace88 --- /dev/null +++ b/crates/core/src/version.rs @@ -0,0 +1,95 @@ +//! Versions: release tags (`v0.5.0`), installed versions from many sources, and ordering. + +use std::cmp::Ordering; + +pub use semver::Version; + +/// Reads a version from a tag or an installed-version string. +/// +/// Accepts `v0.5.0`, `0.5.0`, `0.2.0-rc.1`, Debian's `0.2.0~rc.1-1`, and the two-part `0.5` +/// some platforms report. Build metadata is kept but never affects ordering. +pub fn parse(text: &str) -> Option { + let t = text.trim(); + let t = t.strip_prefix(['v', 'V']).unwrap_or(t); + if t.is_empty() || t.len() > 64 { + return None; + } + // Debian/RPM spell pre-releases with `~` and append a package revision after `-`. + let debianish = t.contains('~'); + let t = if debianish { t.split_once('-').map_or(t, |(v, _)| v).replace('~', "-") } else { t.to_string() }; + if let Ok(v) = Version::parse(&t) { + return Some(v); + } + // `0.5` → `0.5.0` + let (core, rest) = match t.find(['-', '+']) { + Some(i) => t.split_at(i), + None => (t.as_str(), ""), + }; + let parts: Vec<&str> = core.split('.').collect(); + if parts.len() == 2 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) { + return Version::parse(&format!("{core}.0{rest}")).ok(); + } + None +} + +/// A version that's a plain `X.Y.Z` with no pre-release suffix. +pub fn is_stable(v: &Version) -> bool { + v.pre.is_empty() +} + +/// Orders by precedence, ignoring build metadata. +pub fn compare(a: &Version, b: &Version) -> Ordering { + a.cmp_precedence(b) +} + +/// True when `latest` is newer than `installed`. +pub fn is_newer(latest: &Version, installed: &Version) -> bool { + compare(latest, installed) == Ordering::Greater +} + +/// Platforms that only record `X.Y.Z` (the MSI ProductVersion, `CFBundleShortVersionString`) +/// drop a pre-release suffix. A numeric-only installed version therefore counts as current when it +/// matches a pre-release's numbers, rather than nagging forever. +pub fn is_update(latest: &Version, installed: &Version) -> bool { + if installed.pre.is_empty() + && !latest.pre.is_empty() + && (latest.major, latest.minor, latest.patch) == (installed.major, installed.minor, installed.patch) + { + return false; + } + is_newer(latest, installed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &str) -> Version { + parse(s).unwrap() + } + + #[test] + fn reads_tags_and_platform_spellings() { + assert_eq!(v("v0.5.0"), Version::new(0, 5, 0)); + assert_eq!(v(" 1.2.3\n"), Version::new(1, 2, 3)); + assert_eq!(v("0.5"), Version::new(0, 5, 0)); + assert_eq!(v("0.2.0~rc.1-1").to_string(), "0.2.0-rc.1"); + assert_eq!(v("0.2.0-rc.1").to_string(), "0.2.0-rc.1"); + for bad in ["", "v", "latest", "1", "1.x.0", "0.5.", &"9".repeat(80)] { + assert_eq!(parse(bad), None, "{bad}"); + } + } + + #[test] + fn orders_versions_and_spots_updates() { + assert!(is_newer(&v("0.10.0"), &v("0.9.9"))); + assert!(is_newer(&v("0.2.0"), &v("0.2.0-rc.5"))); + assert!(!is_newer(&v("0.2.0+abc"), &v("0.2.0"))); + assert!(is_update(&v("0.5.0"), &v("0.4.1"))); + assert!(!is_update(&v("0.5.0"), &v("0.5.0"))); + assert!(!is_update(&v("0.5.0"), &v("0.6.0"))); + // An MSI that recorded 0.3.0 for 0.3.0-rc.2 is not out of date against 0.3.0-rc.2. + assert!(!is_update(&v("0.3.0-rc.2"), &v("0.3.0"))); + assert!(is_stable(&v("1.0.0")) && !is_stable(&v("1.0.0-beta.1"))); + } +} diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml new file mode 100644 index 0000000..0918a86 --- /dev/null +++ b/crates/native/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "artcraft-launcher-native" +description = "ArtCraft Launcher desktop backend: release lookups, detecting installed Craft Apps, installing and updating them" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +artcraft-launcher-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +dirs = "6" +sha2 = "0.10" +# HTTPS over rustls with the OS's trusted roots, like PdfCraft's update check (webpki-roots and +# webpki-root-certs are CDLA-licensed, so no bundled root list). +ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring", "gzip"] } +rustls-native-certs = "0.8" + +[target.'cfg(unix)'.dependencies] +flate2 = "1" +tar = { version = "0.4", default-features = false } + +[target.'cfg(target_os = "macos")'.dependencies] +plist = "1" + +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.55" + + +[dev-dependencies] +tempfile = "3" diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs new file mode 100644 index 0000000..ced04e4 --- /dev/null +++ b/crates/native/src/backend.rs @@ -0,0 +1,377 @@ +//! [`NativeBackend`]: the desktop implementation of the UI's [`Backend`]. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use artcraft_launcher_core::catalog::{LAUNCHER_ID, LAUNCHER_REPO}; +use artcraft_launcher_core::platform::{Arch, Os, Platform, pick_asset}; +use artcraft_launcher_core::protocol::{Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, Source}; +use artcraft_launcher_core::release::Release; +use artcraft_launcher_core::{CraftApp, Installed, SUITE, version}; + +use crate::http::Http; +use crate::install::{self, Job}; +use crate::manifest::Manifest; +use crate::paths::Paths; +use crate::source::{GitHubSource, ReleaseSource}; + +/// Automatic checks closer together than this reuse the last answers. +const FRESH_FOR: i64 = 10 * 60; +/// Requests in flight at once during a check. +const WORKERS: usize = 4; + +type Waker = Arc; + +struct Shared { + events: Mutex>, + waker: Waker, + latest: Mutex>, + installed: Mutex>, + jobs: Mutex>>, + manifest: Mutex, + options: Mutex, + checking: AtomicBool, + last_check: Mutex>, +} + +impl Shared { + fn emit(&self, event: Event) { + self.events.lock().unwrap_or_else(PoisonError::into_inner).push(event); + (self.waker)(); + } +} + +pub struct NativeBackend { + shared: Arc, + paths: Paths, + http: Option, + source: Option>, + platform: Option, +} + +impl NativeBackend { + /// `waker` is called (from any thread) whenever events are waiting; it should repaint the UI. + pub fn new(waker: impl Fn() + Send + Sync + 'static) -> Self { + let waker: Waker = Arc::new(waker); + let paths = Paths::new().unwrap_or_else(|e| { + log::error!("{e}; keeping files in the temporary folder"); + Paths::under(&std::env::temp_dir().join("artcraft-launcher")) + }); + let http = match Http::new() { + Ok(h) => Some(h), + Err(e) => { + log::error!("HTTPS unavailable: {e}"); + None + } + }; + let source: Option> = + http.clone().map(|h| Arc::new(GitHubSource::new(h, paths.release_cache())) as Arc); + let shared = Arc::new(Shared { + events: Mutex::new(Vec::new()), + waker, + latest: Mutex::new(HashMap::new()), + installed: Mutex::new(HashMap::new()), + jobs: Mutex::new(HashMap::new()), + manifest: Mutex::new(Manifest::load(&paths.manifest())), + options: Mutex::new(BackendOptions::default()), + checking: AtomicBool::new(false), + last_check: Mutex::new(None), + }); + let backend = Self { shared, paths, http, source, platform: native_platform() }; + backend.replay_cache(); + backend.detect_all(); + backend + } + + /// Shows the saved answers from the last run straight away, before any network request. + fn replay_cache(&self) { + let Some(source) = &self.source else { return }; + let pre = self.options().prereleases; + for app in &SUITE { + if let Some(latest) = source.cached(app.repo, pre) { + if let LatestRelease::Found(r) = &latest { + self.shared.latest.lock().unwrap_or_else(PoisonError::into_inner).insert(app.id.to_string(), r.clone()); + } + self.shared.emit(Event::Latest { app: app.id.to_string(), latest }); + } + } + } + + fn options(&self) -> BackendOptions { + self.shared.options.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + + fn detect_all(&self) { + let shared = self.shared.clone(); + std::thread::spawn(move || { + let snapshot = crate::detect::Snapshot::take(); + let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone(); + for app in &SUITE { + let found = crate::detect::detect(app, &manifest, &snapshot); + { + let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner); + match &found { + Some(i) => map.insert(app.id.to_string(), i.clone()), + None => map.remove(app.id), + }; + } + shared.emit(Event::Detected { app: app.id.to_string(), installed: found }); + } + }); + } + + fn check(&self, force: bool) { + let Some(source) = self.source.clone() else { + for app in &SUITE { + self.shared + .emit(Event::Latest { app: app.id.to_string(), latest: LatestRelease::Failed("secure connections aren't available".into()) }); + } + return; + }; + let now = crate::now(); + let recent = self.shared.last_check.lock().unwrap_or_else(PoisonError::into_inner).is_some_and(|t| now - t < FRESH_FOR); + if (recent && !force) || self.shared.checking.swap(true, Ordering::SeqCst) { + return; + } + let shared = self.shared.clone(); + let pre = self.options().prereleases; + std::thread::spawn(move || { + shared.emit(Event::CheckStarted); + let queue = Mutex::new(SUITE.iter().collect::>()); + let sources = Mutex::new(Vec::new()); + std::thread::scope(|scope| { + for _ in 0..WORKERS { + scope.spawn(|| { + loop { + let Some(app) = queue.lock().unwrap_or_else(PoisonError::into_inner).pop() else { break }; + let answer = source.latest(app.repo, pre); + sources.lock().unwrap_or_else(PoisonError::into_inner).push(answer.source); + if let LatestRelease::Found(r) = &answer.latest { + shared.latest.lock().unwrap_or_else(PoisonError::into_inner).insert(app.id.to_string(), r.clone()); + } + shared.emit(Event::Latest { app: app.id.to_string(), latest: answer.latest }); + } + }); + } + }); + // Is there a newer ArtCraft Launcher? + if let LatestRelease::Found(r) = source.latest(LAUNCHER_REPO, false).latest + && let (Some(latest), Some(current)) = (r.version.as_ref(), version::parse(env!("CARGO_PKG_VERSION"))) + && version::is_newer(latest, ¤t) + { + shared.emit(Event::LauncherUpdate(r)); + } + source.save(); + let sources = sources.into_inner().unwrap_or_else(PoisonError::into_inner); + let source_used = if sources.contains(&Source::Cache) { + Source::Cache + } else if sources.contains(&Source::GitHubPages) { + Source::GitHubPages + } else { + Source::GitHubApi + }; + let at = crate::now(); + *shared.last_check.lock().unwrap_or_else(PoisonError::into_inner) = Some(at); + shared.checking.store(false, Ordering::SeqCst); + shared.emit(Event::CheckFinished { at, source: source_used, rate_limited_until: source.rate_limited_until() }); + }); + } + + fn start_job(&self, app_id: &str, uninstall: bool) { + let Some(app) = CraftApp::by_id(app_id) else { return }; + let cancel = Arc::new(AtomicBool::new(false)); + { + let mut jobs = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner); + if jobs.contains_key(app.id) { + return; + } + jobs.insert(app.id.to_string(), cancel.clone()); + } + let shared = self.shared.clone(); + let paths = self.paths.clone(); + let http = self.http.clone(); + let platform = self.platform; + let options = self.options(); + std::thread::spawn(move || { + let existing = shared.installed.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned(); + let record = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).apps.get(app.id).cloned(); + let result = if uninstall { + shared.emit(Event::Progress { app: app.id.to_string(), phase: JobPhase::Removing }); + match &existing { + Some(inst) => install::uninstall(app, inst, record.as_ref()).map(|()| { + let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner); + if m.apps.remove(app.id).is_some() { + let _ = m.save(&paths.manifest()); + } + format!("{} was removed", app.name) + }), + None => Err(format!("{} isn't installed", app.name)), + } + } else { + run_install(app, &shared, &paths, http.as_ref(), platform, &options, existing.as_ref(), record.as_ref(), &cancel) + }; + // Look again: the result is whatever is on disk now. + let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone(); + let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take()); + { + let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner); + match &found { + Some(i) => map.insert(app.id.to_string(), i.clone()), + None => map.remove(app.id), + }; + } + shared.emit(Event::Detected { app: app.id.to_string(), installed: found }); + shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).remove(app.id); + shared.emit(Event::JobFinished { app: app.id.to_string(), result }); + }); + } + + fn installed(&self, app: &str) -> Option { + self.shared.installed.lock().unwrap_or_else(PoisonError::into_inner).get(app).cloned() + } +} + +#[allow(clippy::too_many_arguments)] +fn run_install( + app: &CraftApp, + shared: &Shared, + paths: &Paths, + http: Option<&Http>, + platform: Option, + options: &BackendOptions, + existing: Option<&Installed>, + record: Option<&crate::manifest::Record>, + cancel: &AtomicBool, +) -> Result { + let http = http.ok_or("secure connections aren't available on this system")?; + let platform = platform.ok_or("this system isn't supported")?; + let release = shared + .latest + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(app.id) + .cloned() + .ok_or("the latest release hasn't been found yet. Check for updates first.")?; + let stems: Vec<&str> = app.stems().collect(); + let kinds = install::choose_kinds(existing, record, &options.kinds); + let (asset, kind) = pick_asset(&release, &stems, platform, &kinds) + .ok_or_else(|| format!("{} {} has no download for {}", app.name, release.version_label(), platform.label()))?; + let asset = asset.clone(); + let mut last_emit = Instant::now() - Duration::from_secs(1); + let mut phase = |p: JobPhase| { + // Downloads report thousands of times; the UI needs ~20 a second. + if matches!(p, JobPhase::Downloading { done, total } if done != total && done != 0) && last_emit.elapsed() < Duration::from_millis(50) { + return; + } + last_emit = Instant::now(); + shared.emit(Event::Progress { app: app.id.to_string(), phase: p }); + }; + let mut job = Job { http, paths, cancel, phase: &mut phase }; + let new_record = install::install(app, &release, &asset, kind, existing, &mut job)?; + { + let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner); + let changed = match new_record { + Some(r) => m.apps.insert(app.id.to_string(), r).is_some() || true, + // Installed the platform's own way: any old launcher record is stale. + None => m.apps.remove(app.id).is_some(), + }; + if changed && let Err(e) = m.save(&paths.manifest()) { + log::warn!("couldn't save the install record: {e}"); + } + } + let verb = if existing.is_some() { "updated to" } else { "installed:" }; + Ok(format!("{} {verb} {}", app.name, release.version_label())) +} + +/// The machine's real architecture: an x64 launcher on Windows on ARM should still pick ARM64 +/// builds. +fn native_platform() -> Option { + let mut p = Platform::current()?; + if p.os == Os::Windows { + let arch = std::env::var("PROCESSOR_ARCHITEW6432").or_else(|_| std::env::var("PROCESSOR_ARCHITECTURE")).unwrap_or_default(); + if arch.eq_ignore_ascii_case("ARM64") { + p.arch = Arch::Arm64; + } else if arch.eq_ignore_ascii_case("AMD64") { + p.arch = Arch::X64; + } + } + Some(p) +} + +impl Backend for NativeBackend { + fn send(&mut self, command: Command) { + match command { + Command::CheckForUpdates { force } => self.check(force), + Command::Detect => self.detect_all(), + Command::Install { app } => self.start_job(&app, false), + Command::Uninstall { app } => self.start_job(&app, true), + Command::Cancel { app } => { + if let Some(flag) = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app) { + flag.store(true, Ordering::Relaxed); + } + } + Command::Launch { app } => { + let Some(def) = CraftApp::by_id(&app) else { return }; + let result = match self.installed(&app) { + Some(inst) => install::launch(def, &inst), + None => Err(format!("{} isn't installed", def.name)), + }; + if let Err(e) = result { + self.shared.emit(Event::JobFinished { app, result: Err(e) }); + } + } + Command::Reveal { app } => { + if let Some(inst) = self.installed(&app) { + let path = std::path::PathBuf::from(&inst.location); + let result = if path.exists() { crate::process::reveal(&path) } else { Err("it isn't a file on this computer".into()) }; + if let Err(e) = result { + self.shared.emit(Event::JobFinished { app, result: Err(format!("Couldn't show it: {e}")) }); + } + } + } + Command::Configure(options) => { + let changed_pre = { + let mut o = self.shared.options.lock().unwrap_or_else(PoisonError::into_inner); + let changed = o.prereleases != options.prereleases; + *o = options; + changed + }; + if changed_pre { + self.check(true); + } + } + } + } + + fn poll(&mut self) -> Vec { + std::mem::take(&mut *self.shared.events.lock().unwrap_or_else(PoisonError::into_inner)) + } + + fn capabilities(&self) -> Capabilities { + let reveal_label = match self.platform.map(|p| p.os) { + Some(Os::MacOs) => "Show in Finder", + Some(Os::Windows) => "Show in Explorer", + _ => "Show in Files", + }; + Capabilities { manage_apps: true, reveal_label } + } + + fn platform(&self) -> Option { + self.platform + } + + fn now(&self) -> i64 { + crate::now() + } +} + +/// The launcher's own download for this machine, for "a new launcher is available". +pub fn launcher_download(release: &Release, platform: Platform) -> Option { + let kinds: &[artcraft_launcher_core::PackageKind] = match platform.os { + Os::Linux => &[artcraft_launcher_core::PackageKind::AppImage, artcraft_launcher_core::PackageKind::Deb], + _ => platform.default_kinds(), + }; + pick_asset(release, &[LAUNCHER_ID], platform, kinds).map(|(a, _)| a.url.clone()) +} diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs new file mode 100644 index 0000000..bf410d1 --- /dev/null +++ b/crates/native/src/detect.rs @@ -0,0 +1,285 @@ +//! Finding Craft Apps that are already installed, however they got there. +//! +//! - macOS: `.app` in `/Applications` or `~/Applications`, read from its `Info.plist`. +//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), and portable +//! copies the launcher unpacked. +//! - Linux: AppImages in `~/Applications`, Debian/RPM packages, Flatpaks, and tarball installs. +//! - FreeBSD: tarball installs and `/usr/local/bin/`. + +use artcraft_launcher_core::{CraftApp, InstallSource, Installed, version}; + +use crate::manifest::Manifest; + +/// One look at the system, shared by every app's detection (package lists are read once). +#[derive(Default)] +pub struct Snapshot { + #[allow(dead_code)] + packages: Vec<(String, String, artcraft_launcher_core::InstallSource)>, +} + +impl Snapshot { + pub fn take() -> Self { + #[cfg(target_os = "linux")] + { + Self { packages: linux::packages() } + } + #[cfg(not(target_os = "linux"))] + { + Self::default() + } + } +} + +/// What's installed of `app`, best source first. +pub fn detect(app: &CraftApp, manifest: &Manifest, snapshot: &Snapshot) -> Option { + let _ = snapshot; + if let Some(found) = from_manifest(app, manifest) { + return Some(found); + } + #[cfg(target_os = "macos")] + return macos::detect(app); + #[cfg(windows)] + return windows::detect(app); + #[cfg(target_os = "linux")] + return linux::detect(app, snapshot); + #[cfg(target_os = "freebsd")] + return freebsd::detect(app); + #[allow(unreachable_code)] + None +} + +fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option { + let rec = manifest.apps.get(app.id)?; + if !rec.path.exists() { + return None; + } + // On macOS the bundle itself is the truth (the app may have updated itself). + #[cfg(target_os = "macos")] + if let Some(found) = macos::read_bundle(app, &rec.path) { + return Some(found); + } + Some(Installed { version: version::parse(&rec.version), source: InstallSource::Launcher, location: rec.path.display().to_string() }) +} + +#[cfg(target_os = "macos")] +pub mod macos { + use std::path::{Path, PathBuf}; + + use super::*; + + /// Where a `.app` might be: the system and the user's Applications folders. + pub fn candidates(app: &CraftApp) -> Vec { + let bundle = format!("{}.app", app.name); + let mut out = vec![Path::new("/Applications").join(&bundle)]; + if let Some(home) = dirs::home_dir() { + out.push(home.join("Applications").join(&bundle)); + } + out + } + + pub fn detect(app: &CraftApp) -> Option { + candidates(app).iter().find_map(|p| read_bundle(app, p)) + } + + /// Reads a bundle's `Info.plist`; `None` unless it's really this app. + pub fn read_bundle(app: &CraftApp, path: &Path) -> Option { + let plist = plist::Value::from_file(path.join("Contents/Info.plist")).ok()?; + let dict = plist.as_dictionary()?; + if dict.get("CFBundleIdentifier")?.as_string()? != app.bundle_id() { + return None; + } + // `Version` carries the full version including a pre-release suffix; + // CFBundleShortVersionString only has the numbers. + let full_key = format!("{}Version", app.name); + let text = + [full_key.as_str(), "CFBundleShortVersionString", "CFBundleVersion"].iter().find_map(|k| dict.get(k).and_then(plist::Value::as_string)); + Some(Installed { version: text.and_then(version::parse), source: InstallSource::MacApp, location: path.display().to_string() }) + } +} + +#[cfg(windows)] +pub mod windows { + use std::path::PathBuf; + + use winreg::RegKey; + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY}; + + use super::*; + + const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + const PUBLISHER: &str = "Learning Machines LLC"; + + /// An MSI install found in the registry. + pub struct MsiInstall { + pub product_code: String, + pub version: Option, + pub exe: Option, + } + + /// Every Windows Installer entry for `app`, newest first. + pub fn msi_installs(app: &CraftApp) -> Vec { + let mut out = Vec::new(); + for (hive, flags) in [(HKEY_LOCAL_MACHINE, KEY_WOW64_64KEY), (HKEY_LOCAL_MACHINE, KEY_WOW64_32KEY), (HKEY_CURRENT_USER, 0)] { + let Ok(root) = RegKey::predef(hive).open_subkey_with_flags(UNINSTALL, KEY_READ | flags) else { continue }; + for code in root.enum_keys().flatten() { + let Ok(key) = root.open_subkey_with_flags(&code, KEY_READ | flags) else { continue }; + let name: String = key.get_value("DisplayName").unwrap_or_default(); + let publisher: String = key.get_value("Publisher").unwrap_or_default(); + if name != app.name || publisher != PUBLISHER || out.iter().any(|m: &MsiInstall| m.product_code == code) { + continue; + } + let version = key.get_value::("DisplayVersion").ok().and_then(|v| version::parse(&v)); + let location: String = key.get_value("InstallLocation").unwrap_or_default(); + let exe = exe_path(app, (!location.is_empty()).then(|| PathBuf::from(location))); + out.push(MsiInstall { product_code: code, version, exe }); + } + } + out.sort_by(|a, b| match (&b.version, &a.version) { + (Some(x), Some(y)) => version::compare(x, y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + out + } + + /// The app's exe: from `App Paths`, the install folder, or Program Files. + fn exe_path(app: &CraftApp, location: Option) -> Option { + let exe = format!("{}.exe", app.id); + let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe}"); + for flags in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(&app_paths, KEY_READ | flags) + && let Ok(path) = key.get_value::("") + { + let p = PathBuf::from(path.trim_matches('"')); + if p.exists() { + return Some(p); + } + } + } + let mut dirs: Vec = location.into_iter().collect(); + for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] { + if let Some(pf) = std::env::var_os(var) { + dirs.push(PathBuf::from(pf).join(app.name)); + } + } + dirs.into_iter().map(|d| d.join(&exe)).find(|p| p.exists()) + } + + pub fn detect(app: &CraftApp) -> Option { + let m = msi_installs(app).into_iter().next()?; + Some(Installed { + version: m.version, + source: InstallSource::Msi, + location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()), + }) + } +} + +#[cfg(target_os = "linux")] +pub mod linux { + use std::path::PathBuf; + use std::time::Duration; + + use artcraft_launcher_core::platform::parse_asset_name; + + use super::*; + use crate::process::capture; + + /// Installed Craft App packages: (name, version, source). Read once per detection pass. + pub fn packages() -> Vec<(String, String, InstallSource)> { + let wanted = |name: &str| artcraft_launcher_core::SUITE.iter().any(|a| a.id == name); + let mut out = Vec::new(); + let t = Duration::from_secs(10); + if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) { + for line in list.lines() { + let mut f = line.split('|'); + if let (Some(name), Some("installed"), Some(ver)) = (f.next(), f.next(), f.next()) + && wanted(name) + { + out.push((name.to_string(), ver.to_string(), InstallSource::Deb)); + } + } + } + if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) { + for line in list.lines() { + if let Some((name, ver)) = line.split_once('|') + && wanted(name) + { + out.push((name.to_string(), ver.to_string(), InstallSource::Rpm)); + } + } + } + if let Some(list) = capture("flatpak", &["list", "--app", "--columns=application,version"], t) { + for line in list.lines() { + let mut f = line.split('\t'); + if let (Some(id), Some(ver)) = (f.next(), f.next()) + && let Some(name) = id.strip_prefix("ai.storyteller.") + && wanted(name) + { + out.push((name.to_string(), ver.to_string(), InstallSource::Flatpak)); + } + } + } + out + } + + /// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention). + pub fn appimage_dir() -> Option { + dirs::home_dir().map(|h| h.join("Applications")) + } + + /// AppImages of `app` in `~/Applications`, newest first. + pub fn appimages(app: &CraftApp) -> Vec<(PathBuf, Option)> { + let Some(dir) = appimage_dir() else { return Vec::new() }; + let Ok(entries) = std::fs::read_dir(&dir) else { return Vec::new() }; + let stems: Vec<&str> = app.stems().collect(); + let mut out: Vec<(PathBuf, Option)> = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let parsed = parse_asset_name(&name, &stems)?; + (parsed.kind == artcraft_launcher_core::PackageKind::AppImage).then(|| (e.path(), version::parse(&parsed.version))) + }) + .collect(); + out.sort_by(|a, b| match (&b.1, &a.1) { + (Some(x), Some(y)) => version::compare(x, y), + _ => std::cmp::Ordering::Equal, + }); + out + } + + pub fn detect(app: &CraftApp, snapshot: &Snapshot) -> Option { + if let Some((path, version)) = appimages(app).into_iter().next() { + return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string() }); + } + snapshot.packages.iter().find(|(name, _, _)| name == app.id).map(|(name, ver, source)| Installed { + version: version::parse(ver), + source: *source, + location: if *source == InstallSource::Flatpak { app.bundle_id() } else { format!("/usr/bin/{name}") }, + }) + } +} + +#[cfg(target_os = "freebsd")] +pub mod freebsd { + use std::path::Path; + use std::time::Duration; + + use super::*; + + pub fn detect(app: &CraftApp) -> Option { + let bin = Path::new("/usr/local/bin").join(app.id); + if !bin.exists() { + return None; + } + Some(Installed { version: binary_version(&bin, app.id), source: InstallSource::Binary, location: bin.display().to_string() }) + } + + /// ` --version` prints ` 0.5.0 (sha, date)`. + fn binary_version(bin: &Path, id: &str) -> Option { + let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?; + let rest = out.trim().strip_prefix(id)?.trim_start(); + version::parse(rest.split_whitespace().next()?) + } +} diff --git a/crates/native/src/http.rs b/crates/native/src/http.rs new file mode 100644 index 0000000..de12561 --- /dev/null +++ b/crates/native/src/http.rs @@ -0,0 +1,219 @@ +//! HTTPS requests: small JSON/text answers, redirect probes, and verified downloads. + +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; + +const USER_AGENT: &str = concat!("ArtCraftLauncher/", env!("CARGO_PKG_VERSION")); +/// Largest JSON or text answer we read. +const MAX_SMALL: u64 = 4 << 20; + +/// What a GET of a small resource produced. +#[derive(Debug, PartialEq, Eq)] +pub enum Fetched { + Body { + text: String, + etag: Option, + }, + /// `If-None-Match` matched: the saved copy is current. + NotModified, + NotFound, + /// GitHub's API refused: out of quota until `reset` (Unix seconds), if it said. + RateLimited { + reset: Option, + }, +} + +#[derive(Clone)] +pub struct Http { + small: ureq::Agent, + no_redirect: ureq::Agent, + download: ureq::Agent, + token: Option, +} + +impl Http { + pub fn new() -> Result { + let roots = os_roots()?; + let tls = || ureq::tls::TlsConfig::builder().root_certs(roots.clone()).build(); + let small = ureq::Agent::config_builder() + .tls_config(tls()) + .timeout_global(Some(Duration::from_secs(20))) + .http_status_as_error(false) + .user_agent(USER_AGENT) + .build() + .new_agent(); + let no_redirect = ureq::Agent::config_builder() + .tls_config(tls()) + .timeout_global(Some(Duration::from_secs(20))) + .http_status_as_error(false) + .max_redirects(0) + .max_redirects_will_error(false) + .user_agent(USER_AGENT) + .build() + .new_agent(); + let download = ureq::Agent::config_builder() + .tls_config(tls()) + .timeout_connect(Some(Duration::from_secs(20))) + .timeout_recv_response(Some(Duration::from_secs(60))) + .http_status_as_error(false) + .user_agent(USER_AGENT) + .build() + .new_agent(); + // An optional token raises GitHub's limit from 60 to 5000 requests an hour. Only ever sent + // to api.github.com. + let token = std::env::var("ARTCRAFT_LAUNCHER_GITHUB_TOKEN").ok().map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + Ok(Self { small, no_redirect, download, token }) + } + + /// GET a GitHub API resource, revalidating with `etag` when given. + pub fn get_api(&self, url: &str, etag: Option<&str>) -> Result { + let mut req = self.small.get(url).header("Accept", "application/vnd.github+json").header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(tag) = etag { + req = req.header("If-None-Match", tag); + } + if let Some(token) = &self.token + && url.starts_with("https://api.github.com/") + { + req = req.header("Authorization", &format!("Bearer {token}")); + } + let mut resp = req.call().map_err(|e| format!("couldn't reach GitHub ({e})"))?; + let header = |name: &str| resp.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string); + let status = resp.status().as_u16(); + match status { + 200 => { + let etag = header("etag"); + let text = + resp.body_mut().with_config().limit(MAX_SMALL).read_to_string().map_err(|e| format!("unreadable answer from GitHub ({e})"))?; + Ok(Fetched::Body { text, etag }) + } + 304 => Ok(Fetched::NotModified), + 404 => Ok(Fetched::NotFound), + 403 | 429 => { + let remaining = header("x-ratelimit-remaining"); + let reset = header("x-ratelimit-reset").and_then(|r| r.parse::().ok()); + let retry_after = header("retry-after").and_then(|r| r.parse::().ok()).map(|s| crate::now() + s); + if status == 429 || remaining.as_deref() == Some("0") || retry_after.is_some() { + Ok(Fetched::RateLimited { reset: reset.or(retry_after) }) + } else { + Err(format!("GitHub refused the request (HTTP {status})")) + } + } + s => Err(format!("GitHub answered HTTP {s}")), + } + } + + /// GET a small text file (following redirects). + pub fn get_text(&self, url: &str) -> Result, String> { + let mut resp = self.small.get(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?; + match resp.status().as_u16() { + 200 => resp.body_mut().with_config().limit(MAX_SMALL).read_to_string().map(Some).map_err(|e| format!("unreadable answer ({e})")), + 404 => Ok(None), + s => Err(format!("{} answered HTTP {s}", host(url))), + } + } + + /// Where `url` redirects to, without following it. + pub fn redirect_target(&self, url: &str) -> Result, String> { + let resp = self.no_redirect.head(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?; + match resp.status().as_u16() { + 300..=399 => Ok(resp.headers().get("location").and_then(|v| v.to_str().ok()).map(str::to_string)), + 404 => Ok(None), + s => Err(format!("{} answered HTTP {s}", host(url))), + } + } + + /// Downloads `url` to `dest`, checking the size (when known) and SHA-256 as it goes. + /// `progress(done, total)` is called often; `cancel` stops the download. + pub fn download( + &self, + url: &str, + dest: &Path, + size: u64, + sha256: &str, + cancel: &AtomicBool, + progress: &mut dyn FnMut(u64, u64), + ) -> Result<(), String> { + let mut resp = self.download.get(url).call().map_err(|e| format!("couldn't start the download ({e})"))?; + let status = resp.status().as_u16(); + if status != 200 { + return Err(format!("the download failed (HTTP {status})")); + } + let total = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::().ok()).unwrap_or(size); + if size > 0 && total != size { + return Err(format!("the server offered {total} bytes, but the release lists {size}")); + } + let limit = if total > 0 { total } else { 8 << 30 }; + let mut reader = resp.body_mut().with_config().limit(limit).reader(); + if let Some(dir) = dest.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?; + } + let part = dest.with_extension("part"); + let mut file = std::fs::File::create(&part).map_err(|e| format!("couldn't save the download ({e})"))?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 256 * 1024]; + let mut done: u64 = 0; + progress(0, total); + loop { + if cancel.load(Ordering::Relaxed) { + drop(file); + let _ = std::fs::remove_file(&part); + return Err("cancelled".into()); + } + let n = reader.read(&mut buf).map_err(|e| format!("the download was interrupted ({e})"))?; + if n == 0 { + break; + } + let chunk = buf.get(..n).ok_or("read past the buffer")?; + hasher.update(chunk); + file.write_all(chunk).map_err(|e| format!("couldn't save the download ({e})"))?; + done = done.saturating_add(n as u64); + progress(done, total); + } + file.sync_all().map_err(|e| format!("couldn't save the download ({e})"))?; + drop(file); + if total > 0 && done != total { + let _ = std::fs::remove_file(&part); + return Err(format!("the download stopped early ({done} of {total} bytes)")); + } + let got = hex(&hasher.finalize()); + if !got.eq_ignore_ascii_case(sha256) { + let _ = std::fs::remove_file(&part); + return Err("the download doesn't match its published checksum, so it wasn't installed".into()); + } + std::fs::rename(&part, dest).map_err(|e| format!("couldn't save the download ({e})")) + } +} + +/// The certificate authorities the operating system trusts. +fn os_roots() -> Result { + let found = rustls_native_certs::load_native_certs(); + let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect(); + if certs.is_empty() { + return Err("no trusted certificates found on this system".into()); + } + Ok(ureq::tls::RootCerts::new_with_certs(&certs)) +} + +pub fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// SHA-256 of a file on disk. +pub fn sha256_file(path: &Path) -> Result { + let mut file = std::fs::File::open(path).map_err(|e| format!("couldn't read {} ({e})", path.display()))?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).map_err(|e| format!("couldn't read {} ({e})", path.display()))?; + Ok(hex(&hasher.finalize())) +} + +fn host(url: &str) -> &str { + url.split("://").nth(1).and_then(|r| r.split('/').next()).unwrap_or(url) +} diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs new file mode 100644 index 0000000..fbe2595 --- /dev/null +++ b/crates/native/src/install.rs @@ -0,0 +1,728 @@ +//! Installing, updating, removing and starting Craft Apps. +//! +//! Apps go where their platform keeps applications, installed the platform's own way: +//! +//! - macOS: the signed `.app` from the release DMG into `/Applications`. +//! - Windows: the release MSI (Windows Installer) into `C:\Program Files\`. +//! - Linux: the distro's package (`.deb` through apt, `.rpm` through dnf/zypper/rpm) with the +//! system's polkit password prompt; otherwise a Flatpak, otherwise an AppImage in +//! `~/Applications` (the AppImage convention). +//! - FreeBSD: the release's `/usr/local` tree, with a polkit prompt (or `~/.local` without one). +//! +//! Every download is checked against its published SHA-256 before anything is unpacked, a +//! running app is never replaced, and a new copy is fully in place before the old one goes. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; + +use artcraft_launcher_core::protocol::JobPhase; +use artcraft_launcher_core::release::{self, Asset, Release}; +use artcraft_launcher_core::{CraftApp, InstallSource, Installed, PackageKind}; + +use crate::http::Http; +use crate::manifest::Record; +use crate::paths::Paths; +use crate::process; + +/// What an install needs from the backend. +pub struct Job<'a> { + pub http: &'a Http, + pub paths: &'a Paths, + pub cancel: &'a AtomicBool, + pub phase: &'a mut dyn FnMut(JobPhase), +} + +/// Downloads and installs `asset` of `release`. Returns a record when the launcher owns the +/// result (portable zip, AppImage, tarball); MSI and macOS installs are found by detection. +pub fn install( + app: &CraftApp, + release: &Release, + asset: &Asset, + kind: PackageKind, + existing: Option<&Installed>, + job: &mut Job<'_>, +) -> Result, String> { + if let Some(inst) = existing + && inst.source.launcher_can_update() + && process::is_running(Path::new(&inst.location)) + { + return Err(format!("{} is running. Quit it, then try again.", app.name)); + } + let file = download(release, asset, job)?; + (job.phase)(JobPhase::Installing); + #[allow(unused_variables)] + let version = release.version_label(); + let result = match kind { + #[cfg(target_os = "macos")] + PackageKind::Dmg => macos::install_dmg(app, &file, existing, job.paths).map(|_| None), + #[cfg(windows)] + PackageKind::Msi => windows::run_msi(&file).map(|_| None), + #[cfg(target_os = "linux")] + PackageKind::Deb | PackageKind::Rpm | PackageKind::Flatpak => linux::install_package(kind, &file).map(|_| None), + #[cfg(target_os = "linux")] + PackageKind::AppImage => { + linux::install_appimage(app, &file, &asset.name, existing).map(|path| Some(record(&version, kind, path, Vec::new(), false))) + } + #[cfg(target_os = "freebsd")] + PackageKind::TarGz => freebsd::install_tree(app, &file, job.paths).map(|(bin, files, root)| Some(record(&version, kind, bin, files, root))), + other => Err(format!("{} isn't supported on this system", other.label())), + }; + // The download has served its purpose either way; a failed install starts fresh next time. + let _ = std::fs::remove_file(&file); + result +} + +#[allow(dead_code)] +fn record(version: &str, kind: PackageKind, path: PathBuf, files: Vec, needs_root: bool) -> Record { + Record { version: version.to_string(), kind, path, installed_at: crate::now(), files, needs_root } +} + +/// The package format to install `app` with: the format of the copy that's there (an update +/// stays a `.deb` update), else the user's choice, else the best this system supports. +pub fn choose_kinds(existing: Option<&Installed>, record: Option<&Record>, preferred: &[PackageKind]) -> Vec { + let available = available_kinds(); + if let Some(inst) = existing { + let kind = match inst.source { + InstallSource::Launcher => record.map(|r| r.kind), + InstallSource::MacApp => Some(PackageKind::Dmg), + InstallSource::Msi => Some(PackageKind::Msi), + InstallSource::Deb => Some(PackageKind::Deb), + InstallSource::Rpm => Some(PackageKind::Rpm), + InstallSource::Flatpak => Some(PackageKind::Flatpak), + InstallSource::AppImage => Some(PackageKind::AppImage), + InstallSource::Binary => Some(PackageKind::TarGz), + }; + if let Some(k) = kind.filter(|k| available.contains(k)) { + return vec![k]; + } + } + let mut kinds: Vec = preferred.iter().copied().filter(|k| available.contains(k)).collect(); + for k in available { + if !kinds.contains(&k) { + kinds.push(k); + } + } + kinds +} + +/// Package formats this machine can install, best first. +pub fn available_kinds() -> Vec { + #[cfg(target_os = "macos")] + return vec![PackageKind::Dmg]; + #[cfg(windows)] + return vec![PackageKind::Msi]; + #[cfg(target_os = "linux")] + return linux::available_kinds(); + #[cfg(target_os = "freebsd")] + return vec![PackageKind::TarGz]; + #[allow(unreachable_code)] + Vec::new() +} + +/// Fetches `asset` into the download folder and checks it against its SHA-256. +fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result { + if !release::valid_file_name(&asset.name) { + return Err("the release has an unexpected file name".into()); + } + let sha = match &asset.sha256 { + Some(s) => s.clone(), + None => { + // Older releases predate GitHub's per-asset digests: use SHA256SUMS.txt. + let sums_asset = release.sums_asset().ok_or("this release publishes no checksums, so it can't be verified")?; + let text = job.http.get_text(&sums_asset.url)?.ok_or("this release's checksum list is missing")?; + release::parse_sha256sums(&text) + .into_iter() + .find(|(_, n)| *n == asset.name) + .map(|(h, _)| h) + .ok_or("this file isn't in the release's checksum list")? + } + }; + let dest = job.paths.downloads().join(&asset.name); + let phase = &mut *job.phase; + job.http.download(&asset.url, &dest, asset.size, &sha, job.cancel, &mut |done, total| phase(JobPhase::Downloading { done, total }))?; + (job.phase)(JobPhase::Verifying); + Ok(dest) +} + +/// Removes an installed copy. +pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>) -> Result<(), String> { + let _ = record; + let path = PathBuf::from(&installed.location); + if installed.source == InstallSource::Binary { + return Err(format!("{} was installed outside the launcher. Remove it the way it was installed.", app.name)); + } + if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher) + && process::is_running(&path) + { + return Err(format!("{} is running. Quit it, then try again.", app.name)); + } + match installed.source { + #[cfg(windows)] + InstallSource::Msi => windows::uninstall_msi(app), + #[cfg(target_os = "macos")] + InstallSource::MacApp | InstallSource::Launcher => macos::move_to_trash(&path), + #[cfg(target_os = "linux")] + InstallSource::Deb | InstallSource::Rpm | InstallSource::Flatpak => linux::remove_package(app, installed.source), + #[cfg(target_os = "freebsd")] + InstallSource::Launcher => match record { + Some(r) => freebsd::remove_files(&r.files, r.needs_root), + None => Err("the launcher has no record of these files".into()), + }, + #[cfg(target_os = "linux")] + InstallSource::AppImage | InstallSource::Launcher => remove_appimage(app, &path), + other => Err(format!("{} can't be removed from here", other.label())), + } +} + +#[allow(dead_code)] +/// Deletes an AppImage, and the menu entry it created for itself on first run. +fn remove_appimage(app: &CraftApp, path: &Path) -> Result<(), String> { + if !path.extension().is_some_and(|e| e == "AppImage") || !path.is_file() { + return Err(format!("{} isn't an AppImage, so it was left alone", path.display())); + } + std::fs::remove_file(path).map_err(|e| format!("couldn't remove {} ({e})", path.display()))?; + if let Some(data) = dirs::data_dir() { + let desktop = data.join("applications").join(format!("{}.desktop", app.bundle_id())); + if std::fs::read_to_string(&desktop).is_ok_and(|t| t.contains(&path.display().to_string())) { + let _ = std::fs::remove_file(desktop); + } + } + Ok(()) +} + +/// Starts an installed app. +pub fn launch(app: &CraftApp, installed: &Installed) -> Result<(), String> { + let path = Path::new(&installed.location); + match installed.source { + InstallSource::Flatpak => { + let mut cmd = process::command("flatpak"); + cmd.args(["run", &app.bundle_id()]); + process::spawn_detached(cmd) + } + InstallSource::MacApp => process::open(path.as_os_str()), + _ if cfg!(target_os = "macos") && path.extension().is_some_and(|e| e == "app") => process::open(path.as_os_str()), + _ => { + if !path.exists() { + return Err(format!("{} isn't where it was installed ({})", app.name, path.display())); + } + process::launch_binary(path, &[]) + } + } + .map_err(|e| format!("Couldn't open {}: {e}", app.name)) +} + +/// Moves `staged` into place at `dest`, keeping the old copy until the new one is there. +#[allow(dead_code)] +fn swap_into_place(staged: &Path, dest: &Path) -> Result<(), String> { + let old = dest.with_extension("old"); + if old.exists() { + let _ = if old.is_dir() { std::fs::remove_dir_all(&old) } else { std::fs::remove_file(&old) }; + } + let had_old = dest.exists(); + if had_old { + std::fs::rename(dest, &old).map_err(|e| format!("couldn't move the old copy aside ({e})"))?; + } + if let Err(e) = std::fs::rename(staged, dest) { + if had_old { + let _ = std::fs::rename(&old, dest); + } + return Err(format!("couldn't put the new copy in place ({e})")); + } + if had_old { + let _ = if old.is_dir() { std::fs::remove_dir_all(&old) } else { std::fs::remove_file(&old) }; + } + Ok(()) +} + +/// After unpacking an archive into `dir`: its single top-level folder, if it has one. +#[allow(dead_code)] +fn single_top_folder(dir: &Path) -> Result { + let entries: Vec<_> = std::fs::read_dir(dir).map_err(|e| e.to_string())?.flatten().collect(); + match entries.as_slice() { + [only] if only.path().is_dir() => Ok(only.path()), + _ => Ok(dir.to_path_buf()), + } +} + +/// The app binary inside an unpacked archive: `bin/`, `` or `.exe`. +#[allow(dead_code)] +fn find_binary(root: &Path, app: &CraftApp) -> Option { + let exe = if cfg!(windows) { format!("{}.exe", app.id) } else { app.id.to_string() }; + [root.join("bin").join(&exe), root.join(&exe)].into_iter().find(|p| p.is_file()) +} + +/// Finds a program on `PATH`. +#[allow(dead_code)] +fn which(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file()) +} + +/// Runs a command as root through polkit (`pkexec`), which shows the desktop's password prompt. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> { + let pkexec = which("pkexec").ok_or("installing system-wide needs polkit (pkexec), which isn't installed")?; + let status = process::command(pkexec).args(args).status().map_err(|e| format!("couldn't ask for permission ({e})"))?; + match status.code() { + Some(0) => Ok(()), + // pkexec: 126 = the password prompt was dismissed, 127 = not authorized. + Some(126 | 127) => Err("cancelled".into()), + Some(c) => Err(format!("the system installer stopped with error {c}")), + None => Err("the system installer was interrupted".into()), + } +} + +#[cfg(target_os = "linux")] +mod linux { + use std::ffi::OsStr; + use std::os::unix::fs::PermissionsExt; + + use super::*; + + /// What this system can install: its own package format first (when polkit can ask for + /// permission), then Flatpak, then AppImage, which works everywhere. + pub fn available_kinds() -> Vec { + let mut kinds = Vec::new(); + let root = which("pkexec").is_some(); + if root && which("apt-get").is_some() && which("dpkg").is_some() { + kinds.push(PackageKind::Deb); + } + if root && which("rpm").is_some() && which("dpkg").is_none() { + kinds.push(PackageKind::Rpm); + } + if which("flatpak").is_some() { + kinds.push(PackageKind::Flatpak); + } + kinds.push(PackageKind::AppImage); + kinds + } + + /// Installs (or upgrades) a downloaded package with the system's own tools. + pub fn install_package(kind: PackageKind, file: &Path) -> Result<(), String> { + let f = file.as_os_str(); + match kind { + // apt resolves dependencies and records the package like any other. + PackageKind::Deb => run_as_root(&[OsStr::new("apt-get"), OsStr::new("install"), OsStr::new("-y"), OsStr::new("--allow-downgrades"), f]), + PackageKind::Rpm => { + if which("dnf").is_some() { + run_as_root(&[OsStr::new("dnf"), OsStr::new("install"), OsStr::new("-y"), f]) + } else if which("zypper").is_some() { + run_as_root(&[ + OsStr::new("zypper"), + OsStr::new("--non-interactive"), + OsStr::new("install"), + OsStr::new("--allow-unsigned-rpm"), + f, + ]) + } else { + run_as_root(&[OsStr::new("rpm"), OsStr::new("-U"), OsStr::new("--replacepkgs"), f]) + } + } + PackageKind::Flatpak => { + let status = process::command("flatpak") + .args(["install", "--user", "-y", "--noninteractive", "--bundle"]) + .arg(f) + .status() + .map_err(|e| format!("couldn't start Flatpak ({e})"))?; + if status.success() { + Ok(()) + } else { + Err("Flatpak couldn't install the bundle (it needs the Flathub remote for its runtime)".into()) + } + } + other => Err(format!("{} isn't a system package", other.label())), + } + } + + pub fn remove_package(app: &CraftApp, source: InstallSource) -> Result<(), String> { + match source { + InstallSource::Deb => run_as_root(&[OsStr::new("apt-get"), OsStr::new("remove"), OsStr::new("-y"), OsStr::new(app.id)]), + InstallSource::Rpm => { + if which("dnf").is_some() { + run_as_root(&[OsStr::new("dnf"), OsStr::new("remove"), OsStr::new("-y"), OsStr::new(app.id)]) + } else { + run_as_root(&[OsStr::new("rpm"), OsStr::new("-e"), OsStr::new(app.id)]) + } + } + InstallSource::Flatpak => { + let id = app.bundle_id(); + let user = process::command("flatpak").args(["uninstall", "--user", "-y", "--noninteractive", &id]).status(); + if user.is_ok_and(|s| s.success()) { + return Ok(()); + } + run_as_root(&[ + OsStr::new("flatpak"), + OsStr::new("uninstall"), + OsStr::new("--system"), + OsStr::new("-y"), + OsStr::new("--noninteractive"), + OsStr::new(&id), + ]) + } + other => Err(format!("{} isn't a system package", other.label())), + } + } + + /// Copies an AppImage into `~/Applications` and makes it executable; removes the copy it + /// replaces. + pub fn install_appimage(app: &CraftApp, file: &Path, name: &str, existing: Option<&Installed>) -> Result { + let dir = crate::detect::linux::appimage_dir().ok_or("no home folder")?; + std::fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?; + let dest = dir.join(name); + let partial = dir.join(format!(".{name}.partial")); + std::fs::copy(file, &partial).map_err(|e| format!("couldn't copy the AppImage ({e})"))?; + std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?; + std::fs::rename(&partial, &dest).map_err(|e| format!("couldn't put the AppImage in place ({e})"))?; + // Older AppImages of this app (the one we replaced, or earlier downloads) go. + let mut old: Vec = crate::detect::linux::appimages(app).into_iter().map(|(p, _)| p).collect(); + if let Some(inst) = existing.filter(|i| matches!(i.source, InstallSource::AppImage | InstallSource::Launcher)) { + old.push(PathBuf::from(&inst.location)); + } + for p in old.into_iter().filter(|p| *p != dest && p.extension().is_some_and(|e| e == "AppImage")) { + let _ = std::fs::remove_file(p); + } + Ok(dest) + } +} + +#[cfg(target_os = "macos")] +pub mod macos { + use std::time::Duration; + + use super::*; + + /// `/Applications` when we can write there, else `~/Applications`. + fn applications_dir(existing: Option<&Installed>) -> Result { + if let Some(parent) = + existing.filter(|i| i.source == InstallSource::MacApp).and_then(|i| Path::new(&i.location).parent().map(Path::to_path_buf)) + { + return Ok(parent); + } + let system = PathBuf::from("/Applications"); + let probe = system.join(".artcraft-launcher-write-test"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + return Ok(system); + } + let user = dirs::home_dir().ok_or("no home folder")?.join("Applications"); + std::fs::create_dir_all(&user).map_err(|e| format!("couldn't create {} ({e})", user.display()))?; + Ok(user) + } + + pub fn install_dmg(app: &CraftApp, dmg: &Path, existing: Option<&Installed>, paths: &Paths) -> Result { + let mount = paths.cache.join(format!("mount-{}", app.id)); + let _ = detach(&mount); + std::fs::create_dir_all(&mount).map_err(|e| e.to_string())?; + let (Some(dmg_s), Some(mount_s)) = (dmg.to_str(), mount.to_str()) else { return Err("unusual characters in the cache folder's path".into()) }; + process::capture("hdiutil", &["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountpoint", mount_s, dmg_s], Duration::from_secs(120)) + .ok_or("couldn't open the disk image")?; + let result = copy_app(app, &mount, existing); + let _ = detach(&mount); + let _ = std::fs::remove_dir(&mount); + result + } + + fn copy_app(app: &CraftApp, mount: &Path, existing: Option<&Installed>) -> Result { + let bundle = format!("{}.app", app.name); + let src = mount.join(&bundle); + crate::detect::macos::read_bundle(app, &src).ok_or("the disk image doesn't contain the app")?; + let dir = applications_dir(existing)?; + let dest = dir.join(&bundle); + let staged = dir.join(format!(".{bundle}.partial")); + let _ = std::fs::remove_dir_all(&staged); + let (Some(src_s), Some(staged_s)) = (src.to_str(), staged.to_str()) else { return Err("unusual characters in a path".into()) }; + // ditto keeps the code signature, extended attributes and symlinks intact. + process::capture("ditto", &[src_s, staged_s], Duration::from_secs(600)).ok_or("couldn't copy the app")?; + if crate::detect::macos::read_bundle(app, &staged).is_none() { + let _ = std::fs::remove_dir_all(&staged); + return Err("the copied app is incomplete".into()); + } + swap_into_place(&staged, &dest)?; + Ok(dest) + } + + fn detach(mount: &Path) -> Option { + let m = mount.to_str()?; + if !mount.join(".").exists() { + return None; + } + process::capture("hdiutil", &["detach", m, "-quiet"], Duration::from_secs(60)) + .or_else(|| process::capture("hdiutil", &["detach", m, "-force", "-quiet"], Duration::from_secs(60))) + } + + /// Moves an app to the user's Trash (so it can be put back). + pub fn move_to_trash(path: &Path) -> Result<(), String> { + let trash = dirs::home_dir().ok_or("no home folder")?.join(".Trash"); + let name = path.file_name().ok_or("not an app")?; + let mut dest = trash.join(name); + if dest.exists() { + dest = trash.join(format!("{} {}", name.to_string_lossy(), crate::now())); + } + if std::fs::rename(path, &dest).is_ok() { + return Ok(()); + } + std::fs::remove_dir_all(path).map_err(|e| format!("couldn't remove {} ({e})", path.display())) + } +} + +/// FreeBSD: the release tarball holds a `/usr/local`-style tree (`bin/`, +/// `share/applications`, icons). Older releases had the files flat in one folder; those are laid +/// out the same way first. +#[cfg(unix)] +#[allow(dead_code)] +mod tree { + use super::*; + + /// Unpacks `file` and returns a staging folder laid out like `/usr/local`. + pub fn unpack(app: &CraftApp, file: &Path, staging: &Path) -> Result { + let _ = std::fs::remove_dir_all(staging); + std::fs::create_dir_all(staging).map_err(|e| format!("couldn't create {} ({e})", staging.display()))?; + let gz = std::fs::File::open(file).map_err(|e| e.to_string())?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(gz)); + archive.set_preserve_permissions(true); + // `unpack` refuses entries that would land outside `staging` (`..`, absolute paths). + archive.unpack(staging).map_err(|e| format!("couldn't unpack the download ({e})"))?; + let top = single_top_folder(staging)?; + if top.join("bin").join(app.id).is_file() { + return Ok(top); + } + // Flat layout: put each file where /usr/local keeps it. + let tree = staging.join("tree"); + let id = app.bundle_id(); + for (from, to) in [ + (top.join(app.id), tree.join("bin").join(app.id)), + (top.join(format!("{}-cli", app.id)), tree.join("bin").join(format!("{}-cli", app.id))), + (top.join(format!("{id}.desktop")), tree.join("share/applications").join(format!("{id}.desktop"))), + (top.join(format!("{id}.png")), tree.join("share/pixmaps").join(format!("{id}.png"))), + ] { + if from.is_file() { + if let Some(dir) = to.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::rename(&from, &to).map_err(|e| e.to_string())?; + } + } + if !tree.join("bin").join(app.id).is_file() { + return Err("the download doesn't contain the app".into()); + } + Ok(tree) + } + + /// Every file under `root`, relative to it. + pub fn files(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() { + let p = entry.path(); + if p.is_dir() { + stack.push(p); + } else if let Ok(rel) = p.strip_prefix(root) { + out.push(rel.to_path_buf()); + } + } + } + out.sort(); + out + } +} + +#[cfg(target_os = "freebsd")] +mod freebsd { + use std::ffi::OsStr; + + use super::*; + + /// Copies the release's tree into `/usr/local` (asking for permission through polkit), or + /// into `~/.local` when polkit isn't installed. Returns the binary, every file installed, + /// and whether removing them needs root. + pub fn install_tree(app: &CraftApp, file: &Path, paths: &Paths) -> Result<(PathBuf, Vec, bool), String> { + let staging = paths.cache.join(format!("{}.new", app.id)); + let result = install_from(app, file, &staging); + let _ = std::fs::remove_dir_all(&staging); + result + } + + fn install_from(app: &CraftApp, file: &Path, staging: &Path) -> Result<(PathBuf, Vec, bool), String> { + let tree = tree::unpack(app, file, staging)?; + let rel = tree::files(&tree); + let mut source = tree.into_os_string(); + source.push("/."); + if which("pkexec").is_some() { + let prefix = PathBuf::from("/usr/local"); + run_as_root(&[OsStr::new("/bin/cp"), OsStr::new("-R"), &source, prefix.as_os_str()])?; + return Ok((prefix.join("bin").join(app.id), rel.iter().map(|r| prefix.join(r)).collect(), true)); + } + let prefix = dirs::home_dir().ok_or("no home folder")?.join(".local"); + std::fs::create_dir_all(&prefix).map_err(|e| e.to_string())?; + let status = process::command("/bin/cp").arg("-R").arg(&source).arg(&prefix).status().map_err(|e| e.to_string())?; + if !status.success() { + return Err("couldn't copy the app into ~/.local".into()); + } + // ~/.local/bin is often not on PATH for the desktop: point the menu entry at the binary. + let bin = prefix.join("bin").join(app.id); + let desktop = prefix.join("share/applications").join(format!("{}.desktop", app.bundle_id())); + if let Ok(text) = std::fs::read_to_string(&desktop) { + let exec = format!("Exec=\"{}\"", bin.display()); + let fixed: String = text + .lines() + .map(|l| { + if let Some(rest) = l.strip_prefix("Exec=").and_then(|r| r.strip_prefix(app.id)) { + format!("{exec}{rest}\n") + } else { + format!("{l}\n") + } + }) + .collect(); + let _ = std::fs::write(&desktop, fixed); + } + Ok((bin, rel.iter().map(|r| prefix.join(r)).collect(), false)) + } + + /// Removes the files a tree install put down. + pub fn remove_files(files: &[PathBuf], needs_root: bool) -> Result<(), String> { + if files.is_empty() { + return Err("the launcher has no record of these files".into()); + } + if needs_root { + let mut args: Vec<&OsStr> = vec![OsStr::new("/bin/rm"), OsStr::new("-f"), OsStr::new("--")]; + args.extend(files.iter().map(|f| f.as_os_str())); + return run_as_root(&args); + } + for f in files { + let _ = std::fs::remove_file(f); + } + Ok(()) + } +} + +#[cfg(windows)] +pub mod windows { + use super::*; + + fn msiexec() -> PathBuf { + std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from).join(r"System32\msiexec.exe") + } + + fn explain(code: Option) -> Result<(), String> { + match code { + Some(0 | 3010 | 1641) => Ok(()), + Some(1602) => Err("cancelled".into()), + Some(1618) => Err("another installation is in progress. Wait for it to finish, then try again.".into()), + Some(1925 | 1303) => Err("Windows didn't allow the install (administrator permission is needed)".into()), + Some(c) => Err(format!("the Windows Installer stopped with error {c}")), + None => Err("the Windows Installer was interrupted".into()), + } + } + + /// Runs an MSI with a progress window and no restart. Windows asks for permission itself. + pub fn run_msi(msi: &Path) -> Result<(), String> { + let status = process::command(msiexec()) + .arg("/i") + .arg(msi) + .args(["/passive", "/norestart"]) + .status() + .map_err(|e| format!("couldn't start the Windows Installer ({e})"))?; + explain(status.code()) + } + + pub fn uninstall_msi(app: &CraftApp) -> Result<(), String> { + let installs = crate::detect::windows::msi_installs(app); + if installs.is_empty() { + return Err(format!("{} isn't registered with the Windows Installer", app.name)); + } + for m in installs { + let status = process::command(msiexec()) + .args(["/x", &m.product_code, "/passive", "/norestart"]) + .status() + .map_err(|e| format!("couldn't start the Windows Installer ({e})"))?; + explain(status.code())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn swap_keeps_the_old_copy_until_the_new_one_is_in_place() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("app"); + let staged = dir.path().join("app.new"); + std::fs::create_dir(&dest).unwrap(); + std::fs::write(dest.join("v"), "old").unwrap(); + std::fs::create_dir(&staged).unwrap(); + std::fs::write(staged.join("v"), "new").unwrap(); + swap_into_place(&staged, &dest).unwrap(); + assert_eq!(std::fs::read_to_string(dest.join("v")).unwrap(), "new"); + assert!(!staged.exists() && !dir.path().join("app.old").exists()); + // A missing staged copy leaves the installed one alone. + assert!(swap_into_place(&staged, &dest).is_err()); + assert_eq!(std::fs::read_to_string(dest.join("v")).unwrap(), "new"); + } + + #[cfg(unix)] + fn tarball(path: &Path, entries: &[&str]) { + let f = std::fs::File::create(path).unwrap(); + let mut b = tar::Builder::new(flate2::write::GzEncoder::new(f, flate2::Compression::fast())); + for name in entries { + let mut h = tar::Header::new_gnu(); + h.set_size(2); + h.set_mode(0o755); + h.set_cksum(); + b.append_data(&mut h, name, &b"hi"[..]).unwrap(); + } + b.into_inner().unwrap().finish().unwrap(); + } + + #[cfg(unix)] + #[test] + fn lays_out_both_freebsd_tarball_styles_like_usr_local() { + let app = CraftApp::by_id("wordcraft").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let tree_style = dir.path().join("tree.tar.gz"); + tarball( + &tree_style, + &["wordcraft-0.3.0-freebsd-x86_64/bin/wordcraft", "wordcraft-0.3.0-freebsd-x86_64/share/applications/ai.storyteller.wordcraft.desktop"], + ); + let flat_style = dir.path().join("flat.tar.gz"); + tarball( + &flat_style, + &[ + "wordcraft-0.3.0-freebsd-x86_64/wordcraft", + "wordcraft-0.3.0-freebsd-x86_64/wordcraft-cli", + "wordcraft-0.3.0-freebsd-x86_64/README.md", + "wordcraft-0.3.0-freebsd-x86_64/ai.storyteller.wordcraft.desktop", + "wordcraft-0.3.0-freebsd-x86_64/ai.storyteller.wordcraft.png", + ], + ); + for file in [tree_style, flat_style] { + let root = tree::unpack(app, &file, &dir.path().join("staging")).unwrap(); + let files = tree::files(&root); + assert!(files.contains(&PathBuf::from("bin/wordcraft")), "{files:?}"); + assert!(files.contains(&PathBuf::from("share/applications/ai.storyteller.wordcraft.desktop")), "{files:?}"); + assert!(!files.iter().any(|f| f.ends_with("README.md")), "{files:?}"); + } + let bad = dir.path().join("bad.tar.gz"); + tarball(&bad, &["x/README.md"]); + assert!(tree::unpack(app, &bad, &dir.path().join("staging")).is_err()); + } + + #[test] + fn updates_keep_the_installed_format() { + let inst = |source| Installed { version: None, source, location: String::new() }; + let available = available_kinds(); + if let Some(first) = available.first() { + assert_eq!(choose_kinds(None, None, &[]).first(), Some(first)); + } + #[cfg(target_os = "macos")] + assert_eq!(choose_kinds(Some(&inst(InstallSource::MacApp)), None, &[]), [PackageKind::Dmg]); + #[cfg(windows)] + assert_eq!(choose_kinds(Some(&inst(InstallSource::Msi)), None, &[]), [PackageKind::Msi]); + #[cfg(target_os = "linux")] + assert_eq!(choose_kinds(Some(&inst(InstallSource::AppImage)), None, &[]), [PackageKind::AppImage]); + let _ = inst; + } +} diff --git a/crates/native/src/lib.rs b/crates/native/src/lib.rs new file mode 100644 index 0000000..6b67727 --- /dev/null +++ b/crates/native/src/lib.rs @@ -0,0 +1,26 @@ +//! ArtCraft Launcher's desktop backend. +//! +//! [`NativeBackend`] runs every slow thing (HTTP, scanning disks, installers) on worker threads +//! and reports back through [`Event`](artcraft_launcher_core::protocol::Event)s, so the UI never +//! waits on it. Release information comes from a [`source::ReleaseSource`]: GitHub today, the +//! ArtCraft catalog API later. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod backend; +pub mod detect; +pub mod http; +pub mod install; +pub mod login; +pub mod manifest; +pub mod paths; +pub mod process; +pub mod single_instance; +pub mod source; + +pub use backend::NativeBackend; + +/// Unix seconds now. +pub fn now() -> i64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) +} diff --git a/crates/native/src/login.rs b/crates/native/src/login.rs new file mode 100644 index 0000000..138d17a --- /dev/null +++ b/crates/native/src/login.rs @@ -0,0 +1,113 @@ +//! "Start ArtCraft Launcher when I log in". +//! +//! - macOS: a LaunchAgent in `~/Library/LaunchAgents`. +//! - Windows: a value under `HKCU\…\CurrentVersion\Run`. +//! - Linux and FreeBSD: an XDG autostart entry in `~/.config/autostart`. +//! +//! The launcher starts hidden in the tray when it's started this way (`--hidden`). + +use std::path::PathBuf; + +#[cfg_attr(windows, allow(dead_code))] +const ID: &str = "ai.storyteller.artcraft-launcher"; +/// The command-line flag a login start passes. +pub const HIDDEN_FLAG: &str = "--hidden"; + +/// The program to start at login: the AppImage when running from one, else this executable. +fn program() -> Result { + if let Some(appimage) = std::env::var_os("APPIMAGE") { + return Ok(PathBuf::from(appimage)); + } + std::env::current_exe().map_err(|e| format!("couldn't find this program ({e})")) +} + +#[cfg(target_os = "macos")] +fn agent() -> Result { + Ok(dirs::home_dir().ok_or("no home folder")?.join("Library/LaunchAgents").join(format!("{ID}.plist"))) +} + +#[cfg(target_os = "macos")] +fn xml_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">") +} + +pub fn is_enabled() -> bool { + #[cfg(target_os = "macos")] + return agent().is_ok_and(|p| p.exists()); + #[cfg(windows)] + return windows_run().and_then(|k| k.get_value::("ArtCraft Launcher").ok()).is_some(); + #[cfg(not(any(target_os = "macos", windows)))] + return autostart().is_ok_and(|p| p.exists()); +} + +pub fn set_enabled(on: bool) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let path = agent()?; + if !on { + return remove(&path); + } + // Inside a bundle, `open -a` starts the app the way Finder does (one instance, Dock icon). + let exe = program()?; + let bundle = exe.ancestors().find(|p| p.extension().is_some_and(|e| e == "app")).map(PathBuf::from); + let args: Vec = match bundle { + Some(b) => vec!["/usr/bin/open".into(), "-a".into(), b.display().to_string(), "--args".into(), HIDDEN_FLAG.into()], + None => vec![exe.display().to_string(), HIDDEN_FLAG.into()], + }; + let args_xml: String = args.iter().map(|a| format!(" {}\n", xml_escape(a))).collect(); + let plist = format!( + "\n\n\ + \n\n Label\n {ID}\n ProgramArguments\n \n{args_xml} \n RunAtLoad\n \n\n\n" + ); + crate::paths::write_atomic(&path, plist.as_bytes()) + } + #[cfg(windows)] + { + use winreg::RegKey; + use winreg::enums::HKEY_CURRENT_USER; + let (key, _) = RegKey::predef(HKEY_CURRENT_USER) + .create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run") + .map_err(|e| format!("couldn't change the startup list ({e})"))?; + if on { + let value = format!("\"{}\" {HIDDEN_FLAG}", program()?.display()); + key.set_value("ArtCraft Launcher", &value).map_err(|e| format!("couldn't change the startup list ({e})")) + } else { + match key.delete_value("ArtCraft Launcher") { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("couldn't change the startup list ({e})")), + } + } + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let path = autostart()?; + if !on { + return remove(&path); + } + let exe = program()?.display().to_string().replace('"', "\\\""); + let entry = format!( + "[Desktop Entry]\nType=Application\nName=ArtCraft Launcher\nComment=Install and update the Craft App Suite\nExec=\"{exe}\" {HIDDEN_FLAG}\nIcon={ID}\nTerminal=false\nX-GNOME-Autostart-enabled=true\n" + ); + crate::paths::write_atomic(&path, entry.as_bytes()) + } +} + +#[cfg(windows)] +fn windows_run() -> Option { + winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER).open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run").ok() +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn autostart() -> Result { + Ok(dirs::config_dir().ok_or("no config folder")?.join("autostart").join(format!("{ID}.desktop"))) +} + +#[cfg(not(windows))] +fn remove(path: &std::path::Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("couldn't remove {} ({e})", path.display())), + } +} diff --git a/crates/native/src/manifest.rs b/crates/native/src/manifest.rs new file mode 100644 index 0000000..923cd11 --- /dev/null +++ b/crates/native/src/manifest.rs @@ -0,0 +1,61 @@ +//! The launcher's record of what it installed itself. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use artcraft_launcher_core::PackageKind; +use serde::{Deserialize, Serialize}; + +/// One app the launcher installed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + pub version: String, + pub kind: PackageKind, + /// The `.app`, the exe, the AppImage, or the folder of an unpacked tarball. + pub path: PathBuf, + /// Unix seconds. + pub installed_at: i64, + /// Every file installed, for tree installs (FreeBSD), so removal takes exactly those. + #[serde(default)] + pub files: Vec, + /// The files are in a system prefix and removing them needs root. + #[serde(default)] + pub needs_root: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Manifest { + pub apps: BTreeMap, +} + +impl Manifest { + pub fn load(path: &Path) -> Self { + std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + } + + pub fn save(&self, path: &Path) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?; + crate::paths::write_atomic(path, &bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_survives_garbage() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("installed.json"); + assert_eq!(Manifest::load(&path), Manifest::default()); + let mut m = Manifest::default(); + m.apps.insert( + "photocraft".into(), + Record { version: "0.5.0".into(), kind: PackageKind::AppImage, path: "/x/y".into(), installed_at: 1, files: vec![], needs_root: false }, + ); + m.save(&path).unwrap(); + assert_eq!(Manifest::load(&path), m); + std::fs::write(&path, b"{not json").unwrap(); + assert_eq!(Manifest::load(&path), Manifest::default()); + } +} diff --git a/crates/native/src/paths.rs b/crates/native/src/paths.rs new file mode 100644 index 0000000..0b919e2 --- /dev/null +++ b/crates/native/src/paths.rs @@ -0,0 +1,64 @@ +//! Where the launcher keeps its own files. + +use std::path::PathBuf; + +/// The launcher's folders. +#[derive(Clone, Debug)] +pub struct Paths { + /// Records of what the launcher installed, and tarball installs. + pub data: PathBuf, + /// Saved release answers and in-progress downloads. + pub cache: PathBuf, +} + +const DIR: &str = if cfg!(any(target_os = "macos", target_os = "windows")) { "ArtCraft Launcher" } else { "artcraft-launcher" }; + +impl Paths { + pub fn new() -> Result { + if let Some(root) = portable_root() { + return Ok(Self::under(&root)); + } + let data = dirs::data_local_dir().ok_or("no per-user data folder on this system")?.join(DIR); + let cache = dirs::cache_dir().ok_or("no per-user cache folder on this system")?.join(DIR); + Ok(Self { data, cache }) + } + + /// For tests: everything under one folder. + pub fn under(root: &std::path::Path) -> Self { + Self { data: root.join("data"), cache: root.join("cache") } + } + + pub fn downloads(&self) -> PathBuf { + self.cache.join("downloads") + } + + pub fn release_cache(&self) -> PathBuf { + self.cache.join("releases.json") + } + + pub fn manifest(&self) -> PathBuf { + self.data.join("installed.json") + } + + /// Where tarball installs (FreeBSD, Linux without AppImage) are unpacked: `/apps/`. + pub fn apps(&self) -> PathBuf { + self.data.join("apps") + } +} + +/// Writes `bytes` to `path` via a temporary file and a rename, so a crash never leaves half a file. +pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { + let dir = path.parent().ok_or("no parent folder")?; + std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, bytes).map_err(|e| format!("couldn't write {} ({e})", tmp.display()))?; + std::fs::rename(&tmp, path).map_err(|e| format!("couldn't replace {} ({e})", path.display())) +} + +/// Portable mode: a `portable.txt` beside the executable keeps everything in +/// `ArtCraftLauncherData` next to it (the Windows portable zip ships one). +pub fn portable_root() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + dir.join("portable.txt").is_file().then(|| dir.join("ArtCraftLauncherData")) +} diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs new file mode 100644 index 0000000..369c2a6 --- /dev/null +++ b/crates/native/src/process.rs @@ -0,0 +1,126 @@ +//! Starting programs: the Craft Apps themselves, the file manager, and helper tools. + +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// A `Command` that won't flash a console window on Windows. +pub fn command(program: impl AsRef) -> Command { + #[allow(unused_mut)] + let mut cmd = Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} + +/// Runs a helper and returns its standard output when it succeeds within `timeout`. +pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option { + let mut child = command(program).args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut out = Vec::new(); + let _ = std::io::Read::read_to_end(&mut std::io::Read::take(&mut stdout, 8 << 20), &mut out); + out + }); + let start = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(15)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + let out = reader.join().ok()?; + status.success().then(|| String::from_utf8_lossy(&out).into_owned()) +} + +/// Starts `cmd` without waiting for it, and reaps it when it exits. +pub fn spawn_detached(mut cmd: Command) -> Result<(), String> { + let mut child = cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().map_err(|e| format!("couldn't start it ({e})"))?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +/// Starts an app binary from its own folder. +pub fn launch_binary(exe: &Path, args: &[&str]) -> Result<(), String> { + let mut cmd = command(exe); + cmd.args(args); + if let Some(dir) = exe.parent() { + cmd.current_dir(dir); + } + // An AppImage'd launcher sets these for itself; they'd break the app we start. + for var in [ + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "APPDIR", + "APPIMAGE", + "ARGV0", + "OWD", + "GDK_PIXBUF_MODULE_FILE", + "GIO_MODULE_DIR", + "GTK_PATH", + "PYTHONHOME", + "QT_PLUGIN_PATH", + ] { + if std::env::var_os("APPIMAGE").is_some() { + cmd.env_remove(var); + } + } + spawn_detached(cmd) +} + +/// Opens a file, folder or URL with the system's default handler. +pub fn open(target: &std::ffi::OsStr) -> Result<(), String> { + let mut cmd = if cfg!(target_os = "macos") { + command("open") + } else if cfg!(windows) { + let mut c = command("explorer"); + c.arg(target); + return spawn_detached(c); + } else { + command("xdg-open") + }; + cmd.arg(target); + spawn_detached(cmd) +} + +/// Shows `path` selected in the file manager. +pub fn reveal(path: &Path) -> Result<(), String> { + if cfg!(target_os = "macos") { + let mut cmd = command("open"); + cmd.arg("-R").arg(path); + spawn_detached(cmd) + } else if cfg!(windows) { + let mut arg = std::ffi::OsString::from("/select,"); + arg.push(path); + let mut cmd = command("explorer"); + cmd.arg(arg); + spawn_detached(cmd) + } else { + open(path.parent().unwrap_or(path).as_os_str()) + } +} + +/// Whether a process started from inside `path` (an exe, an AppImage, or a `.app`) is running. +pub fn is_running(path: &Path) -> bool { + if cfg!(windows) { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false }; + let filter = format!("IMAGENAME eq {name}"); + capture("tasklist", &["/FI", &filter, "/NH", "/FO", "CSV"], Duration::from_secs(5)) + .is_some_and(|out| out.to_ascii_lowercase().contains(&name.to_ascii_lowercase())) + } else { + let Some(p) = path.to_str() else { return false }; + // pgrep exits 0 when something matches; it never matches itself. + capture("pgrep", &["-f", "--", p], Duration::from_secs(5)).is_some() + } +} diff --git a/crates/native/src/single_instance.rs b/crates/native/src/single_instance.rs new file mode 100644 index 0000000..5aac28e --- /dev/null +++ b/crates/native/src/single_instance.rs @@ -0,0 +1,54 @@ +//! One launcher at a time: a second start asks the first to show its window, then exits. +//! +//! The first instance listens on a fixed loopback port. The only message is `show`, and the +//! worst another local program can do with it is bring the window forward. + +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::Duration; + +const PORT: u16 = 47_413; +const MESSAGE: &[u8] = b"artcraft-launcher:show"; + +pub enum Instance { + /// We're the only one. A message arrives on the receiver whenever another start asks to show + /// the window; `wake` is called to repaint the (perhaps hidden) window. + Primary(Receiver<()>), + /// Another launcher is running and has been asked to show itself. + Secondary, +} + +/// Claims the instance; `wake` is set later (the UI context doesn't exist yet). +pub fn claim(wake: std::sync::Arc>>) -> Instance { + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, PORT)); + match TcpListener::bind(addr) { + Ok(listener) => { + let (tx, rx) = channel(); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let mut buf = [0u8; 64]; + let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); + let n = (&stream).read(&mut buf).unwrap_or(0); + if buf.get(..n) == Some(MESSAGE) { + if tx.send(()).is_err() { + break; + } + if let Some(wake) = wake.get() { + wake(); + } + } + } + }); + Instance::Primary(rx) + } + Err(_) => match TcpStream::connect_timeout(&addr, Duration::from_secs(1)) { + Ok(mut stream) => { + let _ = stream.write_all(MESSAGE); + Instance::Secondary + } + // The port is taken by something else: run anyway rather than refuse to start. + Err(_) => Instance::Primary(channel().1), + }, + } +} diff --git a/crates/native/src/source.rs b/crates/native/src/source.rs new file mode 100644 index 0000000..c190ae7 --- /dev/null +++ b/crates/native/src/source.rs @@ -0,0 +1,181 @@ +//! Where release information comes from. +//! +//! [`GitHubSource`] asks the GitHub REST API (one request per app, revalidated with ETags) and, +//! when the API is out of quota, falls back to github.com's release pages: the +//! `releases/latest` redirect names the tag and `SHA256SUMS.txt` lists every file with its +//! checksum, which costs no API quota at all. A later [`ReleaseSource`] will ask the ArtCraft +//! catalog API for every app in one request. + +use std::collections::BTreeMap; +use std::sync::{Mutex, PoisonError}; + +use artcraft_launcher_core::protocol::{LatestRelease, Source}; +use artcraft_launcher_core::release::{self, Release}; +use serde::{Deserialize, Serialize}; + +use crate::http::{Fetched, Http}; + +/// One answer from a source. +#[derive(Clone, Debug)] +pub struct Answer { + pub latest: LatestRelease, + pub source: Source, +} + +/// Looks up latest releases. Called from worker threads. +pub trait ReleaseSource: Send + Sync { + fn latest(&self, repo: &str, prereleases: bool) -> Answer; + /// The last answer saved for `repo`, for an instant first paint. + fn cached(&self, repo: &str, prereleases: bool) -> Option; + /// API refused until this time (Unix seconds). + fn rate_limited_until(&self) -> Option; + /// Persist anything worth keeping. + fn save(&self); +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct Entry { + etag: Option, + /// `None`: the repo has no releases. + release: Option, + prereleases: bool, + fetched_at: i64, +} + +#[derive(Default, Serialize, Deserialize)] +struct CacheFile { + entries: BTreeMap, +} + +pub struct GitHubSource { + http: Http, + cache: Mutex, + cache_path: std::path::PathBuf, + limited_until: Mutex>, +} + +impl GitHubSource { + pub fn new(http: Http, cache_path: std::path::PathBuf) -> Self { + let cache = std::fs::read(&cache_path).ok().and_then(|b| serde_json::from_slice::(&b).ok()).unwrap_or_default(); + Self { http, cache: Mutex::new(cache), cache_path, limited_until: Mutex::new(None) } + } + + fn entry(&self, repo: &str, prereleases: bool) -> Option { + let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner); + cache.entries.get(repo).filter(|e| e.prereleases == prereleases).cloned() + } + + fn store(&self, repo: &str, entry: Entry) { + self.cache.lock().unwrap_or_else(PoisonError::into_inner).entries.insert(repo.to_string(), entry); + } + + fn limited(&self) -> bool { + let until = *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner); + until.is_some_and(|u| u > crate::now()) + } + + fn ask_api(&self, repo: &str, prereleases: bool) -> Result, String> { + let url = if prereleases { + format!("https://api.github.com/repos/{repo}/releases?per_page=30") + } else { + format!("https://api.github.com/repos/{repo}/releases/latest") + }; + let saved = self.entry(repo, prereleases); + let found = + |release: Option| Answer { latest: release.map_or(LatestRelease::None, LatestRelease::Found), source: Source::GitHubApi }; + match self.http.get_api(&url, saved.as_ref().and_then(|e| e.etag.as_deref()))? { + Fetched::Body { text, etag } => { + let release = if prereleases { + release::parse_github_release_list(&text, repo, true)? + } else { + Some(release::parse_github_release(&text, repo)?) + }; + self.store(repo, Entry { etag, release: release.clone(), prereleases, fetched_at: crate::now() }); + Ok(Some(found(release))) + } + Fetched::NotModified => match saved { + Some(e) => { + self.store(repo, Entry { fetched_at: crate::now(), ..e.clone() }); + Ok(Some(found(e.release))) + } + None => Err("GitHub said nothing changed, but there's no saved answer".into()), + }, + Fetched::NotFound => { + self.store(repo, Entry { etag: None, release: None, prereleases, fetched_at: crate::now() }); + Ok(Some(found(None))) + } + Fetched::RateLimited { reset } => { + *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner) = Some(reset.unwrap_or_else(|| crate::now() + 15 * 60)); + Ok(None) + } + } + } + + /// The quota-free route through github.com (stable releases only). + fn ask_pages(&self, repo: &str) -> Result { + let location = self.http.redirect_target(&format!("https://github.com/{repo}/releases/latest"))?; + let Some(tag) = location.as_deref().and_then(|l| release::tag_from_redirect(l, repo)) else { + // No published release: github.com sends us to the release list instead. + return Ok(Answer { latest: LatestRelease::None, source: Source::GitHubPages }); + }; + if let Some(e) = self.entry(repo, false) + && let Some(r) = e.release.as_ref().filter(|r| r.tag == tag) + { + return Ok(Answer { latest: LatestRelease::Found(r.clone()), source: Source::GitHubPages }); + } + let sums_url = format!("{}{tag}/SHA256SUMS.txt", release::download_prefix(repo)); + let sums = self.http.get_text(&sums_url)?.map(|t| release::parse_sha256sums(&t)).unwrap_or_default(); + let r = release::release_from_sums(repo, &tag, &sums); + self.store(repo, Entry { etag: None, release: Some(r.clone()), prereleases: false, fetched_at: crate::now() }); + Ok(Answer { latest: LatestRelease::Found(r), source: Source::GitHubPages }) + } +} + +impl ReleaseSource for GitHubSource { + fn latest(&self, repo: &str, prereleases: bool) -> Answer { + let api_error = if self.limited() { + None + } else { + match self.ask_api(repo, prereleases) { + Ok(Some(answer)) => return answer, + Ok(None) => None, + Err(e) => Some(e), + } + }; + if !prereleases && api_error.is_none() { + match self.ask_pages(repo) { + Ok(answer) => return answer, + Err(e) => log::warn!("{repo}: release pages: {e}"), + } + } + let why = api_error.unwrap_or_else(|| "GitHub's request limit was reached".into()); + match self.cached(repo, prereleases) { + Some(latest) => Answer { latest, source: Source::Cache }, + None => Answer { latest: LatestRelease::Failed(why), source: Source::Cache }, + } + } + + fn cached(&self, repo: &str, prereleases: bool) -> Option { + self.entry(repo, prereleases).map(|e| e.release.map_or(LatestRelease::None, LatestRelease::Found)) + } + + fn rate_limited_until(&self) -> Option { + let until = *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner); + until.filter(|u| *u > crate::now()) + } + + fn save(&self) { + let json = { + let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner); + serde_json::to_vec(&*cache) + }; + match json { + Ok(bytes) => { + if let Err(e) = crate::paths::write_atomic(&self.cache_path, &bytes) { + log::warn!("release cache: {e}"); + } + } + Err(e) => log::warn!("release cache: {e}"), + } + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 0000000..5d6983b --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "artcraft-launcher-ui-egui" +description = "ArtCraft Launcher's egui interface, shared by the desktop and web builds" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +artcraft-launcher-core = { workspace = true } +egui = { workspace = true } +egui_extras = { workspace = true, features = ["image"] } +image = { version = "0.25", default-features = false, features = ["png"] } +serde = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs new file mode 100644 index 0000000..a391ccc --- /dev/null +++ b/crates/ui-egui/src/app.rs @@ -0,0 +1,385 @@ +//! [`LauncherUi`]: state, event handling and the page layout. + +use std::time::Duration; + +use artcraft_launcher_core::protocol::{Backend, BackendOptions, Capabilities, Command, Event, LatestRelease, Source}; +use artcraft_launcher_core::release::Release; +use artcraft_launcher_core::status::AppStatus; +use artcraft_launcher_core::{Platform, SUITE}; + +use crate::model::AppEntry; +use crate::settings::{Filter, Settings, SortKey, ViewMode}; +use crate::theme::{self, Tokens}; + +/// What only the shell can do. Every method has a do-nothing default (the web build). +pub trait Host { + /// Shows a system notification. + fn notify(&mut self, _title: &str, _body: &str) {} + /// A tray (menu bar) icon exists, so closing the window can keep the launcher running. + fn has_tray(&self) -> bool { + false + } + /// `Some(enabled)` where starting at login is supported. + fn login_item(&self) -> Option { + None + } + fn set_login_item(&mut self, _on: bool) -> Result<(), String> { + Err("not supported here".into()) + } + /// Quit the launcher for real (not just hide to the tray). + fn quit(&mut self) {} +} + +pub(crate) struct Toast { + pub text: String, + pub error: bool, + pub until: f64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Dialog { + Notes(String), + ConfirmUninstall(String), + Settings, +} + +pub struct LauncherUi { + pub(crate) backend: Box, + pub settings: Settings, + pub(crate) entries: Vec, + pub(crate) caps: Capabilities, + pub(crate) platform: Option, + pub(crate) checking: bool, + pub(crate) last_source: Option, + pub(crate) rate_limited_until: Option, + pub(crate) launcher_update: Option, + pub(crate) search: String, + pub(crate) toasts: Vec, + pub(crate) dialog: Option, + started: bool, + applied_theme: Option, + /// Fonts registered with `set_fonts` only exist from the next frame on; drawing with the named + /// weights before then would abort. + fonts_ready: bool, + sent_options: Option, +} + +impl LauncherUi { + pub fn new(ctx: &egui::Context, backend: Box, settings: Settings) -> Self { + theme::install_fonts(ctx); + egui_extras::install_image_loaders(ctx); + let caps = backend.capabilities(); + let platform = backend.platform(); + Self { + backend, + settings, + entries: SUITE.iter().map(AppEntry::new).collect(), + caps, + platform, + checking: false, + last_source: None, + rate_limited_until: None, + launcher_update: None, + search: String::new(), + toasts: Vec::new(), + dialog: None, + started: false, + applied_theme: None, + fonts_ready: false, + sent_options: None, + } + } + + /// Installed apps with an update waiting (the tray shows the count). + pub fn update_count(&self) -> usize { + self.entries.iter().filter(|e| e.status(self.platform, self.caps.manage_apps) == AppStatus::UpdateAvailable).count() + } + + /// Looks for updates now (the tray's "Check for Updates"). + pub fn check_now(&mut self) { + self.backend.send(Command::CheckForUpdates { force: true }); + self.backend.send(Command::Detect); + } + + pub(crate) fn send(&mut self, command: Command) { + self.backend.send(command); + } + + pub(crate) fn toast(&mut self, ctx: &egui::Context, text: impl Into, error: bool) { + let now = ctx.input(|i| i.time); + self.toasts.push(Toast { text: text.into(), error, until: now + if error { 8.0 } else { 4.5 } }); + if self.toasts.len() > 4 { + self.toasts.remove(0); + } + } + + pub(crate) fn entry(&self, id: &str) -> Option<&AppEntry> { + self.entries.iter().find(|e| e.app.id == id) + } + + fn entry_mut(&mut self, id: &str) -> Option<&mut AppEntry> { + self.entries.iter_mut().find(|e| e.app.id == id) + } + + /// Non-drawing work: runs every frame and also while the window is hidden in the tray. + pub fn logic(&mut self, ctx: &egui::Context, host: &mut dyn Host) { + let options = BackendOptions { kinds: self.settings.preferred_kinds.clone(), prereleases: self.settings.prereleases }; + if self.sent_options.as_ref() != Some(&options) { + self.backend.send(Command::Configure(options.clone())); + self.sent_options = Some(options); + } + if !self.started { + self.started = true; + self.backend.send(Command::CheckForUpdates { force: false }); + } + let now = self.backend.now(); + if let Some(every) = self.settings.check_every.seconds() + && !self.checking + && self.settings.last_check.is_none_or(|t| now - t >= every) + { + self.backend.send(Command::CheckForUpdates { force: false }); + } + let mut changed = false; + for event in self.backend.poll() { + changed |= self.handle(ctx, event); + } + if changed { + self.announce_updates(host); + } + // Wake up now and then for scheduled checks, even in the tray. + ctx.request_repaint_after(Duration::from_secs(60)); + } + + /// Returns whether app state changed. + fn handle(&mut self, ctx: &egui::Context, event: Event) -> bool { + match event { + Event::Latest { app, latest } => { + if let Some(e) = self.entry_mut(&app) { + // A failed check keeps the release we already know about. + if !(matches!(latest, LatestRelease::Failed(_)) && matches!(e.latest, Some(LatestRelease::Found(_)))) { + e.latest = Some(latest); + } + } + true + } + Event::Detected { app, installed } => { + if let Some(e) = self.entry_mut(&app) { + e.installed = installed; + e.detected = true; + } + true + } + Event::Progress { app, phase } => { + if let Some(e) = self.entry_mut(&app) { + e.job = Some(phase); + } + false + } + Event::JobFinished { app, result } => { + if let Some(e) = self.entry_mut(&app) { + e.job = None; + } + match result { + Ok(msg) => self.toast(ctx, msg, false), + Err(e) if e == "cancelled" => self.toast(ctx, "Cancelled", false), + Err(e) => self.toast(ctx, e, true), + } + true + } + Event::LauncherUpdate(r) => { + self.launcher_update = Some(r); + false + } + Event::CheckStarted => { + self.checking = true; + false + } + Event::CheckFinished { at, source, rate_limited_until } => { + self.checking = false; + self.settings.last_check = Some(at); + self.last_source = Some(source); + self.rate_limited_until = rate_limited_until; + true + } + } + } + + /// One notification per new version of an installed app. + fn announce_updates(&mut self, host: &mut dyn Host) { + let mut fresh = Vec::new(); + for e in &self.entries { + if e.status(self.platform, self.caps.manage_apps) != AppStatus::UpdateAvailable { + continue; + } + let Some(v) = e.latest_label() else { continue }; + if self.settings.notified.get(e.app.id) != Some(&v) { + fresh.push((e.app.id, e.app.name, v)); + } + } + if fresh.is_empty() { + return; + } + if self.settings.notify { + let (title, body) = match fresh.as_slice() { + [(_, name, v)] => (format!("{name} {v} is available"), "Open ArtCraft Launcher to update.".to_string()), + many => { + (format!("{} updates are available", many.len()), many.iter().map(|(_, n, v)| format!("{n} {v}")).collect::>().join(", ")) + } + }; + host.notify(&title, &body); + } + for (id, _, version) in fresh { + self.settings.notified.insert(id.to_string(), version); + } + } + + /// Draws the launcher. + pub fn ui(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) { + let ctx = ui.ctx().clone(); + if !self.fonts_ready { + self.fonts_ready = true; + ctx.request_repaint(); + return; + } + if self.applied_theme != Some(self.settings.theme) { + theme::apply(&ctx, self.settings.theme); + self.applied_theme = Some(self.settings.theme); + } + let t = Tokens::get(&ctx); + if self.entries.iter().any(|e| e.job.is_some()) || self.checking { + ctx.request_repaint_after(Duration::from_millis(50)); + } + + egui::Panel::top("header") + .frame( + egui::Frame::new().fill(t.surface).inner_margin(egui::Margin { left: 20, right: 20, top: 14, bottom: 12 }).stroke(egui::Stroke::NONE), + ) + .show(ui, |ui| self.header(ui)); + egui::Panel::bottom("footer") + .frame(egui::Frame::new().fill(t.surface).inner_margin(egui::Margin::symmetric(20, 7))) + .show(ui, |ui| self.footer(ui)); + egui::CentralPanel::default() + .frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 14, bottom: 0 })) + .show(ui, |ui| { + self.toolbar(ui); + ui.add_space(6.0); + let ids = self.visible_ids(); + egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { + if ids.is_empty() { + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new("No apps match").size(16.0).color(t.text_muted)); + }); + return; + } + match self.settings.view { + ViewMode::Grid => self.grid(ui, &ids), + ViewMode::Table => self.table(ui, &ids), + } + ui.add_space(20.0); + }); + }); + self.dialogs(&ctx, host); + self.draw_toasts(&ctx); + } + + /// The ids to show, filtered and sorted. + pub(crate) fn visible_ids(&self) -> Vec<&'static str> { + let needle = self.search.trim().to_lowercase(); + let mut list: Vec<(usize, &AppEntry)> = self + .entries + .iter() + .enumerate() + .filter(|(_, e)| { + let s = e.status(self.platform, self.caps.manage_apps); + let keep = match self.settings.filter { + Filter::All => true, + Filter::Installed => s.is_installed(), + Filter::Updates => s == AppStatus::UpdateAvailable, + Filter::NotInstalled => !s.is_installed(), + }; + keep && (needle.is_empty() + || e.app.name.to_lowercase().contains(&needle) + || e.app.tagline.to_lowercase().contains(&needle) + || e.app.like.to_lowercase().contains(&needle)) + }) + .collect(); + let platform = self.platform; + let manage = self.caps.manage_apps; + list.sort_by(|(ia, a), (ib, b)| { + let ord = match self.settings.sort { + SortKey::Suite => ia.cmp(ib), + SortKey::Name => a.app.name.cmp(b.app.name), + SortKey::Status => crate::model::status_rank(&a.status(platform, manage)) + .cmp(&crate::model::status_rank(&b.status(platform, manage))) + .then(ia.cmp(ib)), + SortKey::Released => { + let at = |e: &AppEntry| e.release().and_then(|r| r.published_at).unwrap_or(0); + at(b).cmp(&at(a)).then(ia.cmp(ib)) + } + }; + if self.settings.sort_descending { ord.reverse() } else { ord } + }); + list.into_iter().map(|(_, e)| e.app.id).collect() + } + + pub(crate) fn count(&self, filter: Filter) -> usize { + self.entries + .iter() + .filter(|e| { + let s = e.status(self.platform, self.caps.manage_apps); + match filter { + Filter::All => true, + Filter::Installed => s.is_installed(), + Filter::Updates => s == AppStatus::UpdateAvailable, + Filter::NotInstalled => !s.is_installed(), + } + }) + .count() + } + + fn draw_toasts(&mut self, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + self.toasts.retain(|t| t.until > now); + if self.toasts.is_empty() { + return; + } + ctx.request_repaint_after(Duration::from_millis(250)); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::new("toasts")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-20.0, -44.0)).order(egui::Order::Foreground).show( + ctx, + |ui| { + ui.set_max_width(380.0); + let mut dismiss = None; + for (i, toast) in self.toasts.iter().enumerate() { + let accent = if toast.error { t.bad } else { t.good }; + let r = egui::Frame::new() + .fill(t.surface) + .stroke(egui::Stroke::new(1.0, t.border_strong)) + .corner_radius(theme::RADIUS) + .shadow(egui::epaint::Shadow { + offset: [0, 6], + blur: 24, + spread: 0, + color: egui::Color32::from_black_alpha(if t.dark { 120 } else { 40 }), + }) + .inner_margin(egui::Margin::symmetric(14, 10)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, accent); + ui.add(egui::Label::new(egui::RichText::new(&toast.text).color(t.text)).wrap()); + }); + }); + if r.response.interact(egui::Sense::click()).clicked() { + dismiss = Some(i); + } + ui.add_space(6.0); + } + if let Some(i) = dismiss { + self.toasts.remove(i); + } + }, + ); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs new file mode 100644 index 0000000..525d4ea --- /dev/null +++ b/crates/ui-egui/src/dialogs.rs @@ -0,0 +1,181 @@ +//! Modal dialogs: release notes, uninstall confirmation, settings. + +use artcraft_launcher_core::protocol::Command; +use artcraft_launcher_core::{Os, PackageKind, time}; +use egui::{RichText, vec2}; + +use crate::app::{Dialog, Host, LauncherUi}; +use crate::settings::CheckEvery; +use crate::theme::{self, ThemeChoice, Tokens}; +use crate::widgets; + +impl LauncherUi { + pub(crate) fn dialogs(&mut self, ctx: &egui::Context, host: &mut dyn Host) { + let Some(dialog) = self.dialog.clone() else { return }; + let t = Tokens::get(ctx); + let modal = egui::Modal::new(egui::Id::new("dialog")).frame( + egui::Frame::new().fill(t.surface).stroke(egui::Stroke::new(1.0, t.border_strong)).corner_radius(theme::RADIUS + 4).inner_margin(22), + ); + let mut close = false; + let response = match &dialog { + Dialog::Notes(id) => modal.show(ctx, |ui| close = self.notes_dialog(ui, id)), + Dialog::ConfirmUninstall(id) => modal.show(ctx, |ui| close = self.uninstall_dialog(ui, id)), + Dialog::Settings => modal.show(ctx, |ui| close = self.settings_dialog(ui, host)), + }; + if close || response.should_close() { + self.dialog = None; + } + } + + fn title(ui: &mut egui::Ui, icon: Option>, title: &str, subtitle: &str) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + if let Some(icon) = icon { + ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(40.0, 40.0))); + } + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.label(RichText::new(title).size(18.0).family(theme::semibold()).color(t.text)); + if !subtitle.is_empty() { + ui.label(RichText::new(subtitle).size(12.5).color(t.text_muted)); + } + }); + }); + ui.add_space(8.0); + } + + fn notes_dialog(&mut self, ui: &mut egui::Ui, id: &str) -> bool { + let Some(entry) = self.entry(id).cloned() else { return true }; + let Some(release) = entry.release().cloned() else { return true }; + ui.set_width(560.0f32.min(ui.ctx().content_rect().width() - 80.0)); + let date = release.published_at.map(|d| format!("Released {}", time::format_date(d))).unwrap_or_default(); + Self::title(ui, crate::icons::app(id), &format!("{} {}", entry.app.name, release.version_label()), &date); + egui::ScrollArea::vertical().max_height(ui.ctx().content_rect().height() * 0.6).show(ui, |ui| { + if release.notes.is_empty() { + ui.label("These notes weren't fetched (GitHub's request limit was reached). They're on the release page."); + } else { + crate::notes::show(ui, &release.notes); + } + }); + ui.add_space(12.0); + let mut close = false; + ui.horizontal(|ui| { + if ui.link("Open on GitHub").clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(release.html_url.clone())); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + close = widgets::secondary(ui, "Close", 90.0).clicked(); + }); + }); + close + } + + fn uninstall_dialog(&mut self, ui: &mut egui::Ui, id: &str) -> bool { + let Some(entry) = self.entry(id).cloned() else { return true }; + let t = Tokens::get(ui.ctx()); + ui.set_width(420.0); + Self::title(ui, crate::icons::app(id), &format!("Uninstall {}?", entry.app.name), ""); + let how = match entry.installed.as_ref().map(|i| i.source) { + Some(artcraft_launcher_core::InstallSource::MacApp) => "The app will be moved to the Trash. Your documents and settings stay.", + Some(artcraft_launcher_core::InstallSource::Msi) => { + "Windows will remove it like any installed program. Your documents and settings stay." + } + Some(artcraft_launcher_core::InstallSource::Deb | artcraft_launcher_core::InstallSource::Rpm) => { + "Your system's package manager will remove it, and may ask for your password. Your documents and settings stay." + } + _ => "The app will be removed. Your documents and settings stay.", + }; + ui.label(RichText::new(how).color(t.text_muted)); + ui.add_space(16.0); + let mut close = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::primary(ui, "Uninstall", t.bad, 110.0).clicked() { + self.send(Command::Uninstall { app: id.to_string() }); + close = true; + } + if widgets::secondary(ui, "Cancel", 90.0).clicked() { + close = true; + } + }); + close + } + + fn settings_dialog(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) -> bool { + let t = Tokens::get(ui.ctx()); + ui.set_width(460.0); + Self::title(ui, Some(crate::icons::launcher()), "Settings", &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION"))); + let section = |ui: &mut egui::Ui, s: &str| { + ui.add_space(10.0); + ui.label(RichText::new(s.to_uppercase()).size(11.0).family(theme::semibold()).color(t.text_faint)); + ui.add_space(2.0); + }; + let row = |ui: &mut egui::Ui, label: &str, add: &mut dyn FnMut(&mut egui::Ui)| { + ui.horizontal(|ui| { + ui.label(label); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui)); + }); + }; + + section(ui, "Updates"); + let mut every = self.settings.check_every; + row(ui, "Check for updates", &mut |ui| { + egui::ComboBox::from_id_salt("every").selected_text(every.label()).width(150.0).show_ui(ui, |ui| { + for e in CheckEvery::ALL { + ui.selectable_value(&mut every, e, e.label()); + } + }); + }); + self.settings.check_every = every; + ui.checkbox(&mut self.settings.notify, "Notify me when an installed app has an update"); + ui.checkbox(&mut self.settings.prereleases, "Include pre-releases (betas and release candidates)"); + + if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Linux) { + section(ui, "Installing on Linux"); + let mut choice = self.settings.preferred_kinds.first().copied(); + row(ui, "Install new apps as", &mut |ui| { + let label = choice.map_or("System package (recommended)", PackageKind::label); + egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| { + ui.selectable_value(&mut choice, None, "System package (recommended)"); + for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::AppImage] { + ui.selectable_value(&mut choice, Some(k), k.label()); + } + }); + }); + self.settings.preferred_kinds = choice.into_iter().collect(); + ui.label(RichText::new("Updates always use the format an app was installed with.").size(12.0).color(t.text_muted)); + } + + if self.caps.manage_apps { + section(ui, "Running in the background"); + if host.has_tray() { + ui.checkbox(&mut self.settings.close_to_tray, "Keep running in the tray when the window is closed"); + } + if let Some(mut on) = host.login_item() + && ui.checkbox(&mut on, "Start ArtCraft Launcher when I log in").changed() + && let Err(e) = host.set_login_item(on) + { + let ctx = ui.ctx().clone(); + self.toast(&ctx, format!("Couldn't change the login item: {e}"), true); + } + } + + section(ui, "Appearance"); + let mut theme_choice = self.settings.theme; + ui.horizontal(|ui| { + widgets::segmented(ui, &mut theme_choice, &ThemeChoice::ALL.map(|c| (c, c.label(), c.label()))); + }); + self.settings.theme = theme_choice; + + ui.add_space(18.0); + let mut close = false; + ui.horizontal(|ui| { + if self.caps.manage_apps && widgets::secondary(ui, "Quit ArtCraft Launcher", 0.0).clicked() { + host.quit(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + close = widgets::primary(ui, "Done", t.accent, 90.0).clicked(); + }); + }); + close + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 0000000..3613a00 --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -0,0 +1,27 @@ +//! Bundled images: each Craft App's icon and the launcher's own. + +use egui::ImageSource; + +/// The 128 px icon of a Craft App. +pub fn app(id: &str) -> Option> { + Some(match id { + "photocraft" => egui::include_image!("../../../assets/app-icons/photocraft.png"), + "vectorcraft" => egui::include_image!("../../../assets/app-icons/vectorcraft.png"), + "filmcraft" => egui::include_image!("../../../assets/app-icons/filmcraft.png"), + "lightcraft" => egui::include_image!("../../../assets/app-icons/lightcraft.png"), + "pdfcraft" => egui::include_image!("../../../assets/app-icons/pdfcraft.png"), + "effectcraft" => egui::include_image!("../../../assets/app-icons/effectcraft.png"), + "designcraft" => egui::include_image!("../../../assets/app-icons/designcraft.png"), + "wordcraft" => egui::include_image!("../../../assets/app-icons/wordcraft.png"), + "deckcraft" => egui::include_image!("../../../assets/app-icons/deckcraft.png"), + "gridcraft" => egui::include_image!("../../../assets/app-icons/gridcraft.png"), + "cadcraft" => egui::include_image!("../../../assets/app-icons/cadcraft.png"), + "soundcraft" => egui::include_image!("../../../assets/app-icons/soundcraft.png"), + _ => return None, + }) +} + +/// The launcher's icon. +pub fn launcher() -> ImageSource<'static> { + egui::include_image!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.artcraft-launcher.png") +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 0000000..13df6c4 --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -0,0 +1,22 @@ +//! ArtCraft Launcher's interface: the Craft App Suite as a grid of cards or a table, with +//! install, update, open and remove, shared by the desktop and web builds. +//! +//! The UI is thin: it draws [`model::AppEntry`]s and sends +//! [`Command`](artcraft_launcher_core::protocol::Command)s to a +//! [`Backend`](artcraft_launcher_core::protocol::Backend). The shell supplies a [`Host`] for the +//! things only it can do (notifications, the tray, quitting). + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod app; +mod dialogs; +pub mod icons; +pub mod model; +mod notes; +pub mod settings; +pub mod theme; +mod views; +mod widgets; + +pub use app::{Host, LauncherUi}; +pub use settings::Settings; diff --git a/crates/ui-egui/src/model.rs b/crates/ui-egui/src/model.rs new file mode 100644 index 0000000..c7d4863 --- /dev/null +++ b/crates/ui-egui/src/model.rs @@ -0,0 +1,101 @@ +//! What the UI knows about each app. + +use artcraft_launcher_core::platform::{Platform, pick_asset}; +use artcraft_launcher_core::protocol::{JobPhase, LatestRelease}; +use artcraft_launcher_core::release::Release; +use artcraft_launcher_core::status::{self, AppStatus, Latest}; +use artcraft_launcher_core::{CraftApp, Installed}; + +#[derive(Clone, Debug)] +pub struct AppEntry { + pub app: &'static CraftApp, + pub latest: Option, + pub installed: Option, + /// Detection has answered at least once. + pub detected: bool, + pub job: Option, +} + +impl AppEntry { + pub fn new(app: &'static CraftApp) -> Self { + Self { app, latest: None, installed: None, detected: false, job: None } + } + + pub fn release(&self) -> Option<&Release> { + match &self.latest { + Some(LatestRelease::Found(r)) => Some(r), + _ => None, + } + } + + /// Whether the latest release has something for `platform`. + pub fn has_download(&self, platform: Option) -> bool { + let (Some(r), Some(p)) = (self.release(), platform) else { return false }; + let stems: Vec<&str> = self.app.stems().collect(); + pick_asset(r, &stems, p, p.download_kinds()).is_some() + } + + pub fn status(&self, platform: Option, manage_apps: bool) -> AppStatus { + if manage_apps && !self.detected { + return AppStatus::Checking; + } + let latest = match &self.latest { + None => Latest::Pending, + Some(LatestRelease::Failed(_)) => Latest::Failed, + Some(LatestRelease::None) => Latest::NoRelease, + Some(LatestRelease::Found(r)) => Latest::Found { version: r.version.as_ref(), has_asset: self.has_download(platform) }, + }; + status::status(self.installed.as_ref(), latest) + } + + pub fn installed_label(&self) -> Option { + self.installed.as_ref().map(|i| i.version.as_ref().map_or_else(|| "installed".to_string(), ToString::to_string)) + } + + pub fn latest_label(&self) -> Option { + self.release().map(Release::version_label) + } + + pub fn failure(&self) -> Option<&str> { + match &self.latest { + Some(LatestRelease::Failed(why)) => Some(why), + _ => None, + } + } +} + +/// The colour role of a status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tone { + Good, + Warn, + Info, + Neutral, + Faint, +} + +pub fn status_text(status: &AppStatus, manage_apps: bool) -> (&'static str, Tone) { + match status { + AppStatus::Checking => ("Checking…", Tone::Faint), + AppStatus::Available if manage_apps => ("Available to install", Tone::Info), + AppStatus::Available => ("Available", Tone::Info), + AppStatus::UpToDate => ("Installed · up to date", Tone::Good), + AppStatus::UpdateAvailable => ("Update available", Tone::Warn), + AppStatus::Installed => ("Installed", Tone::Neutral), + AppStatus::Unavailable => ("Not available here", Tone::Faint), + AppStatus::NoRelease => ("Coming soon", Tone::Faint), + } +} + +/// Sort rank for "by status": things needing attention first. +pub fn status_rank(status: &AppStatus) -> u8 { + match status { + AppStatus::UpdateAvailable => 0, + AppStatus::Installed => 1, + AppStatus::UpToDate => 2, + AppStatus::Available => 3, + AppStatus::Checking => 4, + AppStatus::Unavailable => 5, + AppStatus::NoRelease => 6, + } +} diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs new file mode 100644 index 0000000..e7fbff6 --- /dev/null +++ b/crates/ui-egui/src/notes.rs @@ -0,0 +1,90 @@ +//! Release notes: GitHub's generated Markdown, shown readably without a Markdown engine. + +use egui::{RichText, Ui}; + +use crate::theme::{self, Tokens}; + +/// One line of notes, classified. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Line<'a> { + Heading(&'a str), + /// A bullet: its text, and the pull request link GitHub appends (`… in https://…/pull/12`). + Bullet(&'a str, Option<&'a str>), + Text(&'a str), + Blank, +} + +pub(crate) fn classify(line: &str) -> Line<'_> { + let l = line.trim(); + if l.is_empty() { + return Line::Blank; + } + if let Some(h) = l.strip_prefix('#') { + return Line::Heading(h.trim_start_matches('#').trim()); + } + if let Some(b) = l.strip_prefix("* ").or_else(|| l.strip_prefix("- ")) { + if let Some((text, url)) = b.rsplit_once(" in https://") + && !url.contains(' ') + { + let start = b.len() - url.len() - "https://".len(); + return Line::Bullet(text, b.get(start..)); + } + return Line::Bullet(b, None); + } + Line::Text(l) +} + +pub(crate) fn show(ui: &mut Ui, notes: &str) { + let t = Tokens::get(ui.ctx()); + if notes.trim().is_empty() { + ui.label(RichText::new("No notes were published with this release.").color(t.text_muted)); + return; + } + for line in notes.lines() { + match classify(line) { + Line::Heading(h) => { + ui.add_space(6.0); + ui.label(RichText::new(h).size(15.0).family(theme::semibold()).color(t.text)); + } + Line::Bullet(text, link) => { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.label(RichText::new("•").color(t.text_faint)); + ui.label(RichText::new(text).color(t.text)); + if let Some(url) = link { + let short = url.rsplit('/').next().map_or_else(|| "link".to_string(), |n| format!("#{n}")); + ui.hyperlink_to(RichText::new(short).size(12.0), url); + } + }); + } + Line::Text(text) => { + if let Some(url) = text.strip_prefix("**Full Changelog**: ") { + ui.add_space(6.0); + ui.hyperlink_to("Full changelog", url.trim()); + } else { + ui.label(RichText::new(text).color(t.text_muted)); + } + } + Line::Blank => { + ui.add_space(2.0); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_githubs_generated_notes() { + assert_eq!(classify("## What's Changed"), Line::Heading("What's Changed")); + assert_eq!( + classify("* Add the Patch tool (#213) by @a in https://github.com/storytold/photocraft/pull/381"), + Line::Bullet("Add the Patch tool (#213) by @a", Some("https://github.com/storytold/photocraft/pull/381")) + ); + assert_eq!(classify("- plain bullet"), Line::Bullet("plain bullet", None)); + assert_eq!(classify(" "), Line::Blank); + assert_eq!(classify("hello"), Line::Text("hello")); + } +} diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs new file mode 100644 index 0000000..b5e40c0 --- /dev/null +++ b/crates/ui-egui/src/settings.rs @@ -0,0 +1,106 @@ +//! The user's choices, saved between runs by the shell. + +use std::collections::BTreeMap; + +use artcraft_launcher_core::PackageKind; +use serde::{Deserialize, Serialize}; + +use crate::theme::ThemeChoice; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ViewMode { + #[default] + Grid, + Table, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Filter { + #[default] + All, + Installed, + Updates, + NotInstalled, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SortKey { + /// Suite order: classic apps, then new. + #[default] + Suite, + Name, + Status, + Released, +} + +/// How often to look for updates in the background. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CheckEvery { + Hour, + FourHours, + Day, + Never, +} + +impl CheckEvery { + pub const ALL: [Self; 4] = [Self::Hour, Self::FourHours, Self::Day, Self::Never]; + + pub fn label(self) -> &'static str { + match self { + Self::Hour => "Every hour", + Self::FourHours => "Every 4 hours", + Self::Day => "Once a day", + Self::Never => "Only when I ask", + } + } + + pub fn seconds(self) -> Option { + match self { + Self::Hour => Some(3600), + Self::FourHours => Some(4 * 3600), + Self::Day => Some(24 * 3600), + Self::Never => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + pub view: ViewMode, + pub filter: Filter, + pub sort: SortKey, + pub sort_descending: bool, + pub theme: ThemeChoice, + /// Closing the window keeps the launcher in the tray (menu bar) instead of quitting. + pub close_to_tray: bool, + pub check_every: CheckEvery, + /// Show a system notification when an installed app has an update. + pub notify: bool, + pub prereleases: bool, + /// Linux: the package format to prefer for new installs (empty: the system's own). + pub preferred_kinds: Vec, + /// Updates already announced, so each version is announced once: app id → version. + pub notified: BTreeMap, + /// Unix seconds of the last finished check. + pub last_check: Option, +} + +impl Default for Settings { + fn default() -> Self { + Self { + view: ViewMode::Grid, + filter: Filter::All, + sort: SortKey::Suite, + sort_descending: false, + theme: ThemeChoice::System, + close_to_tray: true, + check_every: CheckEvery::Hour, + notify: true, + prereleases: false, + preferred_kinds: Vec::new(), + notified: BTreeMap::new(), + last_check: None, + } + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 0000000..bdc72ec --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -0,0 +1,189 @@ +//! Colours, type and shapes. Widgets read [`Tokens::get`]; nothing hard-codes a colour. + +use std::sync::Arc; + +use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ThemeChoice { + /// Follow the system's light or dark setting. + #[default] + System, + Dark, + Light, +} + +impl ThemeChoice { + pub const ALL: [Self; 3] = [Self::System, Self::Dark, Self::Light]; + + pub fn label(self) -> &'static str { + match self { + Self::System => "Match system", + Self::Dark => "Dark", + Self::Light => "Light", + } + } +} + +/// Colour tokens for one appearance. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tokens { + pub dark: bool, + pub bg: Color32, + pub surface: Color32, + pub card: Color32, + pub card_hover: Color32, + pub border: Color32, + pub border_strong: Color32, + pub text: Color32, + pub text_muted: Color32, + pub text_faint: Color32, + pub accent: Color32, + pub accent_text: Color32, + pub good: Color32, + pub warn: Color32, + pub bad: Color32, + pub info: Color32, +} + +impl Tokens { + pub const DARK: Self = Self { + dark: true, + bg: Color32::from_rgb(0x0e, 0x10, 0x14), + surface: Color32::from_rgb(0x14, 0x17, 0x1d), + card: Color32::from_rgb(0x19, 0x1c, 0x24), + card_hover: Color32::from_rgb(0x1f, 0x23, 0x2d), + border: Color32::from_rgb(0x26, 0x2a, 0x35), + border_strong: Color32::from_rgb(0x36, 0x3c, 0x4a), + text: Color32::from_rgb(0xec, 0xee, 0xf2), + text_muted: Color32::from_rgb(0x9a, 0xa1, 0xb0), + text_faint: Color32::from_rgb(0x67, 0x6e, 0x7d), + accent: Color32::from_rgb(0x7c, 0x6c, 0xff), + accent_text: Color32::WHITE, + good: Color32::from_rgb(0x3d, 0xd6, 0x8c), + warn: Color32::from_rgb(0xff, 0xb5, 0x47), + bad: Color32::from_rgb(0xff, 0x6b, 0x6b), + info: Color32::from_rgb(0x6c, 0xb4, 0xff), + }; + + pub const LIGHT: Self = Self { + dark: false, + bg: Color32::from_rgb(0xf4, 0xf5, 0xf8), + surface: Color32::from_rgb(0xff, 0xff, 0xff), + card: Color32::from_rgb(0xff, 0xff, 0xff), + card_hover: Color32::from_rgb(0xfa, 0xfa, 0xfd), + border: Color32::from_rgb(0xe3, 0xe5, 0xeb), + border_strong: Color32::from_rgb(0xc9, 0xcd, 0xd6), + text: Color32::from_rgb(0x15, 0x17, 0x1c), + text_muted: Color32::from_rgb(0x5b, 0x62, 0x70), + text_faint: Color32::from_rgb(0x8b, 0x91, 0x9d), + accent: Color32::from_rgb(0x5b, 0x4b, 0xf0), + accent_text: Color32::WHITE, + good: Color32::from_rgb(0x12, 0x8a, 0x52), + warn: Color32::from_rgb(0xb8, 0x6e, 0x00), + bad: Color32::from_rgb(0xc8, 0x2b, 0x2b), + info: Color32::from_rgb(0x1f, 0x6f, 0xd6), + }; + + pub fn get(ctx: &egui::Context) -> Self { + if ctx.theme() == egui::Theme::Dark { Self::DARK } else { Self::LIGHT } + } +} + +pub const RADIUS: u8 = 12; +pub const RADIUS_SMALL: u8 = 8; + +/// Mixes `c` into the background at `alpha` (0–1): tinted chips and glows. +pub fn tint(c: Color32, alpha: f32) -> Color32 { + let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a) +} + +pub fn rgb(c: [u8; 3]) -> Color32 { + Color32::from_rgb(c[0], c[1], c[2]) +} + +fn visuals(t: &Tokens) -> Visuals { + let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = t.bg; + v.window_fill = t.surface; + v.extreme_bg_color = if t.dark { Color32::from_rgb(0x0b, 0x0d, 0x11) } else { Color32::from_rgb(0xee, 0xf0, 0xf4) }; + v.faint_bg_color = t.card; + v.window_stroke = Stroke::new(1.0, t.border); + v.window_corner_radius = CornerRadius::same(RADIUS + 2); + v.menu_corner_radius = CornerRadius::same(RADIUS_SMALL + 2); + v.override_text_color = Some(t.text); + v.hyperlink_color = t.accent; + v.selection.bg_fill = tint(t.accent, 0.35); + v.selection.stroke = Stroke::new(1.0, t.accent); + let r = CornerRadius::same(RADIUS_SMALL); + for (w, fill, stroke) in [ + (&mut v.widgets.noninteractive, t.card, t.border), + (&mut v.widgets.inactive, if t.dark { Color32::from_rgb(0x23, 0x27, 0x31) } else { Color32::from_rgb(0xee, 0xf0, 0xf4) }, t.border), + (&mut v.widgets.hovered, if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) }, t.border_strong), + (&mut v.widgets.active, if t.dark { Color32::from_rgb(0x33, 0x39, 0x47) } else { Color32::from_rgb(0xda, 0xde, 0xe7) }, t.border_strong), + (&mut v.widgets.open, if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) }, t.border_strong), + ] { + w.bg_fill = fill; + w.weak_bg_fill = fill; + w.bg_stroke = Stroke::new(1.0, stroke); + w.corner_radius = r; + w.fg_stroke = Stroke::new(1.0, t.text); + } + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text_muted); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border); + v +} + +/// Applies both appearances (egui switches between them with the theme preference). +pub fn apply(ctx: &egui::Context, choice: ThemeChoice) { + ctx.set_visuals_of(egui::Theme::Dark, visuals(&Tokens::DARK)); + ctx.set_visuals_of(egui::Theme::Light, visuals(&Tokens::LIGHT)); + ctx.set_theme(match choice { + ThemeChoice::System => egui::ThemePreference::System, + ThemeChoice::Dark => egui::ThemePreference::Dark, + ThemeChoice::Light => egui::ThemePreference::Light, + }); + ctx.all_styles_mut(|style| { + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + style.spacing.button_padding = egui::vec2(12.0, 6.0); + style.spacing.interact_size.y = 30.0; + style.spacing.menu_margin = egui::Margin::same(6); + style.text_styles = [ + (TextStyle::Heading, FontId::new(22.0, semibold())), + (TextStyle::Body, FontId::new(14.0, FontFamily::Proportional)), + (TextStyle::Button, FontId::new(14.0, medium())), + (TextStyle::Small, FontId::new(12.0, FontFamily::Proportional)), + (TextStyle::Monospace, FontId::new(13.0, FontFamily::Monospace)), + ] + .into(); + }); +} + +pub fn medium() -> FontFamily { + FontFamily::Name("medium".into()) +} + +pub fn semibold() -> FontFamily { + FontFamily::Name("semibold".into()) +} + +/// Registers Inter with named weights. +pub fn install_fonts(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { + fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + }; + add(&mut fonts, "Inter", include_bytes!("../../../assets/fonts/Inter-Regular.ttf")); + add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf")); + add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")); + fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned()); + let fallback: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + for (family, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] { + let mut stack = vec![primary.to_owned()]; + stack.extend(fallback.iter().cloned()); + fonts.families.insert(FontFamily::Name(family.into()), stack); + } + ctx.set_fonts(fonts); +} diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs new file mode 100644 index 0000000..6c07930 --- /dev/null +++ b/crates/ui-egui/src/views.rs @@ -0,0 +1,586 @@ +//! The header, toolbar, footer, the grid of cards and the table. + +use artcraft_launcher_core::platform::pick_asset; +use artcraft_launcher_core::protocol::{Command, JobPhase}; +use artcraft_launcher_core::status::AppStatus; +use artcraft_launcher_core::{AppGroup, time}; +use egui::{Color32, CornerRadius, RichText, Sense, Stroke, Ui, vec2}; + +use crate::app::{Dialog, LauncherUi}; +use crate::model::{self, AppEntry}; +use crate::settings::{Filter, SortKey, ViewMode}; +use crate::theme::{self, Tokens, rgb, tint}; +use crate::widgets; + +/// What the main button of an app does. +enum Primary { + Install, + Update(String), + Open, + Download(String), + Unavailable(&'static str), +} + +const CARD_MIN_W: f32 = 300.0; +const CARD_H: f32 = 210.0; +const GAP: f32 = 14.0; + +impl LauncherUi { + pub(crate) fn header(&mut self, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.add(egui::Image::new(crate::icons::launcher()).fit_to_exact_size(vec2(38.0, 38.0))); + ui.add_space(4.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.label(RichText::new("ArtCraft Launcher").size(19.0).family(theme::semibold()).color(t.text)); + ui.label(RichText::new(format!("Craft App Suite · {} apps", self.entries.len())).size(12.5).color(t.text_muted)); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::icon_button(ui, "⚙", "Settings").clicked() { + self.dialog = Some(Dialog::Settings); + } + let refresh = ui.add_enabled_ui(!self.checking, |ui| widgets::icon_button(ui, "⟳", "Check for updates")).inner; + if refresh.clicked() { + self.check_now(); + } + if self.checking { + ui.add(egui::Spinner::new().size(16.0).color(t.text_muted)); + } + ui.add_space(8.0); + widgets::segmented( + ui, + &mut self.settings.view, + &[(ViewMode::Grid, "⊞ Grid", "Show apps as cards"), (ViewMode::Table, "☰ Table", "Show apps as a table")], + ); + }); + }); + } + + pub(crate) fn toolbar(&mut self, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if let Some(r) = self.launcher_update.clone() { + egui::Frame::new() + .fill(tint(t.accent, if t.dark { 0.14 } else { 0.08 })) + .stroke(Stroke::new(1.0, tint(t.accent, 0.45))) + .corner_radius(theme::RADIUS) + .inner_margin(egui::Margin::symmetric(14, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("ArtCraft Launcher {} is available.", r.version_label())).family(theme::medium())); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::icon_button(ui, "×", "Dismiss").clicked() { + self.launcher_update = None; + } + let url = self.platform.and_then(|p| launcher_download(&r, p)).unwrap_or_else(|| r.html_url.clone()); + if widgets::primary(ui, "Download", t.accent, 0.0).clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + } + if ui.link("What's new").clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(r.html_url.clone())); + } + }); + }); + }); + ui.add_space(10.0); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let updates = self.count(Filter::Updates); + for (filter, label, highlight) in [ + (Filter::All, "All", None), + (Filter::Installed, "Installed", None), + (Filter::Updates, "Updates", Some(t.warn)), + (Filter::NotInstalled, "Not installed", None), + ] { + if !self.caps.manage_apps && filter != Filter::All { + continue; + } + let n = self.count(filter); + if widgets::chip(ui, self.settings.filter == filter, label, n, highlight).clicked() { + self.settings.filter = filter; + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if self.caps.manage_apps && updates > 0 { + let label = if updates == 1 { "Update 1 app".to_string() } else { format!("Update all ({updates})") }; + if widgets::primary(ui, &label, t.accent, 0.0).clicked() { + let ids: Vec<&str> = self + .entries + .iter() + .filter(|e| e.status(self.platform, true) == AppStatus::UpdateAvailable && e.job.is_none()) + .map(|e| e.app.id) + .collect(); + for id in ids { + self.send(Command::Install { app: id.to_string() }); + } + } + ui.add_space(4.0); + } + ui.add(egui::TextEdit::singleline(&mut self.search).hint_text("🔍 Search apps").desired_width(200.0).margin(vec2(10.0, 6.0))); + if self.settings.view == ViewMode::Grid { + egui::ComboBox::from_id_salt("sort") + .selected_text(RichText::new(sort_label(self.settings.sort)).size(13.0)) + .width(130.0) + .show_ui(ui, |ui| { + for key in [SortKey::Suite, SortKey::Name, SortKey::Status, SortKey::Released] { + ui.selectable_value(&mut self.settings.sort, key, sort_label(key)); + } + }); + } + }); + }); + } + + pub(crate) fn footer(&mut self, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let now = self.backend.now(); + ui.horizontal(|ui| { + let small = |s: String| RichText::new(s).size(12.0).color(t.text_muted); + if self.checking { + ui.label(small("Checking for updates…".into())); + } else if let Some(at) = self.settings.last_check { + let via = self.last_source.map(|s| format!(" · {}", s.label())).unwrap_or_default(); + ui.label(small(format!("Checked {}{via}", time::format_ago(at, now)))); + } else { + ui.label(small("Not checked yet".into())); + } + if let Some(until) = self.rate_limited_until.filter(|u| *u > now) { + ui.label( + RichText::new(format!("· GitHub's hourly limit was reached; full details return in {} min", ((until - now) / 60).max(1))) + .size(12.0) + .color(t.warn), + ); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let label = match self.platform { + Some(p) if self.caps.manage_apps => format!("Installing for {}", p.label()), + Some(p) => format!("Downloads for {}", p.label()), + None => "No downloads for this system".into(), + }; + ui.label(small(label)); + }); + }); + } + + // ---- grid ---------------------------------------------------------------------------- + + pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) { + let width = ui.available_width(); + let cols = (((width + GAP) / (CARD_MIN_W + GAP)).floor() as usize).max(1); + let card_w = ((width - GAP * (cols as f32 - 1.0)) / cols as f32).floor(); + // In suite order, split into the classic apps and the new ones. + let grouped = self.settings.sort == SortKey::Suite && self.settings.filter == crate::settings::Filter::All && self.search.trim().is_empty(); + let sections: Vec<(Option<&str>, Vec<&'static str>)> = if grouped { + [AppGroup::Classic, AppGroup::New] + .iter() + .map(|g| { + let list = ids.iter().copied().filter(|id| self.entry(id).is_some_and(|e| e.app.group == *g)).collect(); + (Some(if *g == AppGroup::Classic { "Classic apps" } else { "New apps" }), list) + }) + .collect() + } else { + vec![(None, ids.to_vec())] + }; + let t = Tokens::get(ui.ctx()); + for (title, list) in sections { + if list.is_empty() { + continue; + } + if let Some(title) = title { + ui.add_space(6.0); + ui.label(RichText::new(title.to_uppercase()).size(11.5).family(theme::semibold()).color(t.text_faint)); + ui.add_space(4.0); + } + for row in list.chunks(cols) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = GAP; + for id in row { + self.card(ui, id, card_w); + } + }); + ui.add_space(GAP); + } + } + } + + fn card(&mut self, ui: &mut Ui, id: &str, width: f32) { + let Some(entry) = self.entry(id).cloned() else { return }; + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(width, CARD_H), Sense::hover()); + let hovered = resp.hovered() || ui.rect_contains_pointer(rect); + let app_color = rgb(entry.app.color); + let painter = ui.painter_at(rect.expand(12.0)); + if hovered { + painter.add( + egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 110 } else { 28 }) } + .as_shape(rect, CornerRadius::same(theme::RADIUS)), + ); + } + painter.rect( + rect, + CornerRadius::same(theme::RADIUS), + if hovered { t.card_hover } else { t.card }, + Stroke::new(1.0, if hovered { t.border_strong } else { t.border }), + egui::StrokeKind::Inside, + ); + // A soft wash of the app's colour from the top edge. + gradient_top(&painter, rect.shrink(1.0), theme::RADIUS as f32 - 1.0, 74.0, app_color, if t.dark { 0.16 } else { 0.10 }); + + let status = entry.status(self.platform, self.caps.manage_apps); + let inner = rect.shrink2(vec2(16.0, 14.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)), |ui| { + ui.horizontal(|ui| { + if let Some(icon) = crate::icons::app(entry.app.id) { + ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(54.0, 54.0))); + } + ui.add_space(4.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.label(RichText::new(entry.app.name).size(17.0).family(theme::semibold()).color(t.text)); + if entry.app.group == AppGroup::New { + ui.label(RichText::new("NEW").size(9.5).family(theme::semibold()).color(app_color)); + } + }); + ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(12.5).color(t.text_muted)).truncate()); + ui.add(egui::Label::new(RichText::new(format!("Like {}", entry.app.like)).size(11.5).color(t.text_faint)).truncate()); + }); + }); + ui.add_space(10.0); + ui.horizontal(|ui| { + let (text, tone) = model::status_text(&status, self.caps.manage_apps); + let tip = entry.failure().map(|f| format!("Couldn't check for updates: {f}")); + let r = widgets::pill(ui, text, tone); + if let Some(tip) = tip { + r.on_hover_text(tip); + } + }); + ui.add_space(2.0); + ui.label(RichText::new(version_line(&entry, &status, self.backend.now())).size(12.0).color(t.text_muted)); + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width())); + }); + }); + } + + // ---- table --------------------------------------------------------------------------- + + pub(crate) fn table(&mut self, ui: &mut Ui, ids: &[&'static str]) { + use egui_extras::{Column, TableBuilder}; + let t = Tokens::get(ui.ctx()); + let now = self.backend.now(); + let mut sort_clicked = None; + egui::Frame::new() + .fill(t.card) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(theme::RADIUS) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| { + TableBuilder::new(ui) + .striped(false) + .vscroll(false) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::remainder().at_least(220.0).clip(true)) + .column(Column::exact(176.0)) + .column(Column::exact(84.0)) + .column(Column::exact(84.0)) + .column(Column::exact(104.0)) + .column(Column::exact(206.0)) + .header(34.0, |mut header| { + for (key, label) in [ + (Some(SortKey::Name), "App"), + (Some(SortKey::Status), "Status"), + (None, "Installed"), + (None, "Latest"), + (Some(SortKey::Released), "Released"), + (None, ""), + ] { + header.col(|ui| { + let active = key == Some(self.settings.sort); + let arrow = if active { if self.settings.sort_descending { " ↓" } else { " ↑" } } else { "" }; + let text = RichText::new(format!("{}{arrow}", label.to_uppercase())) + .size(11.0) + .family(theme::semibold()) + .color(if active { t.text } else { t.text_faint }); + match key { + Some(k) => { + if ui + .add(egui::Label::new(text).sense(Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + sort_clicked = Some(k); + } + } + None => { + ui.label(text); + } + } + }); + } + }) + .body(|mut body| { + for id in ids { + let Some(entry) = self.entry(id).cloned() else { continue }; + let status = entry.status(self.platform, self.caps.manage_apps); + body.row(58.0, |mut row| { + row.col(|ui| { + if let Some(icon) = crate::icons::app(entry.app.id) { + ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(36.0, 36.0))); + } + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + ui.label(RichText::new(entry.app.name).size(14.5).family(theme::semibold()).color(t.text)); + ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(12.0).color(t.text_muted)).truncate()); + }); + }); + row.col(|ui| { + let (text, tone) = model::status_text(&status, self.caps.manage_apps); + let r = widgets::pill(ui, text, tone); + if let Some(f) = entry.failure() { + r.on_hover_text(format!("Couldn't check for updates: {f}")); + } + }); + row.col(|ui| { + ui.label( + RichText::new(entry.installed_label().unwrap_or_else(|| "—".into())).color(if entry.installed.is_some() { + t.text + } else { + t.text_faint + }), + ); + }); + row.col(|ui| { + let c = if status == AppStatus::UpdateAvailable { t.warn } else { t.text }; + ui.label(RichText::new(entry.latest_label().unwrap_or_else(|| "—".into())).color(c)); + }); + row.col(|ui| { + let when = entry.release().and_then(|r| r.published_at); + let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), time::format_date)).color(t.text_muted)); + if let Some(w) = when { + r.on_hover_text(time::format_ago(w, now)); + } + }); + row.col(|ui| self.actions(ui, &entry, &status, 196.0)); + }); + } + }); + }); + if let Some(k) = sort_clicked { + if self.settings.sort == k { + self.settings.sort_descending = !self.settings.sort_descending; + } else { + self.settings.sort = k; + self.settings.sort_descending = false; + } + } + } + + // ---- actions ------------------------------------------------------------------------- + + fn primary_action(&self, entry: &AppEntry, status: &AppStatus) -> Primary { + if !self.caps.manage_apps { + let platform = self.platform; + let url = entry.release().zip(platform).and_then(|(r, p)| { + let stems: Vec<&str> = entry.app.stems().collect(); + pick_asset(r, &stems, p, p.download_kinds()).map(|(a, _)| a.url.clone()) + }); + return match (status, url) { + (AppStatus::NoRelease, _) => Primary::Unavailable("Coming soon"), + (AppStatus::Checking, _) => Primary::Unavailable("Checking…"), + (_, Some(url)) => Primary::Download(url), + (_, None) => Primary::Unavailable("Not available"), + }; + } + match status { + AppStatus::Available => Primary::Install, + AppStatus::UpdateAvailable => Primary::Update(entry.latest_label().unwrap_or_default()), + AppStatus::UpToDate | AppStatus::Installed => Primary::Open, + AppStatus::Checking => Primary::Unavailable("Checking…"), + AppStatus::NoRelease => Primary::Unavailable("Coming soon"), + AppStatus::Unavailable => Primary::Unavailable(if entry.failure().is_some() { "Offline" } else { "Not available" }), + } + } + + /// The main button (or progress) and the ⋯ menu. + fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) { + let t = Tokens::get(ui.ctx()); + let id = entry.app.id.to_string(); + let main_w = (width - 40.0).max(120.0); + if let Some(phase) = entry.job { + let (fraction, caption) = match phase { + JobPhase::Downloading { done, total } if total > 0 => { + let f = done as f32 / total as f32; + let caption = if main_w >= 200.0 { + format!("Downloading {} of {} · {:.0}%", widgets::human_bytes(done), widgets::human_bytes(total), f * 100.0) + } else { + format!("{} of {} · {:.0}%", widgets::human_bytes(done), widgets::human_bytes(total), f * 100.0) + }; + (Some(f), caption) + } + JobPhase::Downloading { done, .. } => (None, format!("Downloading {}", widgets::human_bytes(done))), + JobPhase::Verifying => (None, "Verifying checksum…".into()), + JobPhase::Installing => (None, "Installing…".into()), + JobPhase::Removing => (None, "Removing…".into()), + }; + widgets::progress(ui, fraction, &caption, t.accent, main_w); + if matches!(phase, JobPhase::Downloading { .. }) && widgets::icon_button(ui, "×", "Cancel").clicked() { + self.send(Command::Cancel { app: id }); + } + return; + } + match self.primary_action(entry, status) { + Primary::Install => { + if widgets::primary(ui, "Install", t.accent, main_w).clicked() { + self.send(Command::Install { app: id.clone() }); + } + } + Primary::Update(v) => { + if widgets::primary(ui, &format!("Update to {v}"), t.accent, main_w).clicked() { + self.send(Command::Install { app: id.clone() }); + } + } + Primary::Open => { + if widgets::secondary(ui, "Open", main_w).clicked() { + self.send(Command::Launch { app: id.clone() }); + } + } + Primary::Download(url) => { + let label = match self.platform { + Some(p) => format!("Download for {}", p.os.label()), + None => "Download".into(), + }; + if widgets::primary(ui, &label, t.accent, main_w).clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + } + } + Primary::Unavailable(label) => { + ui.add_enabled_ui(false, |ui| widgets::secondary(ui, label, main_w)); + } + } + self.menu(ui, entry, status); + } + + fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus) { + let id = entry.app.id.to_string(); + let manage = self.caps.manage_apps; + let reveal = self.caps.reveal_label; + ui.menu_button(RichText::new("…").size(16.0), |ui| { + ui.set_min_width(200.0); + if entry.release().is_some() && ui.button("Release notes").clicked() { + self.dialog = Some(Dialog::Notes(id.clone())); + ui.close(); + } + if manage && status.is_installed() { + if ui.button("Open").clicked() { + self.send(Command::Launch { app: id.clone() }); + ui.close(); + } + if ui.button(reveal).clicked() { + self.send(Command::Reveal { app: id.clone() }); + ui.close(); + } + if *status == AppStatus::UpToDate && ui.button("Reinstall").clicked() { + self.send(Command::Install { app: id.clone() }); + ui.close(); + } + ui.separator(); + if ui.button(RichText::new("Uninstall…").color(Tokens::get(ui.ctx()).bad)).clicked() { + self.dialog = Some(Dialog::ConfirmUninstall(id.clone())); + ui.close(); + } + } + if !manage && let (Some(r), Some(p)) = (entry.release(), self.platform) { + let stems: Vec<&str> = entry.app.stems().collect(); + let options: Vec<(String, String)> = p + .download_kinds() + .iter() + .filter_map(|k| { + pick_asset(r, &stems, p, &[*k]).map(|(a, _)| (format!("{} · {}", k.label(), widgets::human_bytes(a.size)), a.url.clone())) + }) + .collect(); + if !options.is_empty() { + ui.separator(); + for (label, url) in options { + if ui.button(label).clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + ui.close(); + } + } + } + } + ui.separator(); + let link = |ui: &mut Ui, label: &str, url: String| { + if ui.button(label).clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + ui.close(); + } + }; + if let Some(site) = entry.app.website() { + link(ui, "Website", site); + } + link(ui, "All releases", entry.app.releases_url()); + link(ui, "Source code", entry.app.repo_url()); + }); + } +} + +fn sort_label(k: SortKey) -> &'static str { + match k { + SortKey::Suite => "Suite order", + SortKey::Name => "Name", + SortKey::Status => "Status", + SortKey::Released => "Newest release", + } +} + +/// "Installed 0.4.0 · 0.5.0 out Oct 8, 2026" and friends. +fn version_line(e: &AppEntry, status: &AppStatus, now: i64) -> String { + let released = e.release().and_then(|r| r.published_at).map(|at| format!(" · {}", time::format_date(at))).unwrap_or_default(); + let _ = now; + match (status, e.installed_label(), e.latest_label()) { + (AppStatus::UpdateAvailable, Some(i), Some(l)) => format!("{i} installed → {l} released{released}"), + (AppStatus::UpToDate, Some(i), _) => format!("Version {i}{released}"), + (_, Some(i), Some(l)) => format!("{i} installed · latest {l}"), + (_, Some(i), None) => format!("Version {i} installed"), + (_, None, Some(l)) => format!("Version {l}{released}"), + (AppStatus::NoRelease, None, None) => "No release published yet".into(), + _ => " ".into(), + } +} + +/// The launcher's own download for this platform. +fn launcher_download(r: &artcraft_launcher_core::Release, p: artcraft_launcher_core::Platform) -> Option { + use artcraft_launcher_core::PackageKind; + let kinds: &[PackageKind] = match p.os { + artcraft_launcher_core::Os::Linux => &[PackageKind::AppImage, PackageKind::Deb], + _ => p.default_kinds(), + }; + pick_asset(r, &[artcraft_launcher_core::catalog::LAUNCHER_ID], p, kinds).map(|(a, _)| a.url.clone()) +} + +/// Fills the top `height` of a rounded rect with opaque `color` at `alpha`, fading to transparent. +fn gradient_top(painter: &egui::Painter, rect: egui::Rect, radius: f32, height: f32, color: Color32, alpha: f32) { + let mut mesh = egui::Mesh::default(); + let h = height.min(rect.height()); + let steps = 24; + let mut prev: Option<(u32, u32)> = None; + for i in 0..=steps { + let y = h * i as f32 / steps as f32; + // Inset along the rounded corner. + let inset = if y < radius { radius - (radius * radius - (radius - y) * (radius - y)).max(0.0).sqrt() } else { 0.0 }; + let a = 1.0 - i as f32 / steps as f32; + let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), (255.0 * alpha * a * a) as u8); + let base = mesh.vertices.len() as u32; + mesh.colored_vertex(egui::pos2(rect.left() + inset, rect.top() + y), c); + mesh.colored_vertex(egui::pos2(rect.right() - inset, rect.top() + y), c); + if let Some((l, r)) = prev { + mesh.add_triangle(l, r, base); + mesh.add_triangle(r, base + 1, base); + } + prev = Some((base, base + 1)); + } + painter.add(egui::Shape::mesh(mesh)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs new file mode 100644 index 0000000..5d7df3d --- /dev/null +++ b/crates/ui-egui/src/widgets.rs @@ -0,0 +1,165 @@ +//! Small building blocks: pills, buttons, segmented controls. + +use egui::{Color32, CornerRadius, Response, RichText, Sense, Stroke, Ui, vec2}; + +use crate::model::Tone; +use crate::theme::{self, Tokens, tint}; + +pub fn tone_color(t: &Tokens, tone: Tone) -> Color32 { + match tone { + Tone::Good => t.good, + Tone::Warn => t.warn, + Tone::Info => t.info, + Tone::Neutral => t.text_muted, + Tone::Faint => t.text_faint, + } +} + +/// A rounded status label with a coloured dot. +pub fn pill(ui: &mut Ui, text: &str, tone: Tone) -> Response { + let t = Tokens::get(ui.ctx()); + let c = tone_color(&t, tone); + let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::new(12.0, theme::medium()), c); + let size = vec2(galley.size().x + 26.0, 22.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, CornerRadius::same(11), tint(c, if t.dark { 0.14 } else { 0.11 })); + p.circle_filled(egui::pos2(rect.left() + 11.0, rect.center().y), 3.5, c); + p.galley(egui::pos2(rect.left() + 19.0, rect.center().y - galley.size().y / 2.0), galley, c); + // Painted, so tell screen readers what it says. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text)); + resp +} + +/// A filled call-to-action button. +pub fn primary(ui: &mut Ui, text: &str, fill: Color32, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let btn = egui::Button::new(RichText::new(text).color(t.accent_text).family(theme::semibold())) + .fill(fill) + .stroke(Stroke::NONE) + .corner_radius(theme::RADIUS_SMALL) + .min_size(vec2(width, 32.0)); + let resp = ui.add(btn); + if resp.hovered() { + ui.painter().rect_filled(resp.rect, CornerRadius::same(theme::RADIUS_SMALL), Color32::from_white_alpha(18)); + } + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A quiet outlined button. +pub fn secondary(ui: &mut Ui, text: &str, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let btn = egui::Button::new(RichText::new(text).color(t.text).family(theme::medium())) + .fill(if t.dark { Color32::from_rgb(0x23, 0x27, 0x31) } else { Color32::from_rgb(0xf1, 0xf2, 0xf6) }) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(theme::RADIUS_SMALL) + .min_size(vec2(width, 32.0)); + ui.add(btn).on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A square icon-only button. +pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let btn = egui::Button::new(RichText::new(icon).size(16.0).color(t.text_muted)) + .fill(Color32::TRANSPARENT) + .stroke(Stroke::NONE) + .min_size(vec2(32.0, 32.0)); + ui.add(btn).on_hover_text(tip) +} + +/// Two or more mutually exclusive options in one rounded control. +pub fn segmented(ui: &mut Ui, value: &mut T, options: &[(T, &str, &str)]) -> bool { + let t = Tokens::get(ui.ctx()); + let mut changed = false; + egui::Frame::new() + .fill(if t.dark { Color32::from_rgb(0x0e, 0x10, 0x14) } else { Color32::from_rgb(0xec, 0xee, 0xf2) }) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(theme::RADIUS_SMALL + 1) + .inner_margin(3) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + // In a right-to-left parent the row fills from the right: keep the options' order. + let rtl = ui.layout().prefer_right_to_left(); + ui.horizontal(|ui| { + let ordered: Vec<&(T, &str, &str)> = if rtl { options.iter().rev().collect() } else { options.iter().collect() }; + for (opt, label, tip) in ordered { + let on = *value == *opt; + let btn = + egui::Button::new(RichText::new(*label).size(13.0).family(theme::medium()).color(if on { t.text } else { t.text_muted })) + .fill(if on { t.card_hover } else { Color32::TRANSPARENT }) + .stroke(if on { Stroke::new(1.0, t.border_strong) } else { Stroke::NONE }) + .corner_radius(theme::RADIUS_SMALL - 1) + .min_size(vec2(0.0, 26.0)); + if ui.add(btn).on_hover_text(*tip).clicked() && !on { + *value = *opt; + changed = true; + } + } + }); + }); + changed +} + +/// A filter chip with a count. +pub fn chip(ui: &mut Ui, on: bool, label: &str, count: usize, highlight: Option) -> Response { + let t = Tokens::get(ui.ctx()); + let text_color = if on { t.text } else { t.text_muted }; + let mut job = egui::text::LayoutJob::default(); + job.append(label, 0.0, egui::TextFormat { font_id: egui::FontId::new(13.0, theme::medium()), color: text_color, ..Default::default() }); + job.append( + &format!(" {count}"), + 0.0, + egui::TextFormat { + font_id: egui::FontId::new(12.0, theme::medium()), + color: highlight.filter(|_| count > 0).unwrap_or(t.text_faint), + ..Default::default() + }, + ); + let btn = egui::Button::new(job) + .fill(if on { tint(t.accent, if t.dark { 0.20 } else { 0.12 }) } else { Color32::TRANSPARENT }) + .stroke(if on { Stroke::new(1.0, tint(t.accent, 0.55)) } else { Stroke::new(1.0, t.border) }) + .corner_radius(CornerRadius::same(15)) + .min_size(vec2(0.0, 28.0)); + ui.add(btn).on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A slim progress bar with a caption. +pub fn progress(ui: &mut Ui, fraction: Option, caption: &str, color: Color32, width: f32) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover()); + let p = ui.painter(); + let bar = egui::Rect::from_min_size(egui::pos2(rect.left(), rect.bottom() - 6.0), vec2(width, 6.0)); + p.rect_filled(bar, CornerRadius::same(3), t.border); + let f = match fraction { + Some(f) => f.clamp(0.0, 1.0), + None => { + // Indeterminate: a sweeping segment. + let time = ui.input(|i| i.time) as f32; + let x = (time * 0.8).fract(); + let seg = + egui::Rect::from_min_size(egui::pos2(bar.left() + bar.width() * (x * 1.3 - 0.3).max(0.0), bar.top()), vec2(bar.width() * 0.3, 6.0)) + .intersect(bar); + p.rect_filled(seg, CornerRadius::same(3), color); + ui.ctx().request_repaint(); + -1.0 + } + }; + if f >= 0.0 { + p.rect_filled(egui::Rect::from_min_size(bar.min, vec2(bar.width() * f, 6.0)), CornerRadius::same(3), color); + } + p.text(egui::pos2(rect.left(), rect.top() + 7.0), egui::Align2::LEFT_CENTER, caption, egui::FontId::new(12.0, theme::medium()), t.text_muted); + let resp = ui.interact(rect, ui.id().with(("progress", caption)), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ProgressIndicator, true, caption)); +} + +pub fn human_bytes(n: u64) -> String { + const MB: f64 = 1024.0 * 1024.0; + let n = n as f64; + if n >= 1024.0 * MB { + format!("{:.1} GB", n / (1024.0 * MB)) + } else if n >= MB { + format!("{:.0} MB", n / MB) + } else { + format!("{:.0} KB", (n / 1024.0).max(1.0)) + } +} diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs new file mode 100644 index 0000000..83dac0c --- /dev/null +++ b/crates/ui-egui/tests/ui.rs @@ -0,0 +1,186 @@ +//! Drives the launcher UI against a fake backend: what's shown for each status, and which +//! commands the buttons send. `ARTCRAFT_LAUNCHER_SNAPSHOTS=` also saves screenshots. + +use std::cell::RefCell; +use std::rc::Rc; + +use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Source}; +use artcraft_launcher_core::release::{Asset, Release}; +use artcraft_launcher_core::status::InstallSource; +use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version}; +use artcraft_launcher_ui_egui::settings::ViewMode; +use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +const NOW: i64 = 1_791_500_000; + +struct Fake { + events: Vec, + sent: Rc>>, + manage: bool, +} + +impl Backend for Fake { + fn send(&mut self, command: Command) { + self.sent.borrow_mut().push(command); + } + fn poll(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + fn capabilities(&self) -> Capabilities { + Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder" } + } + fn platform(&self) -> Option { + Some(Platform { os: Os::MacOs, arch: Arch::Arm64 }) + } + fn now(&self) -> i64 { + NOW + } +} + +struct NoHost; +impl Host for NoHost {} + +fn release(id: &str, v: &str, at: i64) -> Release { + let name = format!("{id}-{v}-macos-universal.dmg"); + Release { + tag: format!("v{v}"), + version: version::parse(v), + name: format!("v{v}"), + published_at: Some(at), + html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"), + notes: "## What's Changed\n* Faster things by @a in https://github.com/storytold/x/pull/1".into(), + prerelease: false, + assets: vec![Asset { + name: name.clone(), + size: 150 << 20, + url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"), + sha256: None, + }], + } +} + +/// A realistic mix: updates, up-to-date apps, a download in progress, one app with no release. +fn events() -> Vec { + let mut ev = Vec::new(); + let versions = [ + ("photocraft", "0.5.0"), + ("vectorcraft", "0.7.0"), + ("filmcraft", "0.4.0"), + ("lightcraft", "0.4.0"), + ("pdfcraft", "0.4.0"), + ("effectcraft", "0.6.0"), + ("designcraft", "0.4.0"), + ("wordcraft", "0.3.0"), + ("deckcraft", "0.3.0"), + ("gridcraft", "0.3.0"), + ("cadcraft", "0.3.0"), + ]; + for (i, (id, v)) in versions.iter().enumerate() { + ev.push(Event::Latest { app: (*id).into(), latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)) }); + } + ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None }); + let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")]; + for app in &SUITE { + let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed { + version: version::parse(v), + source: InstallSource::MacApp, + location: format!("/Applications/{}.app", app.name), + }); + ev.push(Event::Detected { app: app.id.into(), installed: inst }); + } + ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } }); + ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None }); + ev +} + +fn harness(view: ViewMode, theme: egui::Theme, manage: bool) -> (Harness<'static, Option>, Rc>>) { + let sent = Rc::new(RefCell::new(Vec::new())); + let sent2 = sent.clone(); + let mut backend = Some(Fake { events: events(), sent: sent2, manage }); + let settings = Settings { view, notify: false, ..Settings::default() }; + let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).wgpu().build_ui_state( + move |ui, state: &mut Option| { + if state.is_none() + && let Some(b) = backend.take() + { + let mut s = settings.clone(); + s.theme = if theme == egui::Theme::Dark { + artcraft_launcher_ui_egui::theme::ThemeChoice::Dark + } else { + artcraft_launcher_ui_egui::theme::ThemeChoice::Light + }; + *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s)); + } + if let Some(l) = state.as_mut() { + l.logic(&ui.ctx().clone(), &mut NoHost); + l.ui(ui, &mut NoHost); + } + }, + None, + ); + (harness, sent) +} + +fn snapshot(h: &mut Harness<'_, Option>, name: &str) { + let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return }; + let img = h.render().unwrap(); + img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap(); +} + +#[test] +fn grid_shows_every_status_and_installs_on_click() { + let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(8); + snapshot(&mut h, "grid-dark"); + // Statuses the user asked for: available, installed and up to date, update with version. + assert!(h.query_all_by_label("Available to install").count() >= 5); + assert!(h.query_all_by_label("Installed · up to date").count() >= 2); + h.get_by_label("Update to 0.5.0"); + h.get_by_label("Update to 0.6.0"); + h.get_by_label_contains("0.4.1 installed → 0.5.0 released"); + assert!(h.query_all_by_label("Coming soon").count() >= 1); + h.get_by_label_contains("48 MB of 131 MB"); + h.get_by_label("Update all (2)").click(); + h.run_steps(2); + let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect(); + assert_eq!(installs.len(), 2, "{installs:?}"); + assert!(installs.contains(&"photocraft".to_string()) && installs.contains(&"effectcraft".to_string())); +} + +#[test] +fn table_view_and_light_theme() { + let (mut h, sent) = harness(ViewMode::Table, egui::Theme::Light, true); + h.run_steps(8); + snapshot(&mut h, "table-light"); + h.get_by_label("Update to 0.5.0").click(); + h.run_steps(2); + assert!(sent.borrow().contains(&Command::Install { app: "photocraft".into() })); + h.get_all_by_label("Open").next().unwrap().click(); + h.run_steps(2); + assert!(sent.borrow().iter().any(|c| matches!(c, Command::Launch { .. }))); +} + +#[test] +fn switching_views_and_filters() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + h.get_by_label_contains("Table").click(); + h.run_steps(4); + assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table); + h.get_by_label_contains("Updates").click(); + h.run_steps(4); + snapshot(&mut h, "table-updates-dark"); + assert!(h.query_by_label("WordCraft").is_none()); + h.get_by_label("PhotoCraft"); +} + +#[test] +fn web_build_offers_downloads() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false); + h.run_steps(8); + snapshot(&mut h, "web-grid-dark"); + assert!(h.query_all_by_label("Download for macOS").count() >= 10); + assert!(h.query_by_label("Install").is_none()); +} diff --git a/docs/brand/LICENSE-brand.txt b/docs/brand/LICENSE-brand.txt new file mode 100644 index 0000000..9a653a5 --- /dev/null +++ b/docs/brand/LICENSE-brand.txt @@ -0,0 +1,42 @@ +ArtCraft brand assets: license + +The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this +directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks +and are NOT open source. They are not covered by ArtCraft Launcher's MIT OR Apache-2.0 license, and nothing in +that license, in NOTICE or in any other file of this repository grants any right to them. + +Permission + +The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free +permission to use the ArtCraft Marks only: + + 1. as part of copies of this repository; and + 2. within ArtCraft Launcher itself, its documentation and its official builds, to identify ArtCraft Launcher as made + by the ArtCraft team and community and to link to getartcraft.com and its community. + +Conditions + +The permission holds only while every one of these is true: + + - The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop, + stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine + them with any other name, mark or artwork. + - They are used only in the context of ArtCraft Launcher, and only to say that the ArtCraft team makes it. + They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports + any other product, service, fork, modified version, plug-in, organization or person. + - Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media + handle, app-store listing, advertising or marketing of anything other than ArtCraft Launcher. + +Forks and modified versions + +If you distribute or publish a modified version of ArtCraft Launcher, or any work derived from it, you must +remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or +present it as an ArtCraft product. You may say, in plain text, that your work is based on ArtCraft Launcher by +the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks +unchanged. + +Everything else + +Any use not expressly permitted here needs prior written permission from the ArtCraft Team +(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms. +All other rights are reserved. diff --git a/docs/brand/artcraft-logo-white.png b/docs/brand/artcraft-logo-white.png new file mode 100644 index 0000000..8cfb31d Binary files /dev/null and b/docs/brand/artcraft-logo-white.png differ diff --git a/docs/brand/artcraft-logo-white.svg b/docs/brand/artcraft-logo-white.svg new file mode 100644 index 0000000..4af80f7 --- /dev/null +++ b/docs/brand/artcraft-logo-white.svg @@ -0,0 +1,7 @@ + + ArtCraft + + + + + diff --git a/docs/brand/artcraft-logo.png b/docs/brand/artcraft-logo.png new file mode 100644 index 0000000..a4d1544 Binary files /dev/null and b/docs/brand/artcraft-logo.png differ diff --git a/docs/brand/artcraft-logo.svg b/docs/brand/artcraft-logo.svg new file mode 100644 index 0000000..d63f3ea --- /dev/null +++ b/docs/brand/artcraft-logo.svg @@ -0,0 +1,7 @@ + + ArtCraft + + + + + diff --git a/docs/brand/artcraft-mark-black.png b/docs/brand/artcraft-mark-black.png new file mode 100644 index 0000000..27664bc Binary files /dev/null and b/docs/brand/artcraft-mark-black.png differ diff --git a/docs/brand/artcraft-mark-black.svg b/docs/brand/artcraft-mark-black.svg new file mode 100644 index 0000000..6394353 --- /dev/null +++ b/docs/brand/artcraft-mark-black.svg @@ -0,0 +1,4 @@ + + ArtCraft + + diff --git a/docs/brand/artcraft-mark.png b/docs/brand/artcraft-mark.png new file mode 100644 index 0000000..dbb3dc0 Binary files /dev/null and b/docs/brand/artcraft-mark.png differ diff --git a/docs/brand/artcraft-mark.svg b/docs/brand/artcraft-mark.svg new file mode 100644 index 0000000..72e191f --- /dev/null +++ b/docs/brand/artcraft-mark.svg @@ -0,0 +1,4 @@ + + ArtCraft + + diff --git a/packaging/.shellcheckrc b/packaging/.shellcheckrc new file mode 100644 index 0000000..36c19d0 --- /dev/null +++ b/packaging/.shellcheckrc @@ -0,0 +1,3 @@ +# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml). +source-path=SCRIPTDIR +external-sources=true diff --git a/packaging/env.sh b/packaging/env.sh new file mode 100644 index 0000000..737b18d --- /dev/null +++ b/packaging/env.sh @@ -0,0 +1,59 @@ +# shellcheck shell=bash +# Shared setup for the packaging scripts. Source it: `. "$(dirname "$0")/../env.sh"`. +# +# Exports: +# ROOT workspace root +# VERSION [workspace.package] version from Cargo.toml +# (override: ARTCRAFT_LAUNCHER_VERSION) +# DIST output directory for release artifacts (default: $ROOT/dist/release) +# ARTCRAFT_LAUNCHER_BUILD_SHA git commit baked into the binaries (read by the app's build.rs) +# ARTCRAFT_LAUNCHER_BUILD_DATE UTC build date, YYYY-MM-DD +# CARGO_TARGET_DIR cargo's target dir (default: $ROOT/target) + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export ROOT + +# The version lives in exactly one place: `[workspace.package] version` in the root Cargo.toml. +# (`cargo xtask version` prints the same thing; awk avoids compiling xtask here.) +workspace_version() { + awk ' + /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } + in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } + ' "$ROOT/Cargo.toml" +} + +VERSION="${ARTCRAFT_LAUNCHER_VERSION:-$(workspace_version)}" +if [ -z "$VERSION" ]; then + echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 + exit 1 +fi +export VERSION + +DIST="${DIST:-$ROOT/dist/release}" +mkdir -p "$DIST" +export DIST + +if [ -z "${ARTCRAFT_LAUNCHER_BUILD_SHA:-}" ]; then + ARTCRAFT_LAUNCHER_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" +fi +export ARTCRAFT_LAUNCHER_BUILD_SHA +export ARTCRAFT_LAUNCHER_BUILD_DATE="${ARTCRAFT_LAUNCHER_BUILD_DATE:-$(date -u +%Y-%m-%d)}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" + +# Emit a GitHub Actions warning (plain stderr outside Actions). +warn() { + if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi +} + +# Copy licence, notice and readme files that exist into a package directory. +copy_docs() { + local dest="$1" f + for f in README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md; do + if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + done +} + +# Portable SHA-256 of a file (prints just the hash). +sha256() { + if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi +} diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh new file mode 100755 index 0000000..de61c36 --- /dev/null +++ b/packaging/freebsd/package.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Build and package ArtCraft Launcher for FreeBSD: +# +# $DIST/artcraft-launcher--freebsd-x86_64.tar.gz a /usr/local-style tree: +# artcraft-launcher--freebsd-x86_64/{bin, share/applications, share/icons, +# share/metainfo, share/doc/artcraft-launcher} +# +# Install by copying the tree's contents into /usr/local: +# tar -xzf artcraft-launcher--freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local +# +# Usage: packaging/freebsd/package.sh [--skip-build] [--dry-run] +# --dry-run stage the tree from a stub binary and list it, without building (works on any OS) +# +# Needs: bash, cargo, and the packages installed in .github/workflows/freebsd.yml. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +LINUX="$ROOT/packaging/linux" +APP_ID=ai.storyteller.artcraft-launcher +PKG=artcraft-launcher + +SKIP_BUILD=0 +DRY_RUN=0 +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=1; shift ;; + --dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;; + -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +# FreeBSD's uname says amd64; release file names use x86_64 like the Linux and Windows ones. +MACHINE="$(uname -m)" +case "$MACHINE" in + amd64 | x86_64) ARCH=x86_64 ;; + arm64 | aarch64) ARCH=aarch64 ;; + *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; +esac +if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then + echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 + exit 2 +fi +BASENAME="$PKG-$VERSION-freebsd-$ARCH" + +echo "==> ArtCraft Launcher $VERSION for FreeBSD $ARCH" + +export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" +if [ "$SKIP_BUILD" = 0 ]; then + (cd "$ROOT" && cargo build --release --locked -p "$PKG") +fi +WORK="$CARGO_TARGET_DIR/freebsd-package" +STAGE="$WORK/$BASENAME" +rm -rf "$WORK" +mkdir -p "$WORK" + +BIN="$CARGO_TARGET_DIR/release" +OUT_DIR="$DIST" +if [ "$DRY_RUN" = 1 ]; then + # A stub binary, and the tarball stays in the work dir so dist/ only ever holds real builds. + BIN="$WORK/stub-bin" + OUT_DIR="$WORK" + mkdir -p "$BIN" + printf '#!/bin/sh\necho "%s %s (dry-run stub)"\n' "$PKG" "$VERSION" >"$BIN/$PKG" + chmod 755 "$BIN/$PKG" +fi + +# ---- stage a /usr/local-style tree -------------------------------------------------------------- +# FreeBSD's install(1) has no -D: create the directories first. +mkdir -p "$STAGE/bin" "$STAGE/share/applications" "$STAGE/share/metainfo" "$STAGE/share/icons" \ + "$STAGE/share/doc/$PKG" +install -m 755 "$BIN/$PKG" "$STAGE/bin/" +strip "$STAGE/bin/$PKG" 2>/dev/null || true +# The desktop entry, metainfo and icons are the freedesktop files Linux ships. +install -m 644 "$LINUX/$APP_ID.desktop" "$STAGE/share/applications/$APP_ID.desktop" +sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$ARTCRAFT_LAUNCHER_BUILD_DATE/g" \ + "$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml" +cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" +copy_docs "$STAGE/share/doc/$PKG" + +for f in "bin/$PKG" "share/applications/$APP_ID.desktop" \ + "share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \ + "share/doc/$PKG/LICENSE-MIT" "share/doc/$PKG/LICENSE-APACHE"; do + if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi +done + +# ---- .tar.gz ------------------------------------------------------------------------------------ +mkdir -p "$OUT_DIR" +tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" +echo "wrote $OUT_DIR/$BASENAME.tar.gz" + +if [ "$DRY_RUN" = 1 ]; then + echo "==> dry run: tarball contents" + tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" +fi +echo "==> done" diff --git a/packaging/icons.sh b/packaging/icons.sh new file mode 100755 index 0000000..361da3a --- /dev/null +++ b/packaging/icons.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Regenerate every app icon from assets/app-icon/artcraft-launcher.svg (the canonical master) and +# the tray icons from assets/app-icon/tray/*.svg. +# +# Needs: resvg (brew install resvg / cargo install resvg). On macOS, iconutil also writes the +# .icns. The outputs are committed, so packaging (and the Rust build, which include_bytes!s some +# of them) never needs these tools. +# +# packaging/icons.sh +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIR="$ROOT/assets/app-icon" +SVG="$DIR/artcraft-launcher.svg" +ID="ai.storyteller.artcraft-launcher" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +command -v resvg >/dev/null || { echo "error: resvg not found (brew install resvg)" >&2; exit 1; } + +# The artwork is a 512-unit rounded tile (rx=112). macOS icons pad it to Apple's 824/1024 body +# grid (transparent margin). Windows and Linux icons use the whole tile with a hairline margin. +grep -q 'viewBox="0 0 512 512"' "$SVG" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; } +MAC="$TMP/mac.svg" +sed 's/viewBox="0 0 512 512"/viewBox="-62 -62 636 636"/' "$SVG" >"$MAC" +FLAT="$TMP/flat.svg" +sed 's/viewBox="0 0 512 512"/viewBox="-6 -6 524 524"/' "$SVG" >"$FLAT" + +render() { resvg -w "$2" -h "$2" "$1" "$3" /dev/null; then + SET="$TMP/artcraft-launcher.iconset" + mkdir -p "$SET" + for s in 16 32 128 256 512; do + render "$MAC" "$s" "$SET/icon_${s}x${s}.png" + render "$MAC" $((s * 2)) "$SET/icon_${s}x${s}@2x.png" + done + iconutil -c icns -o "$DIR/artcraft-launcher.icns" "$SET" +else + echo "warning: iconutil not found (macOS only); artcraft-launcher.icns not regenerated" >&2 +fi + +# Tray icons: a black template glyph for the macOS menu bar (44 px = 22 pt @2x) and a colour +# glyph for the Windows notification area and Linux/BSD status notifiers. +render "$DIR/tray/tray-template.svg" 44 "$DIR/tray/tray-template.png" +render "$DIR/tray/tray-color.svg" 64 "$DIR/tray/tray-color.png" +echo "icons written to $DIR" diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun new file mode 100755 index 0000000..5f5a645 --- /dev/null +++ b/packaging/linux/AppRun @@ -0,0 +1,72 @@ +#!/bin/sh +# ArtCraft Launcher AppImage entry point (packaging/linux/AppRun; installed by package.sh). +# +# On Wayland the compositor resolves a window's dock icon by taking the window's +# app_id (ai.storyteller.artcraft-launcher) and looking up an installed .desktop file. +# An AppImage installs nothing, so every AppImage run would otherwise show the +# generic "Wayland" icon. Best-effort desktop integration: on first run +# (and whenever the AppImage's path changes) install the .desktop entry and the +# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave +# the launch untouched. Opt out with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1. + +set -u + +APPDIR="$(dirname "$(readlink -f "$0")")" +DESK_ID=ai.storyteller.artcraft-launcher + +integrate() { + case "${ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION:-}" in + 1 | true | yes) return 0 ;; + esac + # The AppImage runtime exports $APPIMAGE; without it (an extracted AppDir) there + # is no path to register and nothing to do. + appimage="${APPIMAGE:-}" + [ -n "$appimage" ] && [ -f "$appimage" ] || return 0 + appimage="$(readlink -f "$appimage")" || return 0 + # Exec= is parsed by the desktop: a path holding a quote, backquote, dollar, backslash, + # percent or a line break would need escaping (or could add lines), so skip integration. + case "$appimage" in + *[\"\`\$\\%]* | *' +'*) return 0 ;; + esac + home="${HOME:-}" + [ -n "$home" ] || return 0 + data="${XDG_DATA_HOME:-$home/.local/share}" + src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop" + [ -f "$src_desktop" ] || return 0 + + # Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop + # TryExec, which would hide the entry; everything else is shipped as packaged. + apps="$data/applications" + mkdir -p "$apps" 2>/dev/null || return 0 + tmp="$apps/.$DESK_ID.tmp.$$" + esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')" + if sed -e "s|^Exec=.*|Exec=\"$esc\"|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then + if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then + rm -f "$tmp" + else + if mv -f "$tmp" "$apps/$DESK_ID.desktop" 2>/dev/null; then + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1 + else + rm -f "$tmp" + fi + fi + else + rm -f "$tmp" + return 0 + fi + + # Icons: per-file compare so an unchanged install costs a handful of cmp calls. + src_icons="$APPDIR/usr/share/icons/hicolor" + [ -d "$src_icons" ] || return 0 + icons="$data/icons/hicolor" + (cd "$src_icons" && find . -type f) | while IFS= read -r rel; do + mkdir -p "$icons/$(dirname "$rel")" 2>/dev/null || continue + if ! cmp -s "$src_icons/$rel" "$icons/$rel"; then + cp "$src_icons/$rel" "$icons/$rel" 2>/dev/null || true + fi + done +} + +integrate +exec "$APPDIR/usr/bin/artcraft-launcher" "$@" diff --git a/packaging/linux/ai.storyteller.artcraft-launcher.desktop b/packaging/linux/ai.storyteller.artcraft-launcher.desktop new file mode 100644 index 0000000..88e13a2 --- /dev/null +++ b/packaging/linux/ai.storyteller.artcraft-launcher.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Type=Application +Name=ArtCraft Launcher +GenericName=App Launcher +Comment=Install, update and launch the Craft App Suite +Exec=artcraft-launcher +TryExec=artcraft-launcher +Icon=ai.storyteller.artcraft-launcher +Terminal=false +StartupNotify=true +StartupWMClass=artcraft-launcher +Categories=Utility;PackageManager; +Keywords=artcraft;craft;launcher;installer;updater;photocraft;filmcraft;apps; diff --git a/packaging/linux/ai.storyteller.artcraft-launcher.metainfo.xml.in b/packaging/linux/ai.storyteller.artcraft-launcher.metainfo.xml.in new file mode 100644 index 0000000..234acb6 --- /dev/null +++ b/packaging/linux/ai.storyteller.artcraft-launcher.metainfo.xml.in @@ -0,0 +1,57 @@ + + + + ai.storyteller.artcraft-launcher + CC0-1.0 + MIT OR Apache-2.0 + ArtCraft Launcher + Install, update and launch the Craft App Suite + + Learning Machines LLC + + +

+ ArtCraft Launcher lists every app in the Craft App Suite (PhotoCraft, LightCraft, FilmCraft, + DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft + and SoundCraft), installs the right build for your computer, and keeps them up to date. +

+
    +
  • One place to find, install and open every Craft app
  • +
  • Checks GitHub releases for updates and installs them for you
  • +
  • Lives in the system tray and tells you when updates are ready
  • +
+
+ ai.storyteller.artcraft-launcher.desktop + ai.storyteller.artcraft-launcher + + https://getartcraft.com/apps + https://github.com/storytold/craft-launcher/issues + https://github.com/storytold/craft-launcher + https://discord.gg/artcraft + + Utility + PackageManager + + + launcher + installer + updater + artcraft + + + #d9dce3 + #26282e + + + keyboard + pointing + + + artcraft-launcher + + + + + +
diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh new file mode 100755 index 0000000..23007d1 --- /dev/null +++ b/packaging/linux/apprun-test.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Smoke test for packaging/linux/AppRun: the AppImage's desktop integration writes +# and refreshes the host .desktop entry and icons, is idempotent, honours the opt-out +# and an unwritable HOME, and always launches the app. +# +# Runs in seconds with no build (CI: .github/workflows/packaging-lint.yml). +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +APP_ID=ai.storyteller.artcraft-launcher + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# Fake AppDir with the real AppRun, .desktop template, icons, and a stub binary. +APPDIR="$TMP/ArtCraftLauncher.AppDir" +mkdir -p "$APPDIR/usr/bin" +install -Dm755 "$HERE/AppRun" "$APPDIR/AppRun" +install -Dm644 "$HERE/$APP_ID.desktop" "$APPDIR/usr/share/applications/$APP_ID.desktop" +mkdir -p "$APPDIR/usr/share/icons" +cp -R "$ROOT/assets/app-icon/hicolor" "$APPDIR/usr/share/icons/" +printf '#!/bin/sh\necho "stub: $*"\n' >"$APPDIR/usr/bin/artcraft-launcher" +chmod +x "$APPDIR/usr/bin/artcraft-launcher" + +APPIMAGE="$TMP/artcraft-launcher test.AppImage" # spaces on purpose: Exec must stay quoted +: >"$APPIMAGE" +export APPIMAGE +export HOME="$TMP/home" +export XDG_DATA_HOME="$HOME/.local/share" +mkdir -p "$HOME" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# 1. Launch works and passes arguments through. +out="$("$APPDIR/AppRun" open file.png)" || fail "AppRun exited non-zero" +[ "$out" = "stub: open file.png" ] || fail "arguments not forwarded: $out" + +DESKTOP="$XDG_DATA_HOME/applications/$APP_ID.desktop" +[ -f "$DESKTOP" ] || fail "desktop entry not installed" + +# 2. Exec rewritten to the AppImage (quoted, spaces intact), TryExec dropped, +# everything else preserved from the packaged entry. +expected_exec="Exec=\"$(readlink -f "$APPIMAGE")\"" +grep -Fxq "$expected_exec" "$DESKTOP" || fail "Exec not rewritten: $(grep '^Exec=' "$DESKTOP")" +grep -q '^TryExec=' "$DESKTOP" && fail "TryExec kept, the entry would be hidden" +grep -Fxq "Icon=$APP_ID" "$DESKTOP" || fail "Icon line lost" +grep -Fxq 'Categories=Utility;PackageManager;' "$DESKTOP" || fail "metadata lines lost" +if command -v desktop-file-validate >/dev/null; then + desktop-file-validate "$DESKTOP" || fail "generated desktop entry is invalid" +fi + +# 3. Icons landed under the name the .desktop entry references. +for f in "16x16/apps/$APP_ID.png" "256x256/apps/$APP_ID.png" "scalable/apps/$APP_ID.svg"; do + [ -f "$XDG_DATA_HOME/icons/hicolor/$f" ] || fail "icon missing: $f" +done + +# 4. Idempotent: a second launch must not rewrite anything (inode unchanged). +before="$(stat -c %i "$DESKTOP")" +out="$("$APPDIR/AppRun")" || fail "second launch exited non-zero" +[ "$out" = "stub: " ] || fail "second launch output: $out" +after="$(stat -c %i "$DESKTOP")" +[ "$before" = "$after" ] || fail "desktop entry rewritten although unchanged" + +# 5. Moving the AppImage refreshes Exec. +mv "$APPIMAGE" "$TMP/moved.AppImage" +export APPIMAGE="$TMP/moved.AppImage" +"$APPDIR/AppRun" >/dev/null || fail "launch after move exited non-zero" +grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\"" "$DESKTOP" || fail "Exec not refreshed after move" + +# 6. Opt-out installs nothing. +rm -rf "$XDG_DATA_HOME" +ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1 "$APPDIR/AppRun" >/dev/null || fail "opt-out launch exited non-zero" +[ ! -e "$XDG_DATA_HOME" ] || fail "opt-out still integrated" + +# 7. An unwritable data dir never blocks the launch. +mkdir -p "$TMP/ro" && chmod a-w "$TMP/ro" +out="$(XDG_DATA_HOME="$TMP/ro/data" "$APPDIR/AppRun" out.txt)" || fail "launch blocked by unwritable data dir" +[ "$out" = "stub: out.txt" ] || fail "stub not run against unwritable data dir: $out" +chmod u+w "$TMP/ro" + +# 8. No $APPIMAGE (extracted AppDir): launch only, nothing installed. +rm -rf "$XDG_DATA_HOME" +env -u APPIMAGE "$APPDIR/AppRun" >/dev/null || fail "extracted-AppDir launch exited non-zero" +[ ! -e "$XDG_DATA_HOME" ] || fail "integrated without $APPIMAGE" + +# 9. A path that would need Exec escaping (here a `$`): launch only, nothing installed. +rm -rf "$XDG_DATA_HOME" +odd="$TMP/odd\$(id).AppImage" +: >"$odd" +out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exited non-zero" +[ "$out" = "stub: x" ] || fail "odd path: $out" +[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping" + +echo "AppRun desktop integration ok" diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh new file mode 100755 index 0000000..e6d3652 --- /dev/null +++ b/packaging/linux/flatpak-bundle.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Repackage an ArtCraft Launcher Linux tarball as a single-file Flatpak bundle: +# +# $DIST/artcraft-launcher--linux-.flatpak +# +# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL] +# +# TARBALL defaults to $DIST/artcraft-launcher--linux-.tar.gz from package.sh. The bundle +# is built for the host architecture (x86_64 or aarch64), which must match the tarball's. +# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's +# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are +# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and its +# files are listed inside the sandbox as a smoke test (the launcher is GUI-only, so it isn't run). +# Manifest: packaging/linux/flatpak/ai.storyteller.artcraft-launcher.bundle.yml. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" +APP_ID=ai.storyteller.artcraft-launcher +PKG=artcraft-launcher +FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo + +TEST=1 +TARBALL="" +while [ $# -gt 0 ]; do + case "$1" in + --no-test) TEST=0; shift ;; + -h | --help) sed -n '2,14p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac +BASENAME="$PKG-$VERSION-linux-$ARCH" +TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" +[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +for tool in flatpak flatpak-builder; do + command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } +done + +echo "==> ArtCraft Launcher $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")" + +WORK="$CARGO_TARGET_DIR/flatpak-bundle" +rm -rf "$WORK" +mkdir -p "$WORK/stage" +tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 +[ -x "$WORK/stage/bin/$PKG" ] || { echo "error: $TARBALL has no bin/$PKG" >&2; exit 1; } +cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" + +flatpak remote-add --user --if-not-exists flathub "$FLATHUB" +# --disable-rofiles-fuse: no FUSE in containers and CI runners. +flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \ + --default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml" + +OUT="$DIST/$BASENAME.flatpak" +# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. +flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable +echo "wrote $OUT" + +if [ "$TEST" = 1 ]; then + flatpak install --user -y --noninteractive --reinstall "$OUT" + flatpak info --user "$APP_ID" + flatpak run --command=sh "$APP_ID" -c 'test -x /app/bin/artcraft-launcher && ls /app/share/applications /app/share/metainfo /app/share/icons/hicolor/scalable/apps' +fi +echo "==> done" +ls -lh "$OUT" diff --git a/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.bundle.yml b/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.bundle.yml new file mode 100644 index 0000000..8dd5706 --- /dev/null +++ b/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.bundle.yml @@ -0,0 +1,52 @@ +# Flatpak manifest for the single-file bundle attached to every GitHub release +# (artcraft-launcher--linux-.flatpak). Build it with packaging/linux/flatpak-bundle.sh. +# +# It repackages the release's own Linux tarball (artcraft-launcher--linux-.tar.gz, +# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no +# network and no vendored crate sources during the build, a few seconds instead of many minutes, +# and byte-for-byte the same binary as the AppImage/deb/rpm. The script unpacks the tarball +# into `stage/` next to a copy of this file. The binary needs glibc >= 2.35 and loads X11, +# Wayland, xkbcommon and Vulkan at runtime; the freedesktop runtime has all of them, and the +# GPU driver comes from its org.freedesktop.Platform.GL extension. +# +# ai.storyteller.artcraft-launcher.yml (same directory) is the from-source manifest for Flathub. +# Keep runtime-version and finish-args identical in both (packaging-lint checks this). +id: ai.storyteller.artcraft-launcher +runtime: org.freedesktop.Platform +runtime-version: '26.08' +sdk: org.freedesktop.Sdk +command: artcraft-launcher +finish-args: + # Display: Wayland first; X11 only when no Wayland compositor is running. + - --socket=wayland + - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for egui's wgpu renderer (Vulkan, or GL as a fallback). + - --device=dri + # The launcher reads the Craft apps' GitHub releases and downloads them. + - --share=network + # Tray icon (StatusNotifierItem) and desktop notifications, both over the session bus. + - --talk-name=org.kde.StatusNotifierWatcher + - --talk-name=org.freedesktop.Notifications + # LIMITATION: a launcher inside a Flatpak sandbox cannot install software on the host by + # itself. It may run `flatpak-spawn --host` (org.freedesktop.Flatpak) to start installed apps + # and host installers, and it gets the home directory so downloaded AppImages and tarballs land + # where the host can run them (~/Applications, ~/.local). Distro packages (.deb/.rpm) and + # anything needing root still have to be installed by the user. org.freedesktop.Flatpak is a + # sandbox escape that Flathub reviews case by case; the AppImage, .deb and .rpm builds are the + # recommended way to run the launcher on Linux. + - --talk-name=org.freedesktop.Flatpak + - --filesystem=home +modules: + - name: artcraft-launcher + buildsystem: simple + build-commands: + - install -Dm755 bin/artcraft-launcher /app/bin/artcraft-launcher + - install -Dm644 share/applications/ai.storyteller.artcraft-launcher.desktop /app/share/applications/ai.storyteller.artcraft-launcher.desktop + # Already has the release's version and date substituted by package.sh. + - install -Dm644 share/metainfo/ai.storyteller.artcraft-launcher.metainfo.xml /app/share/metainfo/ai.storyteller.artcraft-launcher.metainfo.xml + - mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/artcraft-launcher /app/share/doc/ + sources: + - type: dir + path: stage diff --git a/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml b/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml new file mode 100644 index 0000000..3b8f7e9 --- /dev/null +++ b/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml @@ -0,0 +1,70 @@ +# Flatpak manifest for ArtCraft Launcher, ready for a Flathub submission. +# +# Flatpak builds run offline, so the crate sources must be vendored first. From the repo root: +# +# curl -fsSLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py +# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/linux/flatpak/cargo-sources.json +# flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml +# flatpak run ai.storyteller.artcraft-launcher +# +# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit. +# The single-file .flatpak attached to each release comes from +# ai.storyteller.artcraft-launcher.bundle.yml, which repackages the prebuilt release binary. +# Keep runtime-version and finish-args identical. +id: ai.storyteller.artcraft-launcher +runtime: org.freedesktop.Platform +runtime-version: '26.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable +command: artcraft-launcher +finish-args: + # Display: Wayland first; X11 only when no Wayland compositor is running. + - --socket=wayland + - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for egui's wgpu renderer (Vulkan, or GL as a fallback). + - --device=dri + # The launcher reads the Craft apps' GitHub releases and downloads them. + - --share=network + # Tray icon (StatusNotifierItem) and desktop notifications, both over the session bus. + - --talk-name=org.kde.StatusNotifierWatcher + - --talk-name=org.freedesktop.Notifications + # LIMITATION: a launcher inside a Flatpak sandbox cannot install software on the host by + # itself. It may run `flatpak-spawn --host` (org.freedesktop.Flatpak) to start installed apps + # and host installers, and it gets the home directory so downloaded AppImages and tarballs land + # where the host can run them (~/Applications, ~/.local). Distro packages (.deb/.rpm) and + # anything needing root still have to be installed by the user. org.freedesktop.Flatpak is a + # sandbox escape that Flathub reviews case by case; the AppImage, .deb and .rpm builds are the + # recommended way to run the launcher on Linux. + - --talk-name=org.freedesktop.Flatpak + - --filesystem=home +build-options: + append-path: /usr/lib/sdk/rust-stable/bin +modules: + - name: artcraft-launcher + buildsystem: simple + build-options: + env: + CARGO_HOME: /run/build/artcraft-launcher/cargo + CARGO_NET_OFFLINE: 'true' + build-commands: + - cargo --offline build --release --locked -p artcraft-launcher + - install -Dm755 target/release/artcraft-launcher /app/bin/artcraft-launcher + - install -Dm644 packaging/linux/ai.storyteller.artcraft-launcher.desktop /app/share/applications/ai.storyteller.artcraft-launcher.desktop + - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ + - >- + version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml); + install -d /app/share/metainfo; + sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" + packaging/linux/ai.storyteller.artcraft-launcher.metainfo.xml.in > /app/share/metainfo/ai.storyteller.artcraft-launcher.metainfo.xml + sources: + - type: dir + path: ../../.. + skip: + - target + - dist + - build-flatpak + - .flatpak-builder + - cargo-sources.json diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml new file mode 100644 index 0000000..f8f605b --- /dev/null +++ b/packaging/linux/nfpm.yaml @@ -0,0 +1,77 @@ +# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages +# the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and +# exports VERSION and NFPM_ARCH. +# +# nfpm package -f packaging/linux/nfpm.yaml -p deb -t dist/release/ +name: artcraft-launcher +arch: ${NFPM_ARCH} +platform: linux +version: ${VERSION} +# semver: 0.2.0-rc.1 becomes 0.2.0~rc.1 in deb/rpm, so pre-releases sort before the release. +version_schema: semver +release: 1 +section: utils +priority: optional +maintainer: ${ARTCRAFT_LAUNCHER_MAINTAINER} +vendor: Learning Machines LLC +homepage: https://github.com/storytold/craft-launcher +license: MIT OR Apache-2.0 +description: |- + Install, update and launch the Craft App Suite. + ArtCraft Launcher lists PhotoCraft, LightCraft, FilmCraft, DesignCraft, + VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft + and SoundCraft, installs the right build for this computer and keeps them up + to date from GitHub releases. It can live in the system tray. + +# The binary links only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the +# GPU (Vulkan, EGL/GL) are loaded at runtime with dlopen, so they aren't ELF NEEDED entries and +# the package tools can't find them: list them here (the same set as the other Craft apps). +# The tray (StatusNotifierItem) and notifications talk to the session bus in pure Rust (zbus), +# so there is no libdbus, GTK or appindicator dependency; a desktop without a +# StatusNotifierWatcher (plain GNOME needs the AppIndicator extension) simply shows no tray icon. +overrides: + deb: + depends: + - libc6 (>= 2.35) + - libgcc-s1 + - libxkbcommon0 + - libxkbcommon-x11-0 + - libwayland-client0 + - libx11-6 + - libx11-xcb1 + - libxcb1 + - libxcursor1 + - libxi6 + - libvulkan1 | libegl1 + recommends: + - mesa-vulkan-drivers + - libegl-mesa0 + - xdg-desktop-portal + rpm: + # Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL + # (libxkbcommon-x11, libX11-xcb, ...) and openSUSE (libxkbcommon-x11-0, libX11-xcb1, ...). + depends: + - libxkbcommon.so.0()(64bit) + - libxkbcommon-x11.so.0()(64bit) + - libwayland-client.so.0()(64bit) + - libX11.so.6()(64bit) + - libX11-xcb.so.1()(64bit) + - libxcb.so.1()(64bit) + - libXcursor.so.1()(64bit) + - libXi.so.6()(64bit) + - (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit)) + recommends: + - mesa-vulkan-drivers + - xdg-desktop-portal + +contents: + - src: ${STAGE}/usr/bin/artcraft-launcher + dst: /usr/bin/artcraft-launcher + file_info: { mode: 0755 } + - src: ${STAGE}/usr/share/ + dst: /usr/share/ + type: tree + +scripts: + postinstall: packaging/linux/postinst.sh + postremove: packaging/linux/postinst.sh diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh new file mode 100755 index 0000000..fbbf45b --- /dev/null +++ b/packaging/linux/package.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Build and package ArtCraft Launcher for Linux ( is x86_64 or aarch64): +# +# $DIST/artcraft-launcher--linux-.AppImage any distro with glibc >= the build host's +# $DIST/artcraft-launcher--linux-.AppImage.zsync delta updates (needs zsyncmake) +# $DIST/artcraft-launcher--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... +# $DIST/artcraft-launcher--linux-.rpm Fedora, openSUSE, RHEL, ... +# $DIST/artcraft-launcher--linux-.tar.gz plain FHS-style tree (bin/, share/) +# +# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] +# +# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage +# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, +# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, +# zsyncmake (the zsync package) for the AppImage's .zsync. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" +APP_ID=ai.storyteller.artcraft-launcher +PKG=artcraft-launcher + +SKIP_BUILD=0 +FORMATS="appimage deb rpm tar" +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=1; shift ;; + --formats) FORMATS="$2"; shift 2 ;; + -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) DEB_ARCH=amd64 ;; + aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac +export ARTCRAFT_LAUNCHER_MAINTAINER="${ARTCRAFT_LAUNCHER_MAINTAINER:-ArtCraft Launcher maintainers }" +BASENAME="$PKG-$VERSION-linux-$ARCH" + +echo "==> ArtCraft Launcher $VERSION for Linux $ARCH ($FORMATS)" + +if [ "$SKIP_BUILD" = 0 ]; then + (cd "$ROOT" && cargo build --release --locked -p "$PKG") +fi +BIN="$CARGO_TARGET_DIR/release" +WORK="$CARGO_TARGET_DIR/linux-package" +STAGE="$WORK/root" +rm -rf "$WORK" + +# ---- stage an FHS tree (shared by every format) ------------------------------------------------- +install -Dm755 "$BIN/$PKG" "$STAGE/usr/bin/$PKG" +strip "$STAGE/usr/bin/$PKG" 2>/dev/null || true +install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" +mkdir -p "$STAGE/usr/share/metainfo" +sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$ARTCRAFT_LAUNCHER_BUILD_DATE/g" \ + "$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" +mkdir -p "$STAGE/usr/share/icons" +cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/" +mkdir -p "$STAGE/usr/share/doc/$PKG" +copy_docs "$STAGE/usr/share/doc/$PKG" + +if command -v desktop-file-validate >/dev/null; then + desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop" +fi +if command -v appstreamcli >/dev/null; then + appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" +fi + +has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } + +# ---- .tar.gz ------------------------------------------------------------------------------------ +if has tar; then + mkdir -p "$WORK/tar" + cp -R "$STAGE/usr" "$WORK/tar/$BASENAME" + tar -C "$WORK/tar" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" + echo "wrote $DIST/$BASENAME.tar.gz" +fi + +# ---- .deb / .rpm -------------------------------------------------------------------------------- +if has deb || has rpm; then + command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; } + export VERSION + export NFPM_ARCH="$DEB_ARCH" + # nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`. + sed "s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml" + for fmt in deb rpm; do + if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi + done +fi + +# ---- AppImage ----------------------------------------------------------------------------------- +if has appimage; then + APPDIR="$WORK/ArtCraftLauncher.AppDir" + cp -R "$STAGE" "$APPDIR" + mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" + # AppRun is a script, not a symlink: it installs the .desktop entry and icons into + # $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's + # app_id to our icon instead of the generic Wayland logo (opt-out env in the + # script header). Logic is covered by packaging/linux/apprun-test.sh. + install -Dm755 "$HERE/AppRun" "$APPDIR/AppRun" + cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" + cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" + ln -s "$APP_ID.png" "$APPDIR/.DirIcon" + + TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" + if [ -z "$TOOL" ]; then + TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" + if [ ! -x "$TOOL" ]; then + curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + chmod +x "$TOOL" + fi + fi + # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be + # relative, e.g. target/agent-). + OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" + APPDIR="$(cd "$APPDIR" && pwd)" + TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. + rm -f "$OUT.zsync" + # Update information: AppImageUpdate, AppImageLauncher and the like read it from the + # file and fetch only the blocks that changed in a newer release, through the .zsync published + # next to each AppImage on GitHub Releases. `latest` is the newest published release that is + # not a pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY. + # The repo is craft-launcher while the files are artcraft-launcher-*. + REPO="${GITHUB_REPOSITORY:-storytold/craft-launcher}" + UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|$PKG-*-linux-$ARCH.AppImage.zsync" + # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, + # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync + # package) appimagetool also writes the .zsync, into its working directory, hence the cd. + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + echo "wrote $OUT" + if [ -s "$OUT.zsync" ]; then + echo "wrote $OUT.zsync" + else + warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" + fi +fi + +echo "==> done" +ls -lh "$DIST" diff --git a/packaging/linux/postinst.sh b/packaging/linux/postinst.sh new file mode 100755 index 0000000..4822ba3 --- /dev/null +++ b/packaging/linux/postinst.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# deb/rpm post-install and post-remove: refresh the desktop and icon caches. +# Every tool is optional; missing ones are skipped. +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true +fi +exit 0 diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in new file mode 100644 index 0000000..08f1638 --- /dev/null +++ b/packaging/macos/Info.plist.in @@ -0,0 +1,49 @@ + + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ArtCraft Launcher + CFBundleName + ArtCraft Launcher + CFBundleExecutable + ArtCraft Launcher + CFBundleIdentifier + ai.storyteller.artcraft-launcher + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleShortVersionString + @SHORT_VERSION@ + CFBundleVersion + @SHORT_VERSION@ + ArtCraftLauncherVersion + @VERSION@ + ArtCraftLauncherBuildCommit + @BUILD_SHA@ + CFBundleIconFile + ArtCraft Launcher + LSApplicationCategoryType + public.app-category.utilities + + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + NSRequiresAquaSystemAppearance + + NSHumanReadableCopyright + Copyright © 2026 ArtCraft Team and the ArtCraft Launcher contributors. MIT OR Apache-2.0. + + diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist new file mode 100644 index 0000000..859a85b --- /dev/null +++ b/packaging/macos/entitlements.plist @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh new file mode 100755 index 0000000..929cf4f --- /dev/null +++ b/packaging/macos/import-cert.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# CI only: import the Developer ID certificate into a temporary keychain so codesign can use it. +# +# Inputs (env): APPLE_CERTIFICATE (base64 .p12), APPLE_CERTIFICATE_PASSWORD, KEYCHAIN_PASSWORD +# (optional; random if unset), RUNNER_TEMP (or TMPDIR). +# Outputs: MACOS_SIGN_IDENTITY (SHA-1 of the identity) and MACOS_KEYCHAIN, appended to +# $GITHUB_ENV when running in Actions, otherwise printed. +# +# Missing certificate: prints a warning and exits 0, so package.sh falls back to ad-hoc signing. +set -euo pipefail +umask 077 +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" + +if [ -z "${APPLE_CERTIFICATE:-}" ]; then + warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" + exit 0 +fi + +TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +KEYCHAIN="$TMP/artcraft-launcher-signing.keychain-db" +CERT="$TMP/artcraft-launcher-signing.p12" +KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" + +trap 'rm -f "$CERT"' EXIT +printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" +security create-keychain -p "$KC_PASS" "$KEYCHAIN" +security set-keychain-settings -lut 21600 "$KEYCHAIN" +security unlock-keychain -p "$KC_PASS" "$KEYCHAIN" +security import "$CERT" -P "${APPLE_CERTIFICATE_PASSWORD:-}" -T /usr/bin/codesign -T /usr/bin/security -t cert -f pkcs12 -k "$KEYCHAIN" +rm -f "$CERT" +# Let codesign use the key without a UI prompt. +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KEYCHAIN" >/dev/null +# Put it on the search list (keeping the login keychain) so codesign and notarytool find it. +# shellcheck disable=SC2046 +security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + +IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" +if [ -z "$IDENTITY" ]; then + security find-identity -v -p codesigning "$KEYCHAIN" >&2 + echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 + exit 1 +fi +echo "Imported signing identity $IDENTITY" +if [ -n "${GITHUB_ENV:-}" ]; then + { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" +else + echo "MACOS_SIGN_IDENTITY=$IDENTITY" + echo "MACOS_KEYCHAIN=$KEYCHAIN" +fi diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh new file mode 100755 index 0000000..e21d1b0 --- /dev/null +++ b/packaging/macos/package.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Build, sign and (optionally) notarize the macOS release artifact: +# +# $DIST/artcraft-launcher--macos-.dmg ArtCraft Launcher.app on a drag-to-Applications DMG +# +# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build] +# +# Signing (env): +# MACOS_SIGN_IDENTITY codesign identity (name or SHA-1). Default "-" = ad-hoc (local testing; +# Gatekeeper will reject the result on other Macs). In CI, import-cert.sh sets it. +# MACOS_KEYCHAIN keychain holding the identity (optional) +# Notarization (env; all three needed, and a real identity): +# APPLE_ID, APPLE_PASSWORD (app-specific password), APPLE_TEAM_ID +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/macos" + +# ---- app-specific values ---------------------------------------------------------------------- +PKG=artcraft-launcher # cargo package and binary +APP_NAME="ArtCraft Launcher" # display name: bundle, executable, icon, DMG volume + +ARCH=universal +SKIP_BUILD=0 +while [ $# -gt 0 ]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + --skip-build) SKIP_BUILD=1; shift ;; + -h | --help) sed -n '2,13p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +case "$ARCH" in + universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) ;; + aarch64) TARGETS=(aarch64-apple-darwin) ;; + x86_64) TARGETS=(x86_64-apple-darwin) ;; + *) echo "unknown --arch $ARCH" >&2; exit 2 ;; +esac + +# Keep in sync with LSMinimumSystemVersion in Info.plist.in. +export MACOSX_DEPLOYMENT_TARGET=11.0 +IDENTITY="${MACOS_SIGN_IDENTITY:--}" +SHORT_VERSION="${VERSION%%-*}" +WORK="$CARGO_TARGET_DIR/macos-package" +APP="$WORK/$APP_NAME.app" +DMG="$DIST/$PKG-$VERSION-macos-$ARCH.dmg" + +NOTARIZE=0 +if [ "$IDENTITY" = "-" ]; then + warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" +elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then + NOTARIZE=1 +else + warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" +fi + +echo "==> $APP_NAME $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" + +# ---- build ------------------------------------------------------------------------------------- +if [ "$SKIP_BUILD" = 0 ]; then + args=() + for t in "${TARGETS[@]}"; do args+=(--target "$t"); done + (cd "$ROOT" && cargo build --release --locked -p "$PKG" "${args[@]}") +fi + +rm -rf "$WORK" +mkdir -p "$WORK/bin" +inputs=() +for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$PKG"); done +lipo -create -output "$WORK/bin/$PKG" "${inputs[@]}" +lipo -info "$WORK/bin/$PKG" + +# ---- signing helpers --------------------------------------------------------------------------- +sign() { + # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. + local ts=(--timestamp) + [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + local kc=() + [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" +} + +notarize() { + local file="$1" out id status + echo "==> notarizing $(basename "$file") (this can take a few minutes)" + out="$(xcrun notarytool submit "$file" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" --wait --timeout 1h --output-format json)" || true + echo "$out" + id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" + status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" + if [ "$status" != "Accepted" ]; then + if [ -n "$id" ]; then + xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true + fi + echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 + exit 1 + fi +} + +# ---- ArtCraft Launcher.app --------------------------------------------------------------------- +echo "==> assembling $APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +# Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). +cp "$WORK/bin/$PKG" "$APP/Contents/MacOS/$APP_NAME" +cp "$ROOT/assets/app-icon/$PKG.icns" "$APP/Contents/Resources/$APP_NAME.icns" +sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ + -e "s/@BUILD_SHA@/${ARTCRAFT_LAUNCHER_BUILD_SHA:-unknown}/g" \ + "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" +plutil -lint "$APP/Contents/Info.plist" +if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/dev/null 2>&1; then + echo "error: Info.plist must not set LSUIElement (the launcher is a normal Dock app)" >&2 + exit 1 +fi +printf 'APPL????' >"$APP/Contents/PkgInfo" + +# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature). +# Today the only nested code is the main executable; frameworks/helpers would be signed here too. +sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/$APP_NAME" +sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" +codesign --verify --strict --deep --verbose=2 "$APP" + +if [ "$NOTARIZE" = 1 ]; then + ditto -c -k --keepParent "$APP" "$WORK/notarize.zip" + notarize "$WORK/notarize.zip" + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + spctl --assess --type execute -vvv "$APP" +fi + +# ---- DMG --------------------------------------------------------------------------------------- +echo "==> building $DMG" +STAGE="$WORK/dmg" +mkdir -p "$STAGE" +ditto "$APP" "$STAGE/$APP_NAME.app" +ln -s /Applications "$STAGE/Applications" +rm -f "$DMG" "$WORK/raw.dmg" +# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, +# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. +hdiutil makehybrid -hfs -hfs-volume-name "$APP_NAME $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" +hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" +rm -f "$WORK/raw.dmg" +sign "$DMG" +codesign --verify --strict --verbose=2 "$DMG" +if [ "$NOTARIZE" = 1 ]; then + notarize "$DMG" + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + spctl --assess --type open --context context:primary-signature -vvv "$DMG" +fi + +echo "==> done (check the artifact as shipped with packaging/macos/verify.sh --arch $ARCH)" +ls -lh "$DMG" diff --git a/packaging/macos/verify.sh b/packaging/macos/verify.sh new file mode 100755 index 0000000..edb22bd --- /dev/null +++ b/packaging/macos/verify.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Check the macOS release artifact exactly as users download it: +# +# $DIST/artcraft-launcher--macos-.dmg signed, notarized, ticket stapled; the +# app inside is Developer ID signed with the +# hardened runtime and carries every arch +# +# Usage: packaging/macos/verify.sh [--arch universal|aarch64|x86_64] +# +# With a real signing identity and notarization credentials in the environment (the same +# MACOS_SIGN_IDENTITY / APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID that package.sh uses), every +# check is strict and a failure exits non-zero. Without them (local ad-hoc builds, or a release run +# whose secrets are missing) only the signature's integrity is checked and the rest is a warning, +# matching package.sh. Nothing here reads or prints a secret's value; it only tests whether one is set. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" + +PKG=artcraft-launcher +APP_NAME="ArtCraft Launcher" + +ARCH=universal +while [ $# -gt 0 ]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + -h | --help) sed -n '2,14p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +case "$ARCH" in + universal) WANT_ARCHS="x86_64 arm64" ;; + aarch64) WANT_ARCHS="arm64" ;; + x86_64) WANT_ARCHS="x86_64" ;; + *) echo "unknown --arch $ARCH" >&2; exit 2 ;; +esac + +DMG="$DIST/$PKG-$VERSION-macos-$ARCH.dmg" +IDENTITY="${MACOS_SIGN_IDENTITY:--}" +STRICT=0 +if [ "$IDENTITY" != "-" ] && [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then + STRICT=1 +fi + +FAILED=0 +fail() { + if [ "$STRICT" = 1 ]; then + echo "error: $*" >&2 + FAILED=1 + else + warn "$* (expected without signing secrets)" + fi +} + +# spctl's online ticket lookup can lag a few seconds behind notarytool's "Accepted". +assess() { + local out attempt tries=1 + [ "$STRICT" = 1 ] && tries=5 + for attempt in $(seq "$tries"); do + if out="$(spctl "$@" 2>&1)"; then + printf '%s\n' "$out" + return 0 + fi + [ "$attempt" = "$tries" ] || sleep 10 + done + printf '%s\n' "$out" + return 1 +} + +if [ ! -f "$DMG" ]; then + echo "error: missing $DMG" >&2 + exit 1 +fi + +WORK="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/$PKG-verify.XXXXXX")" +MNT="$WORK/mnt" +cleanup() { + hdiutil detach "$MNT" -quiet >/dev/null 2>&1 || true + rm -rf "$WORK" +} +trap cleanup EXIT + +# ---- DMG --------------------------------------------------------------------------------------- +echo "==> $(basename "$DMG")" +codesign --verify --strict --verbose=2 "$DMG" +if [ "$STRICT" = 1 ]; then + xcrun stapler validate "$DMG" || fail "no notarization ticket stapled to the DMG" + assess --assess --type open --context context:primary-signature -vv "$DMG" \ + || fail "Gatekeeper rejects the DMG" +fi + +# ---- the app inside ---------------------------------------------------------------------------- +mkdir -p "$MNT" +hdiutil attach "$DMG" -readonly -nobrowse -noautoopen -mountpoint "$MNT" -quiet +APP="$MNT/$APP_NAME.app" +EXE="$APP/Contents/MacOS/$APP_NAME" +if [ ! -x "$EXE" ]; then + echo "error: $(basename "$DMG") has no $APP_NAME.app/Contents/MacOS/$APP_NAME" >&2 + exit 1 +fi +echo "==> $APP_NAME.app" + +archs="$(lipo -archs "$EXE")" +for a in $WANT_ARCHS; do + case " $archs " in + *" $a "*) ;; + *) echo "error: $APP_NAME lacks $a (has: $archs)" >&2; exit 1 ;; + esac +done + +if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/dev/null 2>&1; then + echo "error: Info.plist sets LSUIElement; the launcher must stay a normal Dock app" >&2 + exit 1 +fi + +# Integrity first: this must hold even for ad-hoc builds. Not --strict here: files read back from +# the DMG's HFS volume (hdiutil makehybrid) report a com.apple.FinderInfo attribute, which strict +# mode calls "detritus". package.sh already ran the strict check on the bundle before imaging it. +codesign --verify --deep --verbose=2 "$APP" +details="$(codesign -d --verbose=2 "$APP" 2>&1)" +printf '%s\n' "$details" | grep -E '^(Identifier|Format|CodeDirectory|Authority|Timestamp|TeamIdentifier)[= ]' || true + +printf '%s\n' "$details" | grep -Eq '^CodeDirectory .*flags=0x[0-9a-f]+\([^)]*runtime' \ + || fail "$APP_NAME.app is not signed with the hardened runtime" +printf '%s\n' "$details" | grep -q '^Authority=Developer ID Application:' \ + || fail "$APP_NAME.app is not signed with a Developer ID Application identity" +printf '%s\n' "$details" | grep -q '^Timestamp=' \ + || fail "$APP_NAME.app signature has no secure timestamp" +if [ -n "${APPLE_TEAM_ID:-}" ]; then + printf '%s\n' "$details" | grep -qx "TeamIdentifier=$APPLE_TEAM_ID" \ + || fail "$APP_NAME.app is signed by a different team" +fi +if [ "$STRICT" = 1 ]; then + xcrun stapler validate "$APP" || fail "no notarization ticket stapled to $APP_NAME.app" + if gk="$(assess --assess --type execute -vv "$APP")"; then + printf '%s\n' "$gk" + printf '%s\n' "$gk" | grep -q 'source=Notarized Developer ID' \ + || fail "Gatekeeper accepts $APP_NAME.app but not as notarized" + else + printf '%s\n' "$gk" + fail "Gatekeeper rejects $APP_NAME.app (not notarized?)" + fi +fi + +if [ "$FAILED" != 0 ]; then + echo "error: macOS artifacts failed verification" >&2 + exit 1 +fi + +if [ "$STRICT" = 1 ]; then + result="signed (Developer ID, hardened runtime), notarized; DMG and app stapled" +else + result="signature intact; Developer ID and notarization not required (no signing secrets)" +fi +echo "==> verified: $result" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "- macOS \`$(basename "$DMG")\`: $result" >>"$GITHUB_STEP_SUMMARY" +fi diff --git a/packaging/web/.htaccess b/packaging/web/.htaccess new file mode 100644 index 0000000..7b7e6e1 --- /dev/null +++ b/packaging/web/.htaccess @@ -0,0 +1,16 @@ +# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional. +AddType application/wasm .wasm +AddType text/javascript .js + + + AddOutputFilterByType DEFLATE application/wasm text/javascript text/html + + + + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "no-cache" + + diff --git a/packaging/web/README.md b/packaging/web/README.md new file mode 100644 index 0000000..656e660 --- /dev/null +++ b/packaging/web/README.md @@ -0,0 +1,76 @@ +# Hosting ArtCraft Launcher for the web + +`artcraft-launcher-web-.zip` (from the GitHub release, or `packaging/web/package.sh`) +holds a static site in `artcraft-launcher-web-/`: + +| File | What it is | +|---|---| +| `index.html` | The page. It loads everything through relative URLs. | +| `artcraft-launcher-web-.js` | wasm-bindgen glue (generated, ES module) | +| `artcraft-launcher-web-_bg.wasm` | The app (egui, built with the `wasm-release` Cargo profile) | +| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache | + +There is no server-side code and no hand-written JavaScript. Upload the folder's contents anywhere +that serves static files. + +## What the web version does + +In the browser the launcher can't install anything: it shows the Craft App Suite catalog, the +latest release of each app and the right download for the visitor's platform, and links to the +files on GitHub. Release data comes straight from the GitHub REST API (`api.github.com` allows +cross-origin requests), so the page needs no backend. Unauthenticated visitors share GitHub's +limit of 60 API requests per hour per IP address. + +## Any path works + +All URLs in `index.html` are relative (`public_url = "./"` in `apps/artcraft-launcher-web/Trunk.toml`), +so the site works at a domain root (`https://example.com/`), under a prefix +(`https://example.com/apps/launcher/`) and from a CDN bucket. The asset names carry a content +hash, so they can be cached forever. Only `index.html` needs revalidation. + +`packaging/web/package.sh` fails if the wasm grows past 24 MiB (Cloudflare Pages and Workers +reject any single file over 25 MiB). + +## Required server settings + +- **MIME type:** serve `.wasm` as `application/wasm`. Browsers refuse to stream-compile it under + any other type. Serve `.js` as `text/javascript`. Most hosts already do both. For nginx, check + that `mime.types` has `application/wasm wasm;`. +- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. +- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the hashed `.wasm` and + `.js` files, and `no-cache` on `index.html`. +- **Content Security Policy:** if you send one, allow `connect-src https://api.github.com` (and + `https://raw.githubusercontent.com` for the app icons) plus `'wasm-unsafe-eval'` in `script-src`. +- **No special isolation headers:** the app doesn't use `SharedArrayBuffer`, so it doesn't need + `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy`. + +nginx example: + +```nginx +location /launcher/ { + types { application/wasm wasm; text/javascript js; text/html html; } + gzip on; + gzip_types application/wasm text/javascript text/html; + location ~* \.(wasm|js)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } + location ~* index\.html$ { add_header Cache-Control "no-cache"; } +} +``` + +Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. + +## Embedding in a page (iframe) + +```html + +``` + +- The app fills the iframe and follows its size, so size the iframe and not the app. +- **Sandboxed iframes** need at least `sandbox="allow-scripts allow-same-origin allow-popups"`; + `allow-popups` lets download links open. +- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page. diff --git a/packaging/web/_headers b/packaging/web/_headers new file mode 100644 index 0000000..fac0fbe --- /dev/null +++ b/packaging/web/_headers @@ -0,0 +1,13 @@ +# Netlify / Cloudflare Pages header rules (see HOSTING.md). Other hosts: set the same headers. +/* + X-Content-Type-Options: nosniff + +/*.wasm + Content-Type: application/wasm + Cache-Control: public, max-age=31536000, immutable + +/*.js + Cache-Control: public, max-age=31536000, immutable + +/index.html + Cache-Control: no-cache diff --git a/packaging/web/package.sh b/packaging/web/package.sh new file mode 100755 index 0000000..fc2cd67 --- /dev/null +++ b/packaging/web/package.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Build the browser version and zip it: $DIST/artcraft-launcher-web-.zip +# +# Usage: packaging/web/package.sh [--skip-build] +# +# Needs: trunk (brew install trunk / cargo install trunk --locked) and the wasm32-unknown-unknown +# target. The zip holds a self-contained static site in artcraft-launcher-web-/ that works +# from any URL path and inside an