ArtCraft Launcher: install and update the Craft App Suite

A native Rust/egui launcher for the 12 Craft Apps (7 classic + 5 new).

- Grid (icon cards) or sortable table view, filters and search
- Status per app: available, installed and up to date, or update
  available with version and release date
- Installs where each OS keeps apps, from the official release packages:
  /Applications (DMG), Program Files (MSI), deb/rpm via the system
  package manager (Flatpak/AppImage fallback), /usr/local on FreeBSD
- Detects apps installed by hand; updates keep the installed format
- Every download verified against its published SHA-256; running apps
  are never replaced
- Tray/menu-bar mode, start at login, one notification per new version
- GitHub API with ETag cache, falling back to release pages when
  rate-limited; release source is pluggable for a future catalog API
- Web build lists latest releases with per-platform downloads
- Packaging and release workflows for macOS, Windows (x64/x86/arm64),
  Linux (x86_64/aarch64), FreeBSD and web

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 21:08:24 -04:00
parent a147fa2039
commit ec69f0be2c
136 changed files with 15652 additions and 5 deletions

2
.cargo/config.toml Normal file
View File

@ -0,0 +1,2 @@
[alias]
xtask = "run -p xtask --"

80
.github/workflows/ci.yml vendored Normal file
View File

@ -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

59
.github/workflows/freebsd.yml vendored Normal file
View File

@ -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$'

78
.github/workflows/packaging-lint.yml vendored Normal file
View File

@ -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

406
.github/workflows/release.yml vendored Normal file
View File

@ -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<version>" 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 <branch>`) 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"

89
.github/workflows/windows-arm64.yml vendored Normal file
View File

@ -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'

6
.gitignore vendored
View File

@ -1,4 +1,8 @@
*~
plan/
plans/
/target
target/
/dist
/temp
/craft-fonts

128
AGENTS.md Normal file
View File

@ -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/<app>/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.<app>.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/<version>` 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 <crate>`, `cargo clippy -p <crate> --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<version>** 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-<ver>-windows-{x64,x86,arm64}.msi`, `…-windows-{x64,x86,arm64}-portable.zip` |
| macOS | `artcraft-launcher-<ver>-macos-universal.dmg` |
| Linux | `artcraft-launcher-<ver>-linux-{x86_64,aarch64}.{AppImage,AppImage.zsync,deb,rpm,tar.gz,flatpak}` |
| FreeBSD | `artcraft-launcher-<ver>-freebsd-x86_64.tar.gz` |
| Web | `artcraft-launcher-web-<ver>.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-<name>`) 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.

42
ATTRIBUTION.md Normal file
View File

@ -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) | <https://github.com/rsms/inter> (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/<app>/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.<app>.png` | MIT OR Apache-2.0 (each app's license) |

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
Read [AGENTS.md](AGENTS.md) first: it is the guide for every agent and contributor working in this repository.

5570
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

46
Cargo.toml Normal file
View File

@ -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"

177
LICENSE-APACHE Normal file
View File

@ -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

21
LICENSE-MIT Normal file
View File

@ -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.

18
NOTICE Normal file
View File

@ -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/<app>), MIT OR Apache-2.0, Copyright (c) 2026 ArtCraft Team and the <App>
contributors.

237
README.md
View File

@ -1,8 +1,237 @@
Craft Launcher
==============
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
The unified Craft App launcher.
<h1 align="center">ArtCraft Launcher</h1>
This lists all Craft Apps, can install them for your platform, and can update them for you.
<p align="center">
<b>One place to install, update and open every app in the Craft App Suite, rebuilt in pure Rust.</b><br>
PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft,<br>
DeckCraft, GridCraft, CADCraft and SoundCraft: the right build for your computer, kept up to date.
</p>
<p align="center">
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-26282e?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://github.com/storytold/craft-launcher/releases/latest"><b>Download ArtCraft Launcher</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">The Craft App Suite</a>
</p>
> [!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).**
<p align="center">
<a href="#what-it-does">What it does</a> ·
<a href="#features">Features</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#build-from-source">Build from source</a> ·
<a href="#the-craft-app-suite">Craft App Suite</a> ·
<a href="https://discord.gg/artcraft">Discord</a>
</p>
## 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
<table>
<tr>
<td width="33%" valign="top">
<h3>📦 Install in one click</h3>
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.
</td>
<td width="33%" valign="top">
<h3>🔄 Stay up to date</h3>
Checks each app's GitHub releases and updates the ones you have. A notification tells you when there's something new.
</td>
<td width="33%" valign="top">
<h3>🧭 Always at hand</h3>
Opens any Craft app from one window, or from the tray and menu-bar icon. Native egui, no Electron, no web view.
</td>
</tr>
</table>
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>.app` | Copied from the signed, notarized release DMG |
| Windows | `C:\Program Files\<App>` | 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. `<ver>` is the version number, for example `0.1.0`.
**Windows** (10 and 11)
| File | What it is |
|---|---|
| `artcraft-launcher-<ver>-windows-x64.msi` | Installer for most PCs (Intel and AMD, 64-bit). Installs for the current user, no admin rights needed |
| `artcraft-launcher-<ver>-windows-arm64.msi` | Installer for Windows on Arm (Snapdragon and other Arm PCs) |
| `artcraft-launcher-<ver>-windows-x86.msi` | Installer for 32-bit Windows |
| `artcraft-launcher-<ver>-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-<ver>-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-<ver>-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-<ver>-linux-{x86_64,aarch64}.deb` | Debian, Ubuntu, Mint, Pop!_OS: `sudo apt install ./artcraft-launcher-…deb` |
| `artcraft-launcher-<ver>-linux-{x86_64,aarch64}.rpm` | Fedora, openSUSE, RHEL: `sudo dnf install ./artcraft-launcher-…rpm` |
| `artcraft-launcher-<ver>-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-<ver>-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-<ver>-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-<ver>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
artcraft-launcher
```
**Web (WebAssembly)**
| File | What it is |
|---|---|
| `artcraft-launcher-web-<ver>.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 |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Releases](https://github.com/storytold/wordcraft/releases) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Releases](https://github.com/storytold/deckcraft/releases) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Releases](https://github.com/storytold/cadcraft/releases) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **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.
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
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.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Craft App Suite</a> ·
<a href="https://github.com/storytold/craft-launcher">ArtCraft Launcher</a>
</p>
---
## 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.
<sub>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.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>

View File

@ -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"] }

View File

@ -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

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ArtCraft Launcher</title>
<link data-trunk rel="rust" data-bin="artcraft-launcher-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #0e1014; }
@media (prefers-color-scheme: light) { html, body { background: #f4f5f8; } }
#launcher_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#launcher_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #9aa1b0; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
</head>
<body>
<canvas id="launcher_canvas"></canvas>
<div id="launcher_loading">Loading ArtCraft Launcher&hellip;</div>
</body>
</html>

View File

@ -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.");
}

View File

@ -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::<web_sys::HtmlCanvasElement>().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<RefCell<Vec<Event>>>,
ctx: egui::Context,
platform: Option<Platform>,
checking: Rc<RefCell<bool>>,
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<LatestRelease, Option<i64>> {
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<Release> = 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<Platform> {
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<Event> {
std::mem::take(&mut *self.events.borrow_mut())
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: false, reveal_label: "" }
}
fn platform(&self) -> Option<Platform> {
self.platform
}
fn now(&self) -> i64 {
(js_sys::Date::now() / 1000.0) as i64
}
}

View File

@ -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"

View File

@ -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}");
}
}

View File

@ -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<OnceLock<Box<dyn Fn() + Send + Sync>>>;
fn main() -> ExitCode {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let args: Vec<String> = 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<bool> {
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<tray::Tray>,
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()
}
}

View File

@ -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<TrayAction>,
tooltip: String,
}
fn load_icon() -> Result<tray_icon::Icon, String> {
// 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<Self, String> {
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<TrayAction>, Receiver<TrayAction>) = 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<TrayAction> {
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)
}
}

View File

@ -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.

39
assets/app-icon/README.md Normal file
View File

@ -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/<size>/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`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- ArtCraft Launcher: the Craft App Suite as a grid of app-coloured tiles, with a launch arrow in the middle. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#26282e"/>
<stop offset="1" stop-color="#0b0b0c"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.18"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<g>
<rect x="76" y="76" width="104" height="104" rx="26" fill="#2f7bf5"/>
<rect x="204" y="76" width="104" height="104" rx="26" fill="#e8573f"/>
<rect x="332" y="76" width="104" height="104" rx="26" fill="#8b5cf6"/>
<rect x="76" y="204" width="104" height="104" rx="26" fill="#e0368f"/>
<rect x="204" y="204" width="104" height="104" rx="26" fill="#efe9dc"/>
<path d="M256.0 286.0V230.0M230.0 254.0L256.0 228.0L282.0 254.0" fill="none" stroke="#0b0b0c" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="332" y="204" width="104" height="104" rx="26" fill="#f2a516"/>
<rect x="76" y="332" width="104" height="104" rx="26" fill="#7bb51c"/>
<rect x="204" y="332" width="104" height="104" rx="26" fill="#1f9d55"/>
<rect x="332" y="332" width="104" height="104" rx="26" fill="#12a58a"/>
</g>
<rect x="2" y="2" width="508" height="508" rx="110" fill="none" stroke="url(#sheen)" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- ArtCraft Launcher: the Craft App Suite as a grid of app-coloured tiles, with a launch arrow in the middle. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#26282e"/>
<stop offset="1" stop-color="#0b0b0c"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.18"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<g>
<rect x="76" y="76" width="104" height="104" rx="26" fill="#2f7bf5"/>
<rect x="204" y="76" width="104" height="104" rx="26" fill="#e8573f"/>
<rect x="332" y="76" width="104" height="104" rx="26" fill="#8b5cf6"/>
<rect x="76" y="204" width="104" height="104" rx="26" fill="#e0368f"/>
<rect x="204" y="204" width="104" height="104" rx="26" fill="#efe9dc"/>
<path d="M256.0 286.0V230.0M230.0 254.0L256.0 228.0L282.0 254.0" fill="none" stroke="#0b0b0c" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="332" y="204" width="104" height="104" rx="26" fill="#f2a516"/>
<rect x="76" y="332" width="104" height="104" rx="26" fill="#7bb51c"/>
<rect x="204" y="332" width="104" height="104" rx="26" fill="#1f9d55"/>
<rect x="332" y="332" width="104" height="104" rx="26" fill="#12a58a"/>
</g>
<rect x="2" y="2" width="508" height="508" rx="110" fill="none" stroke="url(#sheen)" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="2 2 40 40">
<rect x="4" y="4" width="16" height="16" rx="4" fill="#2f7bf5"/>
<rect x="24" y="4" width="16" height="16" rx="4" fill="#8b5cf6"/>
<path d="M32 19V6M26 12L32 6L38 12" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="4" y="24" width="16" height="16" rx="4" fill="#e8573f"/>
<rect x="24" y="24" width="16" height="16" rx="4" fill="#12a58a"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44">
<rect x="4" y="4" width="16" height="16" rx="4" fill="#000"/>
<path d="M32 19V6M26 12L32 6L38 12" fill="none" stroke="#000" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="4" y="24" width="16" height="16" rx="4" fill="#000"/>
<rect x="24" y="24" width="16" height="16" rx="4" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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.

4
clippy.toml Normal file
View File

@ -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

16
crates/core/Cargo.toml Normal file
View File

@ -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 }

175
crates/core/src/catalog.rs Normal file
View File

@ -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.<id>`: 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<String> {
(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<Item = &'static str> + '_ {
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::<Vec<_>>(), ["pdfcraft", "printcraft"]);
assert_eq!(pdf.website().as_deref(), Some("https://getartcraft.com/apps/pdfcraft"));
assert_eq!(CraftApp::by_id("wordcraft").unwrap().website(), None);
}
}

20
crates/core/src/lib.rs Normal file
View File

@ -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};

276
crates/core/src/platform.rs Normal file
View File

@ -0,0 +1,276 @@
//! Platforms, package formats, and choosing the right release asset.
//!
//! Every Craft App release names its files `<app>-<version>-<os>-<arch>[-portable].<ext>`, 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/<app>`, `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<Self> {
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 `<stem>-<version>-<os>-<arch>[-portable].<ext>`; `None` for checksums, zsync files, CLI
/// and web bundles, or anything else.
pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option<AssetName> {
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 = <version>-<os>-<arch>; 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"));
}
}

148
crates/core/src/protocol.rs Normal file
View File

@ -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<PackageKind>,
/// 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<Installed>,
},
Progress {
app: String,
phase: JobPhase,
},
/// A job ended: `Ok` carries a short success message, `Err` what went wrong.
JobFinished {
app: String,
result: Result<String, String>,
},
CheckStarted,
CheckFinished {
/// Unix seconds.
at: i64,
source: Source,
/// GitHub refused more API calls until this time (Unix seconds).
rate_limited_until: Option<i64>,
},
}
/// 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<Event>;
fn capabilities(&self) -> Capabilities;
/// The machine apps would be installed on (on the web: the visitor's, best guess).
fn platform(&self) -> Option<Platform>;
/// Unix seconds now.
fn now(&self) -> i64;
}

264
crates/core/src/release.rs Normal file
View File

@ -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/<repo>/releases/download/<tag>/<name>`.
pub url: String,
/// Lowercase hex SHA-256, when GitHub or `SHA256SUMS.txt` told us.
pub sha256: Option<String>,
}
/// A published release of one app.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Release {
pub tag: String,
pub version: Option<Version>,
/// `PhotoCraft v0.5.0`
pub name: String,
/// Unix seconds; `None` when only the tag is known.
pub published_at: Option<i64>,
pub html_url: String,
/// Release notes (Markdown), capped.
pub notes: String,
pub prerelease: bool,
pub assets: Vec<Asset>,
}
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/<repo>/releases/latest` (or one element of `/releases`).
pub fn parse_github_release(json: &str, repo: &str) -> Result<Release, String> {
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/<repo>/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<Option<Release>, 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<Release> = 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<Release, String> {
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<Asset> = 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/<repo>/releases/tag/<tag>` redirect target.
pub fn tag_from_redirect(location: &str, repo: &str) -> Option<String> {
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: `<hex> <name>` (or `<hex> *<name>`) 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("<html>", 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"));
}
}

139
crates/core/src/status.rs Normal file
View File

@ -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<Version>,
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());
}
}

111
crates/core/src/time.rs Normal file
View File

@ -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<i64> {
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<i64> {
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");
}
}

View File

@ -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<Version> {
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")));
}
}

37
crates/native/Cargo.toml Normal file
View File

@ -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"

View File

@ -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<dyn Fn() + Send + Sync>;
struct Shared {
events: Mutex<Vec<Event>>,
waker: Waker,
latest: Mutex<HashMap<String, Release>>,
installed: Mutex<HashMap<String, Installed>>,
jobs: Mutex<HashMap<String, Arc<AtomicBool>>>,
manifest: Mutex<Manifest>,
options: Mutex<BackendOptions>,
checking: AtomicBool,
last_check: Mutex<Option<i64>>,
}
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<Shared>,
paths: Paths,
http: Option<Http>,
source: Option<Arc<dyn ReleaseSource>>,
platform: Option<Platform>,
}
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<Arc<dyn ReleaseSource>> =
http.clone().map(|h| Arc::new(GitHubSource::new(h, paths.release_cache())) as Arc<dyn ReleaseSource>);
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::<Vec<&CraftApp>>());
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, &current)
{
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<Installed> {
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<Platform>,
options: &BackendOptions,
existing: Option<&Installed>,
record: Option<&crate::manifest::Record>,
cancel: &AtomicBool,
) -> Result<String, String> {
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<Platform> {
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<Event> {
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<Platform> {
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<String> {
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())
}

285
crates/native/src/detect.rs Normal file
View File

@ -0,0 +1,285 @@
//! Finding Craft Apps that are already installed, however they got there.
//!
//! - macOS: `<App>.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/<app>`.
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<Installed> {
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<Installed> {
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<PathBuf> {
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<Installed> {
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<Installed> {
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;
}
// `<App>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<artcraft_launcher_core::version::Version>,
pub exe: Option<PathBuf>,
}
/// Every Windows Installer entry for `app`, newest first.
pub fn msi_installs(app: &CraftApp) -> Vec<MsiInstall> {
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::<String, _>("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<PathBuf>) -> Option<PathBuf> {
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::<String, _>("")
{
let p = PathBuf::from(path.trim_matches('"'));
if p.exists() {
return Some(p);
}
}
}
let mut dirs: Vec<PathBuf> = 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<Installed> {
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<PathBuf> {
dirs::home_dir().map(|h| h.join("Applications"))
}
/// AppImages of `app` in `~/Applications`, newest first.
pub fn appimages(app: &CraftApp) -> Vec<(PathBuf, Option<version::Version>)> {
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<version::Version>)> = 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<Installed> {
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<Installed> {
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() })
}
/// `<app> --version` prints `<app> 0.5.0 (sha, date)`.
fn binary_version(bin: &Path, id: &str) -> Option<version::Version> {
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()?)
}
}

219
crates/native/src/http.rs Normal file
View File

@ -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<String>,
},
/// `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<i64>,
},
}
#[derive(Clone)]
pub struct Http {
small: ureq::Agent,
no_redirect: ureq::Agent,
download: ureq::Agent,
token: Option<String>,
}
impl Http {
pub fn new() -> Result<Self, String> {
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<Fetched, String> {
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::<i64>().ok());
let retry_after = header("retry-after").and_then(|r| r.parse::<i64>().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<Option<String>, 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<Option<String>, 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::<u64>().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<ureq::tls::RootCerts, String> {
let found = rustls_native_certs::load_native_certs();
let certs: Vec<ureq::tls::Certificate<'static>> = 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<String, String> {
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)
}

View File

@ -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\<App>`.
//! - 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<Option<Record>, 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<PathBuf>, 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<PackageKind> {
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<PackageKind> = 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<PackageKind> {
#[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<PathBuf, String> {
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<PathBuf, String> {
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/<id>`, `<id>` or `<id>.exe`.
#[allow(dead_code)]
fn find_binary(root: &Path, app: &CraftApp) -> Option<PathBuf> {
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<PathBuf> {
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<PackageKind> {
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<PathBuf, String> {
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<PathBuf> = 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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<String> {
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/<app>`,
/// `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<PathBuf, String> {
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<PathBuf> {
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<PathBuf>, 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<PathBuf>, 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<i32>) -> 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;
}
}

26
crates/native/src/lib.rs Normal file
View File

@ -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))
}

113
crates/native/src/login.rs Normal file
View File

@ -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<PathBuf, String> {
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<PathBuf, String> {
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
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::<String, _>("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<String> = 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!(" <string>{}</string>\n", xml_escape(a))).collect();
let plist = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>{ID}</string>\n <key>ProgramArguments</key>\n <array>\n{args_xml} </array>\n <key>RunAtLoad</key>\n <true/>\n</dict>\n</plist>\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> {
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<PathBuf, String> {
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())),
}
}

View File

@ -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<PathBuf>,
/// 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<String, Record>,
}
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());
}
}

View File

@ -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<Self, String> {
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: `<data>/apps/<app>`.
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<PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
dir.join("portable.txt").is_file().then(|| dir.join("ArtCraftLauncherData"))
}

View File

@ -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<std::ffi::OsStr>) -> 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<String> {
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()
}
}

View File

@ -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<std::sync::OnceLock<Box<dyn Fn() + Send + Sync>>>) -> 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),
},
}
}

181
crates/native/src/source.rs Normal file
View File

@ -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<LatestRelease>;
/// API refused until this time (Unix seconds).
fn rate_limited_until(&self) -> Option<i64>;
/// Persist anything worth keeping.
fn save(&self);
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct Entry {
etag: Option<String>,
/// `None`: the repo has no releases.
release: Option<Release>,
prereleases: bool,
fetched_at: i64,
}
#[derive(Default, Serialize, Deserialize)]
struct CacheFile {
entries: BTreeMap<String, Entry>,
}
pub struct GitHubSource {
http: Http,
cache: Mutex<CacheFile>,
cache_path: std::path::PathBuf,
limited_until: Mutex<Option<i64>>,
}
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::<CacheFile>(&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<Entry> {
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<Option<Answer>, 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<Release>| 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<Answer, String> {
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<LatestRelease> {
self.entry(repo, prereleases).map(|e| e.release.map_or(LatestRelease::None, LatestRelease::Found))
}
fn rate_limited_until(&self) -> Option<i64> {
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}"),
}
}
}

23
crates/ui-egui/Cargo.toml Normal file
View File

@ -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"] }

385
crates/ui-egui/src/app.rs Normal file
View File

@ -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<bool> {
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<dyn Backend>,
pub settings: Settings,
pub(crate) entries: Vec<AppEntry>,
pub(crate) caps: Capabilities,
pub(crate) platform: Option<Platform>,
pub(crate) checking: bool,
pub(crate) last_source: Option<Source>,
pub(crate) rate_limited_until: Option<i64>,
pub(crate) launcher_update: Option<Release>,
pub(crate) search: String,
pub(crate) toasts: Vec<Toast>,
pub(crate) dialog: Option<Dialog>,
started: bool,
applied_theme: Option<theme::ThemeChoice>,
/// 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<BackendOptions>,
}
impl LauncherUi {
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, 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<String>, 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::<Vec<_>>().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);
}
},
);
}
}

View File

@ -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<egui::ImageSource<'static>>, 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
}
}

View File

@ -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<ImageSource<'static>> {
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")
}

22
crates/ui-egui/src/lib.rs Normal file
View File

@ -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;

101
crates/ui-egui/src/model.rs Normal file
View File

@ -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<LatestRelease>,
pub installed: Option<Installed>,
/// Detection has answered at least once.
pub detected: bool,
pub job: Option<JobPhase>,
}
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<Platform>) -> 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<Platform>, 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<String> {
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<String> {
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,
}
}

View File

@ -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"));
}
}

View File

@ -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<i64> {
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<PackageKind>,
/// Updates already announced, so each version is announced once: app id → version.
pub notified: BTreeMap<String, String>,
/// Unix seconds of the last finished check.
pub last_check: Option<i64>,
}
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,
}
}
}

189
crates/ui-egui/src/theme.rs Normal file
View File

@ -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<String> = 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);
}

586
crates/ui-egui/src/views.rs Normal file
View File

@ -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<String> {
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));
}

View File

@ -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<T: PartialEq + Copy>(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<Color32>) -> 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<f32>, 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))
}
}

186
crates/ui-egui/tests/ui.rs Normal file
View File

@ -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=<dir>` 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<Event>,
sent: Rc<RefCell<Vec<Command>>>,
manage: bool,
}
impl Backend for Fake {
fn send(&mut self, command: Command) {
self.sent.borrow_mut().push(command);
}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder" }
}
fn platform(&self) -> Option<Platform> {
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<Event> {
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<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
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<LauncherUi>| {
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<LauncherUi>>, 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());
}

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Some files were not shown because too many files have changed in this diff Show More