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>
|
|
@ -0,0 +1,2 @@
|
|||
[alias]
|
||||
xtask = "run -p xtask --"
|
||||
|
|
@ -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
|
||||
|
|
@ -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$'
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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'
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
*~
|
||||
plan/
|
||||
plans/
|
||||
|
||||
/target
|
||||
target/
|
||||
/dist
|
||||
/temp
|
||||
/craft-fonts
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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) |
|
||||
|
|
@ -0,0 +1 @@
|
|||
Read [AGENTS.md](AGENTS.md) first: it is the guide for every agent and contributor working in this repository.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
@ -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
|
||||
|
|
@ -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…</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 679 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 449 B |
|
|
@ -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 |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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, ¤t)
|
||||
{
|
||||
shared.emit(Event::LauncherUpdate(r));
|
||||
}
|
||||
source.save();
|
||||
let sources = sources.into_inner().unwrap_or_else(PoisonError::into_inner);
|
||||
let source_used = if sources.contains(&Source::Cache) {
|
||||
Source::Cache
|
||||
} else if sources.contains(&Source::GitHubPages) {
|
||||
Source::GitHubPages
|
||||
} else {
|
||||
Source::GitHubApi
|
||||
};
|
||||
let at = crate::now();
|
||||
*shared.last_check.lock().unwrap_or_else(PoisonError::into_inner) = Some(at);
|
||||
shared.checking.store(false, Ordering::SeqCst);
|
||||
shared.emit(Event::CheckFinished { at, source: source_used, rate_limited_until: source.rate_limited_until() });
|
||||
});
|
||||
}
|
||||
|
||||
fn start_job(&self, app_id: &str, uninstall: bool) {
|
||||
let Some(app) = CraftApp::by_id(app_id) else { return };
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut jobs = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if jobs.contains_key(app.id) {
|
||||
return;
|
||||
}
|
||||
jobs.insert(app.id.to_string(), cancel.clone());
|
||||
}
|
||||
let shared = self.shared.clone();
|
||||
let paths = self.paths.clone();
|
||||
let http = self.http.clone();
|
||||
let platform = self.platform;
|
||||
let options = self.options();
|
||||
std::thread::spawn(move || {
|
||||
let existing = shared.installed.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned();
|
||||
let record = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).apps.get(app.id).cloned();
|
||||
let result = if uninstall {
|
||||
shared.emit(Event::Progress { app: app.id.to_string(), phase: JobPhase::Removing });
|
||||
match &existing {
|
||||
Some(inst) => install::uninstall(app, inst, record.as_ref()).map(|()| {
|
||||
let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if m.apps.remove(app.id).is_some() {
|
||||
let _ = m.save(&paths.manifest());
|
||||
}
|
||||
format!("{} was removed", app.name)
|
||||
}),
|
||||
None => Err(format!("{} isn't installed", app.name)),
|
||||
}
|
||||
} else {
|
||||
run_install(app, &shared, &paths, http.as_ref(), platform, &options, existing.as_ref(), record.as_ref(), &cancel)
|
||||
};
|
||||
// Look again: the result is whatever is on disk now.
|
||||
let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
|
||||
let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take());
|
||||
{
|
||||
let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match &found {
|
||||
Some(i) => map.insert(app.id.to_string(), i.clone()),
|
||||
None => map.remove(app.id),
|
||||
};
|
||||
}
|
||||
shared.emit(Event::Detected { app: app.id.to_string(), installed: found });
|
||||
shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).remove(app.id);
|
||||
shared.emit(Event::JobFinished { app: app.id.to_string(), result });
|
||||
});
|
||||
}
|
||||
|
||||
fn installed(&self, app: &str) -> Option<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())
|
||||
}
|
||||
|
|
@ -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()?)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
return agent().is_ok_and(|p| p.exists());
|
||||
#[cfg(windows)]
|
||||
return windows_run().and_then(|k| k.get_value::<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())),
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 26 KiB |