Compare commits

..

4 Commits

Author SHA1 Message Date
Brandon Thomas 8c026a9965 Restyle the release feed's notices and alerts for the new theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:15:38 -04:00
Brandon Thomas 3d92df6423 Rate-limit calls to the ArtCraft API; back off hard on HTTP 429
Every launcher polls the same server, so a FeedThrottle now gates each request:
- at most one a minute once the first load is over;
- up to ten a minute during the first load, front-loaded, with the gaps doubling after each
  failure (the first load ends at the first answer or after ten requests);
- failures back off 2, 4, 8 … minutes (at most an hour), with jitter;
- HTTP 429 waits at least 15 minutes, doubling per 429 in a row up to six hours, never sooner
  than Retry-After, plus up to 50% jitter so a herd of clients doesn't return in step. The
  backoff is saved to cache/feed-throttle.json, so relaunching doesn't skip it.

A check that would have to wait more than ten seconds goes without the feed this time, and the
saved answer stands in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:14:47 -04:00
Brandon Thomas b2abc6cac6 Footer names the services in use; retry the ArtCraft API before GitHub
Footer: "Checked just now · using ArtCraft API", "· using GitHub" or
"· using ArtCraft API and GitHub", from where each release on screen came
from (Latest events now carry their origin), so it follows what's shown.

Retries and fallback:
- The very first poll (nothing ever loaded or saved) tries the ArtCraft
  API once, then GitHub at once, so the window fills
- Every other poll tries the API up to three times (1 s, then 3 s apart)
- Until every app has loaded once, a failing API lets GitHub be asked
  fairly freely (every 5 minutes per app)
- After that, GitHub is asked only for apps the API doesn't list or
  hasn't refreshed in a day, or once the API has seriously failed (three
  checks in a row and an hour without an answer); then hourly per app.
  Until then the API's last answer keeps being shown

Tests: the fallback policy for each stage, and the footer wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:14:47 -04:00
Brandon Thomas 7287ec978e Use ArtCraft's release feed; GitHub only as the fallback
The launcher now asks storyteller-web
(GET https://api.storyteller.ai/v1/craft_apps/release_info), which polls
GitHub for every Craft App on a schedule, instead of polling GitHub itself.

- At launch and every five minutes: one request (10 s timeout) for every
  app's latest release, plus renames and notices
- Per app, GitHub is asked only when the feed can't vouch for it: the app
  isn't listed, its entry is more than 24 hours old, or the feed didn't
  answer. Even then, at most once an hour per app (Refresh asks at once).
  With pre-releases on, GitHub decides (the feed serves stable releases)
- Read leniently (crates/core/src/feed.rs): every field optional, unknown
  fields ignored, wrong types read as missing, one bad entry never spoils
  the rest. Download links must still point at the app's own GitHub
  releases; a moved repo counts only when the feed's repo_renames says so
- The last good answer is saved for an instant first paint and stands in
  (while still fresh) when the feed can't be reached
- Announcements show inline until dismissed; alerts pop up until the user
  presses OK (with "Open link" for web links); app renames show as
  announcements; dismissals persist
- "Check for updates": Automatically (every five minutes) or Only when I
  ask; older saved choices load as Automatically
- ARTCRAFT_LAUNCHER_FEED_URL points at a staging server or a local copy

Tests: the feed parser against hostile input, renames and notices; a
contract test against a response produced by storyteller-web's own code;
the feed-first rule with a counting fake GitHub; the notices UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:14:40 -04:00
100 changed files with 1623 additions and 17116 deletions

View File

@ -27,7 +27,7 @@ jobs:
# are kept for release builds, and release.yml builds and checks macOS.
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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.
@ -42,7 +42,7 @@ jobs:
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@v2
- name: Format
if: runner.os == 'Linux'
@ -60,12 +60,12 @@ jobs:
env:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
components: clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@v2
with:
key: wasm32
- name: Check wasm-safe crates

View File

@ -25,5 +25,35 @@ permissions:
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
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$'

View File

@ -19,5 +19,61 @@ concurrency:
jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
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/windows/artcraft-launcher.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
if grep -q "<key>LSUIElement</key>" 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 docs/brand/artcraft-mark-black-44.png \
docs/brand/artcraft-mark-64.png docs/brand/artcraft-logo.svg docs/brand/artcraft-logo-white.svg \
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

View File

@ -1,3 +1,12 @@
# 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:
@ -11,32 +20,389 @@ on:
default: ""
permissions:
contents: write
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
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/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:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: craft-launcher
app-title: ArtCraft Launcher
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
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 }}
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"

View File

@ -23,12 +23,92 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: craft-launcher
app-dir: ArtCraft Launcher
is-per-user: true
main-binary: artcraft-launcher.exe
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
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/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
$startLnk = Join-Path ([Environment]::GetFolderPath('Programs')) 'ArtCraft Launcher.lnk'
$deskLnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'ArtCraft Launcher.lnk'
$key = 'HKCU:\Software\Learning Machines LLC\ArtCraft Launcher'
function Install([string[]] $props) {
$p = Start-Process msiexec.exe -ArgumentList (@('/i', "`"$msi`"", '/qn', '/l*v', 'install.log') + $props) -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
}
function Uninstall([string] $exe) {
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
foreach ($left in $exe, $startLnk, $deskLnk) { if (Test-Path $left) { throw "uninstall left $left behind" } }
if ((Get-ItemProperty $key -ErrorAction SilentlyContinue).InstallDir) { throw "uninstall left InstallDir in $key" }
Write-Output 'ok uninstalled'
}
function Expect([bool] $ok, [string] $what) { if (-not $ok) { Get-Content install.log -Tail 60; throw $what }; Write-Output "ok $what" }
# 1. A first install with the defaults, as the wizard's Next, Next, Install does:
# %LOCALAPPDATA%\Programs\ArtCraft Launcher, with the Start Menu and desktop shortcuts.
Install @()
$exe = Join-Path $env:LOCALAPPDATA 'Programs\ArtCraft Launcher\artcraft-launcher.exe'
Expect (Test-Path $exe) "installed in the default folder: $exe"
$bytes = [System.IO.File]::ReadAllBytes($exe)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
Expect ($machine -eq 0xAA64) "the exe is ARM64 (machine 0x$('{0:X}' -f $machine))"
Expect (Test-Path $startLnk) "Start Menu shortcut: $startLnk"
Expect (Test-Path $deskLnk) "desktop shortcut (ticked by default): $deskLnk"
Expect ((Get-ItemProperty $key).DesktopShortcutChoice -eq '1') 'the desktop-shortcut choice is recorded as 1'
Uninstall $exe
# 2. A folder of the user's choosing, without the desktop shortcut.
$dir = Join-Path $env:LOCALAPPDATA 'ArtCraft Launcher Test Folder'
Install @("INSTALLFOLDER=`"$dir\`"", 'INSTALLDESKTOPSHORTCUT=0')
$exe = Join-Path $dir 'artcraft-launcher.exe'
Expect (Test-Path $exe) "installed in the chosen folder: $exe"
Expect ((Get-ItemProperty $key).InstallDir.TrimEnd('\') -eq $dir) 'the chosen folder is recorded for updates and self-update'
Expect (Test-Path $startLnk) "Start Menu shortcut: $startLnk"
Expect (-not (Test-Path $deskLnk)) 'no desktop shortcut when it is not wanted'
Expect ((Get-ItemProperty $key).DesktopShortcutChoice -eq '0') 'the desktop-shortcut choice is recorded as 0'
Uninstall $exe
Expect (-not (Test-Path $dir)) 'uninstall removed the chosen folder'

View File

@ -2,7 +2,7 @@
ArtCraft Launcher installs, updates and launches the **Craft App Suite** (PhotoCraft, LightCraft,
FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft,
CADCraft, SoundCraft, FigCraft). It is written in **Rust only** (no JavaScript or TypeScript): the desktop
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.
@ -58,20 +58,9 @@ settings file must produce an error the user can act on, never a panic. Shared s
### Installing other people's software safely
- Download only from `https://github.com/storytold/<app>/releases` (and GitHub's asset CDN).
The one exception is a developer's own loopback mirror (`ARTCRAFT_LAUNCHER_DOWNLOAD_BASE`,
`cargo xtask local`); keep it loopback-only and behind the `local-mirror` cargo feature, which
release builds never enable.
- Verify each download against the release's `SHA256SUMS.txt` before running or installing it.
- The details drawer's pictures are the one other download: JPEGs only, from a pinned tag of
`https://raw.githubusercontent.com/storytold/craft-launcher-assets/` (`core::details`),
capped at 2 MB each, cached, and never run. Pictures are not bundled in this repo.
- 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.
- **Agents never land or approve a third-party app.** A change that adds a catalog row, download
source or install path for an app whose repo is not under `storytold/`, or that loosens the
rules above to allow one, is a maintainer's call alone. An agent reviewing it flags it at the
top of its review (who owns the repo, its age and history, whether the builds are signed) and
stops there: no merge, no approval, no fixes pushed to the PR.
### Look and feel
@ -80,23 +69,6 @@ hard square corners, 1 px hairlines instead of shadows, Archivo (620, 118 % widt
and app names, Inter for body text, upper-case Geist Mono labels and buttons, and the site's
colour tokens by name (`theme::Tokens`). Build new UI from `widgets.rs`; never hard-code a colour.
Animations follow the OS: with Windows' "Animation effects" off, macOS' "Reduce motion" on, a Linux
desktop's animations off or a browser's `prefers-reduced-motion`, nothing fades, wipes or glides
(`Host::reduce_motion`, `crates/ui-egui/src/motion.rs`). Time a new animation with egui's
`style.animation_time`, or switch it off on `LauncherUi::reduce_motion`. Progress bars and
spinners keep moving.
### Languages
Every user-facing string goes through `crates/ui-egui/src/i18n` (`tl!("…")` for literals, `fmt`
for `{placeholders}`, `tn` for counts). English in the code is the lookup key; adding a string
means adding its row to every catalog marked `complete_menus` (the `*.tsv` files in
`crates/ui-egui/src/i18n/`), which the i18n tests check. Translate clean-room from the meaning of
the English text, never through another catalog. Product and app names (ArtCraft Launcher, Craft
App Suite, PhotoCraft…) stay in English.
A CJK language is drawn with the system's own CJK font (`crates/ui-egui/src/cjk_font.rs`, desktop
only; English where there is none): never bundle a CJK font, they are 10 MB and more.
### Other rules
1. **Assets need attribution.** Every bundled asset gets a row in `ATTRIBUTION.md` (path, title,
@ -138,7 +110,7 @@ Release assets (keep these names exact; the launcher's own self-update and the R
|---|---|
| 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}`, `artcraft-launcher-<ver>-linux-riscv64.{deb,rpm,tar.gz}` |
| 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` |
@ -158,16 +130,6 @@ Packaging facts the code relies on:
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).
- Inside the Flatpak launcher (`process::in_sandbox()`), apps normally install as host Flatpaks
and run through `flatpak-spawn --host`. When the host has no usable Flatpak — none installed,
or `flatpak-spawn` not allowed — `linux::order_available_kinds` offers the release tarball
instead: it is unpacked into `<data>/apps/<app>` (`bin/`, `share/`) and run with `HOME` =
`<data>/apps/<app>/home`, so a Craft App's settings stay in the sandbox. An update swaps
`bin/` and `share/` and keeps `home/`; removing the app takes those two folders only. Outside
a sandbox the tarball is not on offer at all.
- `--socket=pulseaudio` in the launcher manifests is for an app running *inside* the sandbox
(SoundCraft's ALSA reaches the runtime's PulseAudio plugin through it); it also covers the
microphone, which SoundCraft records through.
- `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.

View File

@ -11,11 +11,9 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `assets/app-icon/` (all files) | ArtCraft Launcher app icon: the ArtCraft "A" mark in blue (getartcraft.com's favicon) | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` by `packaging/icons.sh`, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `packaging/macos/dmg/` (all files) | macOS DMG window background and layout | ArtCraft Launcher contributors, with the ArtCraft mark | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md); the mark is `docs/brand/artcraft-mark-black.svg`, linked from `background.svg` and rendered unmodified into `background.tiff` | MIT OR Apache-2.0, like the code; the ArtCraft mark in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)). The TIFF carries LittleCMS's built-in sRGB profile ("No copyright, use freely") |
| `assets/social/linkedin.svg` | LinkedIn logo, linking to ArtCraft's company page | Fonticons, Inc. ([Font Awesome Free](https://fontawesome.com) 6.7.2) | Font Awesome Free `svgs/brands/linkedin.svg`; white fill and a square view box, see [`assets/social/README.md`](assets/social/README.md) | CC BY 4.0. The logo is a trademark of LinkedIn Corporation. |
| `assets/social/x.svg`, `youtube.svg`, `tiktok.svg`, `instagram.svg` | X, YouTube, TikTok and Instagram logos, linking to ArtCraft's accounts | [Simple Icons](https://simpleicons.org/) contributors | Simple Icons, `fill` set to white for tinting; see [`assets/social/README.md`](assets/social/README.md) | CC0 1.0. The logos are trademarks of X Corp., Google LLC, TikTok / ByteDance and Meta Platforms, Inc. |
| `docs/screenshots/artcraft-launcher-grid.png`, `artcraft-launcher-table.png` | Screenshots of ArtCraft Launcher (macOS: grid view in dark theme, table view in light theme), used in the README | ArtCraft Launcher contributors | Captured from the app | MIT OR Apache-2.0 for the screenshot itself; the ArtCraft logo in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)) and the app icons in it keep their own licenses (above) |
| (not bundled) the pictures on each app's details page | Screenshots of the Craft Apps, fetched when a page is opened and kept in the launcher's cache folder | The apps' contributors | [storytold/craft-launcher-assets](https://github.com/storytold/craft-launcher-assets), pinned to a tag (`SCREENSHOTS_REV` in `crates/core/src/details.rs`); each picture's source and license are listed in that repository's `ATTRIBUTION.md` | Each app's own license for the interface (MIT OR Apache-2.0); the work shown in them is public domain or made in the app, as that file says |
| `docs/brand/artcraft-logo.svg`, `artcraft-logo-white.svg` | ArtCraft wordmark, shown in the window header | ArtCraft Team | Supplied brand files, unmodified (same as PhotoCraft's `docs/brand/`) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/brand/artcraft-mark-64.png`, `artcraft-mark-black-44.png` | ArtCraft "A" mark, the tray / menu-bar icon | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` and `artcraft-mark-black.svg` by `packaging/icons.sh` | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `assets/fonts/Inter-Variable.ttf` | Inter 4.001, variable (body text) | The Inter Project Authors (Rasmus Andersson) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/inter> (`Inter[opsz,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
@ -42,7 +40,6 @@ licensed like those apps. Refresh them from the source path, never redraw them.
| `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)) |
| `assets/app-icons/figcraft.png` | FigCraft app icon (chameleon), 128 px | ArtCraft Team and the FigCraft contributors | [`storytold/figcraft`](https://github.com/storytold/figcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.figcraft.png`, copied unmodified | MIT OR Apache-2.0 (FigCraft's [`LICENSE-MIT`](https://github.com/storytold/figcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/figcraft/blob/main/LICENSE-APACHE)) |
## Documentation and README

70
Cargo.lock generated
View File

@ -276,7 +276,7 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "artcraft-launcher"
version = "0.4.1"
version = "0.1.2"
dependencies = [
"artcraft-launcher-core",
"artcraft-launcher-native",
@ -290,17 +290,13 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"ron",
"serde",
"tray-icon",
"winreg",
"winresource",
"zbus",
]
[[package]]
name = "artcraft-launcher-core"
version = "0.4.1"
version = "0.1.2"
dependencies = [
"semver",
"serde",
@ -309,13 +305,14 @@ dependencies = [
[[package]]
name = "artcraft-launcher-native"
version = "0.4.1"
version = "0.1.2"
dependencies = [
"artcraft-launcher-core",
"dirs 6.0.0",
"flate2",
"log",
"plist",
"rustls-native-certs",
"serde",
"serde_json",
"sha2",
@ -327,7 +324,7 @@ dependencies = [
[[package]]
name = "artcraft-launcher-ui-egui"
version = "0.4.1"
version = "0.1.2"
dependencies = [
"artcraft-launcher-core",
"egui",
@ -336,14 +333,11 @@ dependencies = [
"image",
"log",
"serde",
"serde_json",
"skrifa",
"sys-locale",
]
[[package]]
name = "artcraft-launcher-web"
version = "0.4.1"
version = "0.1.2"
dependencies = [
"artcraft-launcher-core",
"artcraft-launcher-ui-egui",
@ -854,7 +848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@ -2285,8 +2279,6 @@ dependencies = [
"num-traits",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
@ -3933,33 +3925,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
@ -4377,15 +4342,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@ -4810,7 +4766,6 @@ dependencies = [
"percent-encoding",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
@ -5182,15 +5137,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@ -5803,7 +5749,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.4.1"
version = "0.1.2"
[[package]]
name = "yoke"

View File

@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.4.1"
version = "0.1.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"

View File

@ -12,7 +12,7 @@
<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, SoundCraft and FigCraft: the right build for your computer, kept up to date.
DeckCraft, GridCraft, CADCraft and SoundCraft: the right build for your computer, kept up to date.
</p>
<p align="center">
@ -47,10 +47,10 @@
</p>
<p align="center">
<img src="docs/screenshots/artcraft-launcher-grid.png" alt="ArtCraft Launcher on macOS in the grid view (dark theme): a card for each Craft App with its icon, status, version, release date and an Install or Open button" width="900">
<img src="docs/screenshots/artcraft-launcher-grid.png" alt="ArtCraft Launcher on macOS in the grid view (dark theme): a card for each Craft App with its icon, status, version, release date and an Install or Open button, grouped into classic and new apps" width="900">
</p>
<p align="center">
<img src="docs/screenshots/artcraft-launcher-table.png" alt="The same apps in the table view (light theme), with columns for each app's status, installed and latest versions, and release date" width="900"><br>
<img src="docs/screenshots/artcraft-launcher-table.png" alt="The same apps in the table view (light theme), with columns for what each app is inspired by, its status, installed and latest versions, and release date" width="900"><br>
<sub>Switch between the grid and a sortable table.</sub>
</p>
@ -82,27 +82,6 @@ you when an update is out. It is a small native app that can sit in your menu ba
The web version shows the same catalog in the browser, with the latest download for each app.
## Interface languages
The interface is in English, Brazilian Portuguese (Português (Brasil)), Czech (Čeština), French
(Français), German (Deutsch), Hungarian (Magyar), Japanese (日本語), Polish (Polski), Russian
(Русский), Simplified Chinese (简体中文) or Spanish (Español). **Auto**, the default, follows the
system or browser language; **Settings → Language** picks one and switches at once, and the choice
is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, German ones (`de-DE`,
`de-AT`, `de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones
(`hu`, `hu-HU`, `hu_HU.UTF-8`…) Hungarian, Japanese ones (`ja`, `ja-JP`, `ja_JP.UTF-8`…) Japanese,
Polish ones (`pl`, `pl-PL`, `pl_PL.UTF-8`) Polish, Spanish ones (`es-ES`, `es-MX`, `es-419`…)
Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese. Simplified
Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such as `zh_CN.UTF-8`) select
Chinese; Traditional Chinese ones don't. To try a language without changing the setting, start the
desktop app with `ARTCRAFT_LAUNCHER_LOCALE=pt-BR`, or add `?lang=ru` to the web address.
The CJK languages are drawn with the system's own CJK font (Hiragino Sans or PingFang on macOS,
Yu Gothic, Meiryo or Microsoft YaHei on Windows, Noto Sans CJK, IPA, Takao or WenQuanYi on Linux and
FreeBSD). Where the system has no font for one, and in the web version, the interface stays in
English and Settings says why. Product names and release notes stay as published; text without a
translation falls back to English.
## Where apps are installed
ArtCraft Launcher installs each app where your system normally keeps applications, using the
@ -114,7 +93,6 @@ installed by hand (and the launcher finds apps you installed yourself):
| 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` |
| Linux, inside the launcher's Flatpak, with no Flatpak on the host | `~/.var/app/ai.storyteller.artcraft-launcher/data/artcraft-launcher/apps/<app>` | The release tarball, unpacked into the launcher's own folder and run inside the same sandbox, with its settings in a `home/` folder beside it. Nothing is installed on the host, and each app's configuration stays in the sandbox |
| FreeBSD | `/usr/local` | The release tree, with a password prompt (polkit); `~/.local` when polkit isn't installed |
ArtCraft Launcher keeps itself current too: when a new version is out, **Update and restart**
@ -124,12 +102,6 @@ package or portable folder, the AppImage, or the `.deb`/`.rpm`) and reopens it.
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.
The launcher writes its log to `logs/artcraft-launcher.log` in its data folder
(`~/.local/share/artcraft-launcher` on Linux, `~/Library/Application Support/ArtCraft Launcher`
on macOS, `%LOCALAPPDATA%\ArtCraft Launcher` on Windows, `ArtCraftLauncherData` beside the
portable zip's executable) and keeps the previous two runs as `artcraft-launcher.1.log` and
`artcraft-launcher.2.log`. `RUST_LOG` changes what it records (`debug`, `info,zbus=warn`, …).
## 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`.
@ -156,11 +128,9 @@ Download the build for your computer from the [latest release](https://github.co
| `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 installs apps as Flatpaks on the host through `flatpak-spawn --host`; where the host has no Flatpak it falls back to each app's release tarball, unpacked and run inside the sandbox with the app's settings kept in it. Either way it can only reach the host as far as that permission allows; the AppImage, .deb and .rpm builds are the recommended way to run the launcher on Linux |
| `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 |
RISC-V (riscv64): `artcraft-launcher-<ver>-linux-riscv64.{deb,rpm,tar.gz}`, built against Ubuntu 24.04 (needs glibc 2.39+).
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)
@ -200,31 +170,12 @@ rustup target add wasm32-unknown-unknown
cd apps/artcraft-launcher-web && trunk serve
```
### Installing your own builds
To install and update the Craft apps from checkouts on your machine instead of GitHub, clone them
next to this repo (`../photocraft`, `../wordcraft`, …) and run:
```sh
cargo xtask local sync # git pull every app, build and package each new version into ../local-mirror
cargo xtask local serve # serve the builds and a release feed on http://127.0.0.1:8787/
ARTCRAFT_LAUNCHER_DOWNLOAD_BASE=http://127.0.0.1:8787/ cargo run --release -p artcraft-launcher --features local-mirror
```
Run `sync` again whenever you want updates, or let the server do it: `cargo xtask local serve
--sync-every 1h` pulls and builds on that interval while it serves. An app is rebuilt when its
checkout's version isn't in the mirror yet (`--rebuild` forces it), and the launcher's next check
picks it up. Add `--every-commit` to build every new commit instead, as a dev version between
releases (`0.4.0` builds as `0.4.1-dev.<commit time>`), which the launcher offers as an update. While
`ARTCRAFT_LAUNCHER_DOWNLOAD_BASE` is set (loopback addresses only), the launcher asks only the
mirror, never GitHub. Only builds with the `local-mirror` feature read it; releases never have it. `cargo xtask local help` lists the options (`--only`, `--no-pull`, `--arch`, …).
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, which app you were installing and the launcher's log file) or tell us on
> issues (include your OS and which app you were installing) or tell us on
> [Discord](https://discord.gg/artcraft).
## The Craft App Suite
@ -243,11 +194,10 @@ stand on its own.
| <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) | [Website](https://getartcraft.com/apps/wordcraft) |
| <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) | [Website](https://getartcraft.com/apps/deckcraft) |
| <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) | [Website](https://getartcraft.com/apps/cadcraft) |
| <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) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <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.
@ -313,6 +263,8 @@ are trademarks of the ArtCraft Team and are not covered by this license. They ma
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 either registered trademarks or trademarks of Adobe in the United States and/or other countries. Microsoft, Word, PowerPoint and Excel are trademarks of the Microsoft group of companies. Autodesk and AutoCAD are registered trademarks or trademarks of Autodesk, Inc. Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. All other trademarks are the property of their respective owners. These names are used only to describe what each Craft App is for; the Craft App Suite is independent and is not affiliated with, sponsored by or endorsed by any of these companies.</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>

View File

@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] }

View File

@ -3,72 +3,17 @@
use std::cell::RefCell;
use std::rc::Rc;
use artcraft_launcher_core::details::{AppDetails, MAX_SCREENSHOT_BYTES, looks_like_jpeg};
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, 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, i18n};
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
const SETTINGS_KEY: &str = "settings";
/// Tell the UI the browser's languages (`?lang=ru` in the address first, as `ARTCRAFT_LAUNCHER_LOCALE`
/// does on the desktop) before the first frame, so the Automatic interface language follows the
/// browser; and show the loading text in that language.
fn detect_language() {
let window = match web_sys::window() {
Some(w) => w,
None => return,
};
let document = match window.document() {
Some(d) => d,
None => return,
};
let mut tags = Vec::new();
// ?lang=xx in the URL takes priority.
if let Ok(href) = window.location().href()
&& let Some(idx) = href.find('?')
{
for pair in href[idx + 1..].split('&') {
if let Some((k, v)) = pair.split_once('=')
&& k == "lang"
&& !v.is_empty()
{
tags.push(v.to_string());
}
}
}
// Then the browser's preferred languages.
let navigator = window.navigator();
let langs: js_sys::Array = navigator.languages();
for i in 0..langs.length() {
if let Some(v) = langs.get(i).as_string() {
tags.push(v);
}
}
if let Some(lang) = navigator.language() {
tags.push(lang);
}
i18n::set_system_locales(&tags);
// Screen readers and spell-check follow the page language: match <html lang>
// to what was detected (`en` when the browser asks for nothing supported).
if let Some(root) = document.document_element() {
let _ = root.set_attribute("lang", i18n::system_lang().code());
}
// Update loading text in the detected language.
if let Some(el) = document.get_element_by_id("launcher_loading") {
el.set_text_content(Some(i18n::tr(i18n::system_lang(), "Loading ArtCraft Launcher…")));
}
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Warn).ok();
// Detect language before starting the UI.
detect_language();
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 {
@ -96,14 +41,8 @@ pub fn start() {
});
}
/// The browser: nothing of the desktop's, but it knows whether the visitor asked for fewer animations.
struct WebHost;
impl Host for WebHost {
fn reduce_motion(&self) -> bool {
web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()).is_some_and(|query| query.matches())
}
}
struct NoHost;
impl Host for NoHost {}
struct WebApp {
ui: LauncherUi,
@ -111,15 +50,11 @@ struct WebApp {
impl eframe::App for WebApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.ui.logic(ctx, &mut WebHost);
self.ui.logic(ctx, &mut NoHost);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.ui.ui(ui, &mut WebHost);
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
self.ui.raw_input(ctx, raw_input);
self.ui.ui(ui, &mut NoHost);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
@ -133,43 +68,11 @@ struct WebBackend {
platform: Option<Platform>,
checking: Rc<RefCell<bool>>,
prereleases: bool,
/// Apps whose details pictures are on their way (a second request meanwhile does nothing).
pictures_busy: Rc<RefCell<std::collections::HashSet<&'static str>>>,
}
impl WebBackend {
fn new(ctx: egui::Context) -> Self {
Self { events: Rc::default(), ctx, platform: visitor_platform(), checking: Rc::default(), prereleases: false, pictures_busy: Rc::default() }
}
/// Fetches the pictures of an app's details page, lead first. The browser keeps them in its own
/// cache, so there is nothing to save here.
fn fetch_screenshots(&self, app: &str) {
let Some(details) = AppDetails::by_id(app) else { return };
if !self.pictures_busy.borrow_mut().insert(details.id) {
return;
}
let (events, ctx, busy) = (self.events.clone(), self.ctx.clone(), self.pictures_busy.clone());
wasm_bindgen_futures::spawn_local(async move {
// Once the network has failed the rest would fail the same way: say so at once.
let mut offline: Option<String> = None;
for shot in details.shots {
let result = match (&offline, details.screenshot_url(shot.key)) {
(Some(why), _) => Err(why.clone()),
(None, Some(url)) => picture(&url).await.map_err(|failure| match failure {
PictureError::Network(why) => {
offline = Some(why.clone());
why
}
PictureError::Picture(why) => why,
}),
(None, None) => Err("this page has no such picture".to_string()),
};
events.borrow_mut().push(Event::Screenshot { app: details.id.to_string(), key: shot.key.to_string(), result });
ctx.request_repaint();
}
busy.borrow_mut().remove(details.id);
});
Self { events: Rc::default(), ctx, platform: visitor_platform(), checking: Rc::default(), prereleases: false }
}
fn check(&self) {
@ -208,49 +111,6 @@ impl WebBackend {
}
}
/// Why a picture couldn't be had.
enum PictureError {
/// This picture only: it isn't published, or what came back isn't a picture.
Picture(String),
/// The network or the server: the other pictures would fail the same way.
Network(String),
}
/// One picture of a details page. The size cap is checked on the announced length first and again
/// on what arrived; the bytes must start like a JPEG.
async fn picture(url: &str) -> Result<Vec<u8>, PictureError> {
let offline = |what: &str| PictureError::Network(format!("couldn't load a picture ({what})"));
let window = web_sys::window().ok_or_else(|| offline("no window"))?;
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(|_| offline("bad request"))?;
let resp: web_sys::Response = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| offline("no connection"))?
.dyn_into()
.map_err(|_| offline("no answer"))?;
match resp.status() {
200 => {}
404 => return Err(PictureError::Picture("this picture isn't published".into())),
status => return Err(PictureError::Network(format!("the server answered HTTP {status}"))),
}
let too_big = || PictureError::Picture(format!("a picture of more than {MAX_SCREENSHOT_BYTES} bytes"));
let announced = resp.headers().get("content-length").ok().flatten().and_then(|n| n.parse::<usize>().ok());
if announced.is_some_and(|n| n > MAX_SCREENSHOT_BYTES) {
return Err(too_big());
}
let buffer = JsFuture::from(resp.array_buffer().map_err(|_| offline("unreadable"))?).await.map_err(|_| offline("unreadable"))?;
let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
if bytes.len() > MAX_SCREENSHOT_BYTES {
return Err(too_big());
}
if !looks_like_jpeg(&bytes) {
return Err(PictureError::Picture("the answer isn't a JPEG picture".into()));
}
Ok(bytes)
}
/// 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 {
@ -298,13 +158,7 @@ fn visitor_platform() -> Option<Platform> {
} else {
return None;
};
let arch = if ua.contains("aarch64") || ua.contains("arm64") || ua.contains("armv8") {
Arch::Arm64
} else if ua.contains("riscv") {
Arch::Riscv64
} else {
Arch::X64
};
let arch = if ua.contains("aarch64") || ua.contains("arm64") || ua.contains("armv8") { Arch::Arm64 } else { Arch::X64 };
Some(Platform { os, arch })
}
@ -312,7 +166,6 @@ impl Backend for WebBackend {
fn send(&mut self, command: Command) {
match command {
Command::CheckForUpdates { .. } => self.check(),
Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
Command::Configure(o) => {
let changed = o.prereleases != self.prereleases;
self.prereleases = o.prereleases;

View File

@ -12,10 +12,6 @@ publish = false
[lints]
workspace = true
[features]
# Development only: install from `cargo xtask local serve` (see the README).
local-mirror = ["artcraft-launcher-native/local-mirror"]
[dependencies]
artcraft-launcher-core = { workspace = true }
artcraft-launcher-ui-egui = { workspace = true }
@ -26,36 +22,18 @@ 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"
# Read and write eframe's settings file while there is no window (settings_file.rs). Already in
# Cargo.lock through eframe's "persistence" feature.
ron = "0.12.2"
serde = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "5"
# 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"
# macOS only: which desktop (Space) the window shows on (spaces.rs) and the "Reduce motion"
# setting (motion.rs). Safe AppKit calls only, and already in Cargo.lock through winit and
# tray-icon; pinned exactly to those versions.
# macOS only: which desktop (Space) the window shows on (spaces.rs). Safe AppKit calls only, and
# already in Cargo.lock through winit and tray-icon; pinned exactly to those versions.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "=0.6.5"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSApplication", "NSResponder", "NSWindow", "NSWorkspace"] }
# Windows only: the "Animation effects" setting (motion.rs). Already in Cargo.lock through
# artcraft-launcher-native.
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
# Linux and the BSDs: the desktop's animations setting, from its settings portal (motion.rs).
# Already in Cargo.lock through tray-icon and notify-rust.
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
[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"] }

View File

@ -1,332 +0,0 @@
//! Where log records go: standard error and a log file.
//!
//! A launcher started from the Dock, the Start menu or at login has no terminal (and the Windows
//! build has no console at all), so without a file every `log::` record and the report of a panic
//! vanished. `env_logger` still formats and filters the records; its output goes to standard
//! error and to `logs/artcraft-launcher.log` in the launcher's data folder (`Paths::logs`). Each
//! start moves the previous log to `artcraft-launcher.1.log` (and that one to `.2`), so the log
//! of a run that crashed survives the next launch.
//!
//! Levels: `info` for the launcher's own crates, `warn` for everything else (wgpu and zbus are
//! chatty at `info`). `RUST_LOG` replaces that as before: `debug`, `info,zbus=warn`, ….
//!
//! Records logged before the file is attached are kept (up to [`MAX_PENDING`]) and written first.
//! Writing never panics: a file that can't be created or written leaves standard error as the
//! only sink. Never log a sign-in token.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "artcraft-launcher.log";
/// How many previous logs are kept (`artcraft-launcher.1.log` … `artcraft-launcher.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The filter when `RUST_LOG` is unset (env_logger matches `artcraft_launcher` as a prefix).
pub const DEFAULT_FILTER: &str = "warn,artcraft_launcher=info";
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("artcraft-launcher.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// Where formatted records go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `record` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, record: &str) {
if self.file.is_none() && !self.capped {
if self.pending.len() < MAX_PENDING {
self.pending.push(record.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(record);
}
/// Start writing to `file`, first the records kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for record in std::mem::take(&mut self.pending) {
self.append(&record);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// No file this run: stop keeping records for one.
pub fn no_file(&mut self) {
self.pending = Vec::new();
self.dropped = 0;
self.capped = true;
}
fn append(&mut self, record: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(record.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
record.to_owned()
};
// A full disk or a vanished file must not take the launcher down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// env_logger's output: each formatted record to standard error and to the [`Sink`].
struct Tee {
sink: Arc<Mutex<Sink>>,
stderr: bool,
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.stderr {
let _ = std::io::stderr().write_all(buf);
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&String::from_utf8_lossy(buf));
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if self.stderr {
let _ = std::io::stderr().flush();
}
Ok(())
}
}
/// The installed logger's file side.
pub struct Logs {
sink: Arc<Mutex<Sink>>,
}
impl Logs {
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
/// No log file this run (no data folder on this system).
pub fn no_file(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
}
}
/// An env_logger builder (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]) writing through a [`Tee`].
fn builder(sink: Arc<Mutex<Sink>>, stderr: bool) -> env_logger::Builder {
let mut b = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(DEFAULT_FILTER));
b.target(env_logger::Target::Pipe(Box::new(Tee { sink, stderr })));
b
}
/// Install the logger; `None` when another logger was installed first. Call
/// [`Logs::attach_dir`] (or [`Logs::no_file`]) once it is known whether this run keeps a log.
pub fn install() -> Option<Logs> {
let sink = Arc::new(Mutex::new(Sink::new(MAX_FILE_BYTES)));
builder(sink.clone(), true).try_init().ok()?;
Some(Logs { sink })
}
/// Report a panic through the logger (standard error and the log file), with a backtrace when
/// `RUST_BACKTRACE` asks for one; standard error alone when `RUST_LOG` turned errors off.
pub fn install_panic_hook() {
std::panic::set_hook(Box::new(|info| {
let trace = std::backtrace::Backtrace::capture();
let report = if trace.status() == std::backtrace::BacktraceStatus::Captured {
format!("internal error: {info}\n{trace}")
} else {
format!("internal error: {info}")
};
if log::log_enabled!(log::Level::Error) {
log::error!("{report}");
} else {
let _ = writeln!(std::io::stderr(), "artcraft-launcher: {report}");
}
}));
}
#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("artcraft-launcher-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap()
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).unwrap();
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).unwrap();
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("artcraft-launcher.1.log")), "run 3");
assert_eq!(read(&dir.join("artcraft-launcher.2.log")), "run 2");
assert!(!dir.join("artcraft-launcher.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::write(&dir, "a file where the directory should be").unwrap();
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn records_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
/// env_logger's formatted records reach the file: the launcher's info records and everyone's
/// warnings.
#[test]
fn env_logger_records_reach_the_attached_file() {
let dir = temp_dir("logger");
let sink = Arc::new(Mutex::new(Sink::new(MAX_FILE_BYTES)));
let logger = builder(sink.clone(), false).parse_filters(DEFAULT_FILTER).build();
let logs = Logs { sink };
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "artcraft_launcher", "before the file");
let path = logs.attach_dir(&dir).unwrap();
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Info, "artcraft_launcher_native::proxy", "proxy in use");
record(log::Level::Info, "zbus::connection", "too chatty");
record(log::Level::Debug, "artcraft_launcher", "below info");
let text = read(&path);
assert!(text.contains("artcraft_launcher] before the file\n"), "{text}");
assert!(text.contains("WARN wgpu_hal::vulkan] a real warning\n"), "{text}");
assert!(text.contains("artcraft_launcher_native::proxy] proxy in use\n"), "{text}");
assert!(!text.contains("too chatty") && !text.contains("below info") && !text.contains('\u{1b}'), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logs = Logs { sink: Arc::new(Mutex::new(Sink::new(MAX_FILE_BYTES))) };
let path = logs.attach_dir(&dir).unwrap();
let sink = logs.sink.clone();
let _ = std::thread::spawn(move || {
let _guard = sink.lock();
panic!("poison the sink");
})
.join();
assert!(logs.sink.is_poisoned());
let mut tee = Tee { sink: logs.sink.clone(), stderr: false };
tee.write_all(b"after the panic\n").unwrap();
assert!(read(&path).contains("after the panic\n"));
let _ = std::fs::remove_dir_all(&dir);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,113 +0,0 @@
//! Whether the OS asks for fewer animations: Windows' "Animation effects" switched off, macOS'
//! "Reduce motion", a Linux or BSD desktop's animations setting. The UI asks about once a second
//! while it draws (`Host::reduce_motion`), so a change shows without a restart.
/// Does the OS ask for fewer animations? Animations stay on where it can't be told.
#[cfg(windows)]
pub fn reduced() -> bool {
use winreg::RegKey;
use winreg::enums::HKEY_CURRENT_USER;
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Control Panel\Desktop")
.and_then(|desktop| desktop.get_raw_value("UserPreferencesMask"))
.is_ok_and(|mask| animations_off(&mask.bytes))
}
/// Windows keeps `SPI_GETCLIENTAREAANIMATION` (the switch in Settings, and what browsers follow
/// for `prefers-reduced-motion`) as bit 33 of `UserPreferencesMask`. Reading the registry stands
/// in for `SystemParametersInfoW`, which needs `unsafe`. A mask too short for the bit says nothing.
#[cfg(any(windows, test))]
fn animations_off(mask: &[u8]) -> bool {
mask.get(4).is_some_and(|byte| byte & 0b10 == 0)
}
/// Does the OS ask for fewer animations?
#[cfg(target_os = "macos")]
pub fn reduced() -> bool {
objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()
}
/// Does the desktop ask for fewer animations? What its settings portal last said: the first call
/// starts a thread that asks it and then follows its changes, as an answer over D-Bus can take a
/// while and the UI must not wait for one. Animations stay on until it has answered, and where
/// there is no portal.
#[cfg(all(unix, not(target_os = "macos")))]
pub fn reduced() -> bool {
use std::sync::Once;
use std::sync::atomic::Ordering;
static WATCH: Once = Once::new();
WATCH.call_once(|| {
std::thread::spawn(|| {
if let Err(e) = portal::watch() {
log::debug!("the desktop's animation setting can't be read: {e}");
}
});
});
portal::REDUCED.load(Ordering::Relaxed)
}
/// The XDG desktop portal's settings, which a sandboxed Flatpak can read as well.
#[cfg(all(unix, not(target_os = "macos")))]
mod portal {
use std::sync::atomic::{AtomicBool, Ordering};
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::{OwnedValue, Value};
pub static REDUCED: AtomicBool = AtomicBool::new(false);
/// Keeps [`REDUCED`] to what the portal says, for as long as the session bus is there.
pub fn watch() -> zbus::Result<()> {
let bus = Connection::session()?;
let settings = Proxy::new(&bus, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
let changes = settings.receive_signal("SettingChanged")?;
REDUCED.store(reduced(&settings), Ordering::Relaxed);
for _ in changes {
REDUCED.store(reduced(&settings), Ordering::Relaxed);
}
Ok(())
}
/// Any one of the settings desktops keep this in asking for fewer animations is enough: the
/// portal's own `reduced-motion`, GNOME's `enable-animations` (which desktops built on GTK
/// share), and KDE's animation speed, where 0 is "instant".
fn reduced(settings: &Proxy<'_>) -> bool {
let read = |namespace: &str, key: &str| {
// `ReadOne` is the newer call; older portals only have `Read`.
["ReadOne", "Read"].iter().find_map(|method| settings.call::<_, _, OwnedValue>(*method, &(namespace, key)).ok())
};
read("org.freedesktop.appearance", "reduced-motion").is_some_and(|v| matches!(plain(&v), Value::U32(1)))
|| read("org.gnome.desktop.interface", "enable-animations").is_some_and(|v| matches!(plain(&v), Value::Bool(false)))
|| read("org.kde.kdeglobals.KDE", "AnimationDurationFactor").is_some_and(|v| match plain(&v) {
Value::Str(factor) => factor.trim().parse::<f64>().is_ok_and(|f| f == 0.0),
Value::F64(factor) => *factor == 0.0,
_ => false,
})
}
/// The value inside the variants the portal wraps it in (`Read` wraps it twice).
fn plain<'v>(value: &'v Value<'v>) -> &'v Value<'v> {
match value {
Value::Value(inner) => plain(inner),
other => other,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_animation_setting_is_bit_33_of_the_mask() {
// Windows' default mask, then one from a machine with "Animation effects" switched off.
assert!(!animations_off(&[0x9E, 0x1E, 0x07, 0x80, 0x12, 0x00, 0x00, 0x00]));
assert!(animations_off(&[0x90, 0x12, 0x03, 0x80, 0x10, 0x00, 0x00, 0x00]));
}
#[test]
fn a_short_mask_leaves_animations_on() {
assert!(!animations_off(&[]));
assert!(!animations_off(&[0x90, 0x12, 0x03, 0x80]));
}
}

View File

@ -1,131 +0,0 @@
//! The settings in eframe's own file, read and written while there is no window.
//!
//! eframe keeps its state (window size, egui memory and the launcher's settings) in `app.ron` in
//! its storage folder, but only while a window is open. Where the window closes to the tray
//! (Wayland), the launcher carries on without one, so it saves its settings to the same file
//! itself: same folder, same format, other keys left as they are.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Where eframe keeps its state for `app_id`, as it picks it for a window with that app id.
pub fn path(app_id: &str) -> Option<PathBuf> {
eframe::storage_dir(app_id).map(|dir| dir.join("app.ron"))
}
/// The value stored under `key`, if the file has one that still reads.
pub fn load<T: serde::de::DeserializeOwned>(path: &Path, key: &str) -> Option<T> {
eframe::get_value(&RonFile::read(path), key)
}
/// Stores `value` under `key`, keeping what else the file holds.
pub fn save<T: serde::Serialize>(path: &Path, key: &str, value: &T) -> Result<(), String> {
let mut file = RonFile::read(path);
eframe::set_value(&mut file, key, value);
file.write(path)
}
/// eframe's file: a RON map of keys to RON strings.
#[derive(Default)]
struct RonFile(HashMap<String, String>);
impl RonFile {
/// What the file holds; empty when it is missing or doesn't read, as eframe takes it.
fn read(path: &Path) -> Self {
let Ok(text) = std::fs::read_to_string(path) else { return Self::default() };
match ron::from_str(&text) {
Ok(kv) => Self(kv),
Err(e) => {
log::warn!("{}: {e}", path.display());
Self::default()
}
}
}
/// Writes a new file and moves it into place, so a failed write leaves the old one whole.
fn write(&self, path: &Path) -> Result<(), String> {
let text = ron::ser::to_string_pretty(&self.0, ron::ser::PrettyConfig::default()).map_err(|e| e.to_string())?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let tmp = path.with_extension("ron.tmp");
std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
}
}
impl eframe::Storage for RonFile {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.to_string(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
#[cfg(test)]
mod tests {
use artcraft_launcher_ui_egui::Settings;
use super::*;
/// A file in a folder of its own, removed when the test ends.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("artcraft-launcher-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Self(dir)
}
fn file(&self) -> PathBuf {
self.0.join("app.ron")
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn settings_saved_with_no_file_read_back() {
let dir = Scratch::new("settings-round-trip");
let settings = Settings { last_check: Some(1_791_500_000), notify: false, ..Settings::default() };
assert_eq!(save(&dir.file(), "settings", &settings), Ok(()));
assert_eq!(load::<Settings>(&dir.file(), "settings"), Some(settings));
}
#[test]
fn saving_keeps_what_eframe_stored() {
let dir = Scratch::new("settings-keeps-window");
assert!(std::fs::create_dir_all(&dir.0).is_ok());
// As eframe writes it: a map of keys to RON strings.
let window = r#"(inner_position_pixels:Some((x:10.0,y:20.0)),fullscreen:false)"#;
let eframe_file = format!("{{\n \"window\": {window:?},\n \"settings\": \"(notify:true)\",\n}}");
assert!(std::fs::write(dir.file(), eframe_file).is_ok());
assert_eq!(load::<Settings>(&dir.file(), "settings").map(|s| s.notify), Some(true));
let settings = Settings { notify: false, ..Settings::default() };
assert_eq!(save(&dir.file(), "settings", &settings), Ok(()));
assert_eq!(load::<Settings>(&dir.file(), "settings"), Some(settings));
assert_eq!(RonFile::read(&dir.file()).0.get("window").map(String::as_str), Some(window));
}
#[test]
fn a_missing_or_broken_file_has_no_settings() {
let dir = Scratch::new("settings-broken");
assert_eq!(load::<Settings>(&dir.file(), "settings"), None);
assert!(std::fs::create_dir_all(&dir.0).is_ok());
assert!(std::fs::write(dir.file(), "not ron {").is_ok());
assert_eq!(load::<Settings>(&dir.file(), "settings"), None);
}
}

View File

@ -1,147 +0,0 @@
//! The desktop appearance portal supplies the theme that winit cannot read on Wayland.
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
use zbus::blocking::{Proxy, connection::Builder};
use zbus::zvariant::{OwnedValue, Value};
const APPEARANCE: &str = "org.freedesktop.appearance";
const COLOR_SCHEME: &str = "color-scheme";
#[derive(Default)]
pub struct Monitor(Arc<AtomicU8>);
impl Monitor {
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
let monitor = Self::default();
let state = Arc::downgrade(&monitor.0);
if let Err(e) = std::thread::Builder::new().name("system-theme".into()).spawn(move || {
let publish = |scheme| {
if let Some(state) = state.upgrade()
&& state.swap(scheme, Ordering::Relaxed) != scheme
{
log::debug!("desktop appearance: {:?}", theme(scheme));
wake();
}
};
// Retry after the portal goes away, backing off to every 5 minutes where there's none.
let mut retry = Duration::from_secs(5);
while state.strong_count() > 0 {
match watch(&publish) {
Ok(()) => retry = Duration::from_secs(5),
Err(e) => log::debug!("desktop appearance portal: {e}"),
}
publish(0);
std::thread::sleep(retry);
retry = (retry * 2).min(Duration::from_secs(300));
}
}) {
log::warn!("couldn't monitor the system theme: {e}");
}
monitor
}
pub fn apply(&self, input: &mut egui::RawInput) {
if let Some(theme) = theme(self.0.load(Ordering::Relaxed)) {
input.system_theme = Some(theme);
}
}
}
fn theme(scheme: u8) -> Option<egui::Theme> {
match scheme {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
fn scheme(value: OwnedValue) -> u8 {
// Read returns an extra variant wrapper; ReadOne and SettingChanged do not.
let value = match Value::from(value) {
Value::Value(value) => *value,
value => value,
};
match u32::try_from(value) {
Ok(1) => 1,
Ok(2) => 2,
_ => 0,
}
}
fn watch(publish: &impl Fn(u8)) -> zbus::Result<()> {
let connection = Builder::session()?.method_timeout(Duration::from_secs(3)).build()?;
let proxy = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
// Subscribe before reading so a theme switch during startup is not missed.
let changes = proxy.receive_signal_with_args("SettingChanged", &[(0, APPEARANCE), (1, COLOR_SCHEME)])?;
let initial: OwnedValue = proxy.call("ReadOne", &(APPEARANCE, COLOR_SCHEME)).or_else(|_| proxy.call("Read", &(APPEARANCE, COLOR_SCHEME)))?;
publish(scheme(initial));
for message in changes {
let (namespace, key, value): (String, String, OwnedValue) = message.body().deserialize()?;
if namespace == APPEARANCE && key == COLOR_SCHEME {
publish(scheme(value));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires a session bus and a desktop portal with an explicit color preference"]
fn running_desktop_portal_reaches_egui() {
let (send, receive) = std::sync::mpsc::channel();
let monitor = Monitor::start(move || {
let _ = send.send(());
});
assert!(receive.recv_timeout(Duration::from_secs(10)).is_ok());
let mut input = egui::RawInput::default();
monitor.apply(&mut input);
assert!(input.system_theme.is_some());
let ctx = egui::Context::default();
ctx.set_theme(egui::ThemePreference::System);
ctx.begin_pass(input.clone());
assert_eq!(Some(ctx.theme()), input.system_theme);
ctx.end_pass().textures_delta.clear();
}
#[test]
fn portal_values_include_no_preference_and_unknown_values() {
for (value, expected) in [(0, None), (1, Some(egui::Theme::Dark)), (2, Some(egui::Theme::Light)), (3, None), (u32::MAX, None)] {
assert_eq!(theme(scheme(OwnedValue::from(value))), expected);
}
assert_eq!(scheme(OwnedValue::from(true)), 0);
}
#[test]
fn legacy_read_unwraps_the_extra_variant() {
let value = Value::Value(Box::new(Value::U32(2)));
assert_eq!(value.try_to_owned().map(scheme).ok(), Some(2));
}
#[test]
fn portal_changes_preserve_explicit_choices_and_unknown_falls_back_to_winit() {
let monitor = Monitor::default();
let ctx = egui::Context::default();
for preference in [egui::ThemePreference::System, egui::ThemePreference::Light, egui::ThemePreference::Dark] {
ctx.set_theme(preference);
for scheme in [1, 2, 0] {
monitor.0.store(scheme, Ordering::Relaxed);
let mut input = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
monitor.apply(&mut input);
let expected = match preference {
egui::ThemePreference::Light => egui::Theme::Light,
egui::ThemePreference::Dark => egui::Theme::Dark,
egui::ThemePreference::System => theme(scheme).unwrap_or(egui::Theme::Dark),
};
ctx.begin_pass(input);
assert_eq!(ctx.theme(), expected);
ctx.end_pass().textures_delta.clear();
}
}
}
}

View File

@ -1,7 +1,5 @@
//! The tray (menu bar) icon: show the window, check for updates, quit.
use artcraft_launcher_ui_egui::i18n;
use std::sync::mpsc::{Receiver, Sender, channel};
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
@ -22,8 +20,6 @@ pub struct Tray {
quit: MenuId,
rx: Receiver<TrayAction>,
tooltip: String,
language: String,
labels: Vec<(MenuItem, &'static str)>,
}
fn load_icon() -> Result<tray_icon::Icon, String> {
@ -40,31 +36,21 @@ fn load_icon() -> Result<tray_icon::Icon, String> {
tray_icon::Icon::from_rgba(img.into_raw(), w, h).map_err(|e| e.to_string())
}
/// Names the icon for Linux and the BSDs. tray-icon's default id is "<pid>-<n>" and the title is
/// empty, and StatusNotifierItem hosts (KDE, GNOME's AppIndicator extension) fall back to the id
/// when there is no title, so the tray showed a number like "61965-1" instead of the app name.
/// Windows and macOS keep tray-icon's defaults: there the title is drawn next to the icon (macOS)
/// or unsupported (Windows), and the id never reaches the user.
fn identity(builder: TrayIconBuilder) -> TrayIconBuilder {
if cfg!(any(target_os = "macos", target_os = "windows")) { builder } else { builder.with_id("artcraft-launcher").with_title("ArtCraft Launcher") }
}
impl Tray {
/// Creates the tray icon. `wake` repaints the UI so it sees the action, even when hidden.
/// `on_quit` runs when Quit is picked, before the UI can see it: the UI may never get to.
pub fn new(wake: impl Fn() + Send + Sync + Clone + 'static, on_quit: impl Fn() + Send + Sync + 'static) -> Result<Self, String> {
let show = MenuItem::new(i18n::t("Show ArtCraft Launcher"), true, None);
let check = MenuItem::new(i18n::t("Check for Updates"), true, None);
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);
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
let website = MenuItem::new(i18n::t("Visit getartcraft.com"), true, None);
let github = MenuItem::new(i18n::t("ArtCraft on GitHub"), true, None);
let discord = MenuItem::new(i18n::t("Join the ArtCraft Discord"), true, None);
let quit = MenuItem::new(i18n::t("Quit ArtCraft Launcher"), true, None);
let website = MenuItem::new("Visit getartcraft.com", true, None);
let github = MenuItem::new("ArtCraft on GitHub", true, None);
let discord = MenuItem::new("Join the ArtCraft Discord", true, None);
let quit = MenuItem::new("Quit ArtCraft Launcher", true, None);
let menu = Menu::new();
menu.append_items(&[&show, &check, &PredefinedMenuItem::separator(), &website, &github, &discord, &PredefinedMenuItem::separator(), &quit])
.map_err(|e| e.to_string())?;
let links = [(website.id().clone(), WEBSITE_URL), (github.id().clone(), GITHUB_URL), (discord.id().clone(), DISCORD_URL)];
let icon = identity(TrayIconBuilder::new())
let icon = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_icon(load_icon()?)
.with_icon_as_template(cfg!(target_os = "macos"))
@ -90,9 +76,6 @@ impl Tray {
} else {
return;
};
if matches!(action, TrayAction::Quit) {
on_quit();
}
let _ = tx.send(action);
wake();
}));
@ -103,15 +86,7 @@ impl Tray {
wake();
}
}));
let labels = vec![
(show, "Show ArtCraft Launcher"),
(check, "Check for Updates"),
(website, "Visit getartcraft.com"),
(github, "ArtCraft on GitHub"),
(discord, "Join the ArtCraft Discord"),
(quit, "Quit ArtCraft Launcher"),
];
Ok(Self { icon, show: show_id, check: check_id, quit: quit_id, rx, tooltip: String::new(), language: String::new(), labels })
Ok(Self { icon, show: show_id, check: check_id, quit: quit_id, rx, tooltip: String::new() })
}
pub fn actions(&self) -> Vec<TrayAction> {
@ -119,17 +94,10 @@ impl Tray {
}
pub fn set_updates(&mut self, count: usize) {
let lang = i18n::current();
if self.language != lang.code() {
for (item, source) in &self.labels {
item.set_text(i18n::tr(lang, source));
}
self.language = lang.code().to_string();
}
let tip = if count == 0 {
"ArtCraft Launcher".to_string()
} else {
i18n::trn(lang, count as u64, "ArtCraft Launcher · {n} update available", "ArtCraft Launcher · {n} updates available")
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));
@ -142,20 +110,3 @@ impl Tray {
(&self.show, &self.check, &self.quit)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linux_tray_has_a_stable_id_not_the_process_id() {
let id = identity(TrayIconBuilder::new()).id().clone();
let pid = std::process::id().to_string();
if cfg!(any(target_os = "macos", target_os = "windows")) {
// Left as tray-icon picks it there: "<pid>-<n>".
assert!(id.as_ref().starts_with(&pid), "{id:?}");
} else {
assert_eq!(id.as_ref(), "artcraft-launcher");
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@ -1,18 +1,15 @@
//! The Craft App Suite: the thirteen apps the launcher knows about.
//! 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.
//!
//! Text here is English, the source the UI translates from: each `tagline` has a catalog row
//! keyed by the app id (`tr_ctx(app.id, app.tagline)` in `ui-egui`).
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.
/// The seven original apps, each with a page on getartcraft.com.
Classic,
/// The six apps added after them.
/// The five apps added after them.
New,
}
@ -32,8 +29,10 @@ pub struct CraftApp {
pub id: &'static str,
/// Display name (`PhotoCraft`).
pub name: &'static str,
/// What it's for, in a few words (English; the UI translates it by app id).
/// 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), for surfaces: getartcraft.com's `--app`.
pub color: [u8; 3],
@ -45,11 +44,14 @@ pub struct CraftApp {
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],
/// Older display names its installers registered (PdfCraft's MSI said PrintCraft until 0.2.1).
pub former_names: &'static [&'static str],
}
impl CraftApp {
/// The product it's inspired by, with its registered-trademark sign (`Adobe Photoshop®`).
pub fn inspired_by(&self) -> String {
format!("{}®", self.like)
}
/// `ai.storyteller.<id>`: macOS bundle id, Flatpak id and Linux desktop-file id.
pub fn bundle_id(&self) -> String {
format!("ai.storyteller.{}", self.id)
@ -63,9 +65,9 @@ impl CraftApp {
format!("https://github.com/{}", self.repo)
}
/// The app's page on getartcraft.com.
pub fn website(&self) -> String {
format!("https://getartcraft.com/apps/{}", self.id)
/// 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.
@ -73,11 +75,6 @@ impl CraftApp {
std::iter::once(self.id).chain(self.former_ids.iter().copied())
}
/// Display names this app's installers may have registered, current first.
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
std::iter::once(self.name).chain(self.former_names.iter().copied())
}
pub fn by_id(id: &str) -> Option<&'static CraftApp> {
SUITE.iter().find(|a| a.id == id)
}
@ -87,19 +84,21 @@ const fn app(
id: &'static str,
name: &'static str,
tagline: &'static str,
like: &'static str,
group: AppGroup,
[color, ink_light, ink_dark]: [[u8; 3]; 3],
repo: &'static str,
) -> CraftApp {
CraftApp { id, name, tagline, group, color, ink_light, ink_dark, repo, former_ids: &[], former_names: &[] }
CraftApp { id, name, tagline, like, group, color, ink_light, ink_dark, repo, former_ids: &[] }
}
/// The Craft App Suite, in display order: the seven classic apps, then the six new ones.
pub const SUITE: [CraftApp; 13] = [
/// 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], [0x1a, 0x5b, 0xc4], [0x7f, 0xb0, 0xff]],
"storytold/photocraft",
@ -108,6 +107,7 @@ pub const SUITE: [CraftApp; 13] = [
"vectorcraft",
"VectorCraft",
"Vector illustration",
"Adobe Illustrator",
AppGroup::Classic,
[[0xe8, 0x57, 0x3f], [0xb8, 0x3a, 0x24], [0xff, 0x8a, 0x70]],
"storytold/vectorcraft",
@ -116,6 +116,7 @@ pub const SUITE: [CraftApp; 13] = [
"filmcraft",
"FilmCraft",
"Video editing, color and sound",
"Adobe Premiere Pro",
AppGroup::Classic,
[[0x8b, 0x5c, 0xf6], [0x6a, 0x3f, 0xd6], [0xb6, 0x9c, 0xff]],
"storytold/filmcraft",
@ -124,17 +125,18 @@ pub const SUITE: [CraftApp; 13] = [
"lightcraft",
"LightCraft",
"Photo library and raw development",
"Adobe Lightroom",
AppGroup::Classic,
[[0xf2, 0xa5, 0x16], [0x8a, 0x58, 0x00], [0xff, 0xc1, 0x4d]],
"storytold/lightcraft",
),
CraftApp {
former_ids: &["printcraft"],
former_names: &["PrintCraft"],
..app(
"pdfcraft",
"PdfCraft",
"Reading, organizing and protecting PDFs",
"Adobe Acrobat",
AppGroup::Classic,
[[0x12, 0xa5, 0x8a], [0x0a, 0x75, 0x63], [0x3f, 0xd6, 0xb8]],
"storytold/pdfcraft",
@ -144,6 +146,7 @@ pub const SUITE: [CraftApp; 13] = [
"effectcraft",
"EffectCraft",
"Motion graphics and visual effects",
"Adobe After Effects",
AppGroup::Classic,
[[0xe0, 0x36, 0x8f], [0xb0, 0x20, 0x6c], [0xff, 0x7a, 0xb8]],
"storytold/effectcraft",
@ -152,6 +155,7 @@ pub const SUITE: [CraftApp; 13] = [
"designcraft",
"DesignCraft",
"Page layout and publishing",
"Adobe InDesign",
AppGroup::Classic,
[[0x7b, 0xb5, 0x1c], [0x45, 0x70, 0x08], [0xa6, 0xe0, 0x4a]],
"storytold/designcraft",
@ -160,6 +164,7 @@ pub const SUITE: [CraftApp; 13] = [
"wordcraft",
"WordCraft",
"Writing and document design",
"Microsoft Word",
AppGroup::New,
[[0x3f, 0x5f, 0xe0], [0x2a, 0x44, 0xb8], [0x8e, 0xa2, 0xff]],
"storytold/wordcraft",
@ -168,6 +173,7 @@ pub const SUITE: [CraftApp; 13] = [
"deckcraft",
"DeckCraft",
"Presentations and slide shows",
"Microsoft PowerPoint",
AppGroup::New,
[[0xf0, 0x7a, 0x1e], [0xa8, 0x4f, 0x0a], [0xff, 0xa0, 0x60]],
"storytold/deckcraft",
@ -176,6 +182,7 @@ pub const SUITE: [CraftApp; 13] = [
"gridcraft",
"GridCraft",
"Spreadsheets and data",
"Microsoft Excel",
AppGroup::New,
[[0x1f, 0x9d, 0x55], [0x12, 0x70, 0x3a], [0x4f, 0xd6, 0x8a]],
"storytold/gridcraft",
@ -184,6 +191,7 @@ pub const SUITE: [CraftApp; 13] = [
"cadcraft",
"CADCraft",
"Computer-aided design and drafting",
"Autodesk AutoCAD",
AppGroup::New,
[[0x3e, 0xa6, 0xe8], [0x1a, 0x6f, 0xa8], [0x7c, 0xc8, 0xff]],
"storytold/cadcraft",
@ -192,25 +200,23 @@ pub const SUITE: [CraftApp; 13] = [
"soundcraft",
"SoundCraft",
"Recording, editing and mixing audio",
"Avid Pro Tools",
AppGroup::New,
[[0x1f, 0xa5, 0xc9], [0x0e, 0x72, 0x90], [0x5f, 0xd0, 0xee]],
"storytold/soundcraft",
),
app(
"figcraft",
"FigCraft",
"Interface design and prototyping",
AppGroup::New,
[[0x02, 0xda, 0x96], [0x04, 0x7a, 0x56], [0x4f, 0xe8, 0xb6]],
"storytold/figcraft",
),
];
/// Shown wherever the products the apps are inspired by are named.
pub const TRADEMARK_NOTICE: &str = "Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are either \
registered trademarks or trademarks of Adobe in the United States and/or other countries. Microsoft, Word, PowerPoint and Excel are \
trademarks of the Microsoft group of companies. Autodesk and AutoCAD are registered trademarks or trademarks of Autodesk, Inc. Avid \
and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. All other trademarks are the property of their \
respective owners. These names are used only to describe what each Craft App is for; the Craft App Suite is independent and is not \
affiliated with, sponsored by or endorsed by any of these companies.";
/// ArtCraft's website.
pub const WEBSITE_URL: &str = "https://getartcraft.com";
/// The GitHub account every Craft App's repository lives under: the only one the launcher
/// installs from, whatever the release feed says.
pub const GITHUB_OWNER: &str = "storytold";
/// ArtCraft on GitHub: every Craft App's source.
pub const GITHUB_URL: &str = "https://github.com/storytold";
/// ArtCraft's social accounts: (service, URL). Shown as small icons, less prominent than Discord.
@ -232,6 +238,7 @@ pub const LAUNCHER: CraftApp = app(
"artcraft-launcher",
"ArtCraft Launcher",
"Installs and updates the Craft App Suite",
"",
AppGroup::New,
[[0x0b, 0x0b, 0x0c], [0x0b, 0x0b, 0x0c], [0xf2, 0xf1, 0xee]],
"storytold/craft-launcher",
@ -247,9 +254,9 @@ mod tests {
use super::*;
#[test]
fn suite_has_seven_classic_and_six_new_apps_with_unique_ids() {
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(), 6);
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();
@ -260,12 +267,22 @@ mod tests {
}
}
#[test]
fn every_inspiring_product_is_covered_by_the_trademark_notice() {
for a in &SUITE {
assert!(a.inspired_by().ends_with('®'));
// The company and the product name both appear in the notice.
let (company, product) = a.like.split_once(' ').unwrap();
assert!(TRADEMARK_NOTICE.contains(company), "{company}");
assert!(TRADEMARK_NOTICE.contains(product), "{product}");
}
}
#[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.names().collect::<Vec<_>>(), ["PdfCraft", "PrintCraft"]);
assert_eq!(pdf.website(), "https://getartcraft.com/apps/pdfcraft");
assert_eq!(CraftApp::by_id("wordcraft").unwrap().website(), "https://getartcraft.com/apps/wordcraft");
assert_eq!(pdf.website().as_deref(), Some("https://getartcraft.com/apps/pdfcraft"));
assert_eq!(CraftApp::by_id("wordcraft").unwrap().website(), None);
}
}

View File

@ -1,483 +0,0 @@
//! Hand-written product pages: what each app is, in the launcher's own words.
//!
//! The text is compiled in, so it works offline and on the web, and nothing from the network is
//! ever shown as page copy. What changes with every release (version, date, size, notes) comes
//! from the GitHub release instead. An app without an entry here simply has no details page.
//!
//! The pictures are not compiled in: they are fetched when a page is opened (see
//! [`AppDetails::screenshot_url`]), so the repository and the binary stay small.
/// One feature: a short name and a sentence.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Feature {
pub title: &'static str,
pub text: &'static str,
}
/// A screenshot: `key` names the file to fetch (`<app>/<key>.jpg` in the pictures repository).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Shot {
pub key: &'static str,
pub caption: &'static str,
}
const fn shot(key: &'static str, caption: &'static str) -> Shot {
Shot { key, caption }
}
/// The editorial half of an app's page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AppDetails {
/// Matches [`crate::CraftApp::id`].
pub id: &'static str,
/// One line under the name.
pub headline: &'static str,
/// One or two paragraphs, separated by a blank line.
pub about: &'static str,
pub features: &'static [Feature],
/// The first is the lead picture. All are 8:5.
pub shots: &'static [Shot],
/// Where it runs, for display (`"macOS, Windows, Linux, web"`).
pub platforms: &'static str,
}
const fn feature(title: &'static str, text: &'static str) -> Feature {
Feature { title, text }
}
/// Where the pictures live: one 1200 px wide, 8:5 JPEG per [`Shot`], at `<app>/<key>.jpg`.
///
/// Pinned to a tag (`SCREENSHOTS_REV`) so that what a launcher fetches never changes under it, and
/// so that a saved copy never goes stale. To change a picture, publish a new tag and bump the
/// revision: older launchers keep working from the tag they know.
pub const SCREENSHOTS_BASE: &str = "https://raw.githubusercontent.com/storytold/craft-launcher-assets";
/// The tag of the pictures repository the launcher reads. Also part of the saved copies' path.
pub const SCREENSHOTS_REV: &str = "v1";
/// The largest picture the launcher accepts. The real ones are 60 to 150 KB.
pub const MAX_SCREENSHOT_BYTES: usize = 2 << 20;
/// True when `bytes` start like a JPEG file. A captive portal's login page, a proxy's error page or
/// a truncated answer is refused before it is saved or decoded.
pub fn looks_like_jpeg(bytes: &[u8]) -> bool {
bytes.starts_with(&[0xFF, 0xD8, 0xFF])
}
/// Where every Craft App runs (getartcraft.com lists the same for each of them).
const PLATFORMS: &str = "macOS, Windows, Linux, FreeBSD, web";
pub const DETAILS: [AppDetails; 13] = [
AppDetails {
id: "photocraft",
headline: "The image editor you already know how to use.",
about: "Layers, masks, adjustment layers, layer styles, type, vectors and brushes in a native app \
written in Rust. Open source, offline, and yours.\n\n\
PhotoCraft keeps the menus, shortcuts and panels your hands already know, so there is \
nothing to relearn.",
features: &[
feature("Native GPU canvas", "No Electron and no web view: a fast native compositor."),
feature("Real layered files", "Opens and saves layered PSD and PSB documents."),
feature("Non-destructive editing", "Adjustment layers, live-preview filters, smart objects and layer styles."),
feature("On-device selection", "Subject and object selection and mask refinement run locally."),
feature("Serious color", "8-, 16- and 32-bit RGB, CMYK, Lab and Grayscale, with ICC color management and soft proofing."),
],
shots: &[
shot("demo", "Curves and type layers"),
shot("adjustments", "Levels with a live histogram"),
shot("layer-styles", "Layer styles on live type"),
shot("masks", "Selections and masked adjustments"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "vectorcraft",
headline: "Vector illustration, reimagined in pure Rust.",
about: "A native vector illustration app with the pen, panels and shortcuts illustrators expect. \
Open source, and it also runs in a browser.",
features: &[
feature(
"The workflow you know",
"Pen, direct selection, shape booleans, snapping guides and an appearance panel, laid out the way you expect.",
),
feature("Genuinely fast", "Drawings with thousands of shapes stay responsive on a retina display."),
feature(
"Booleans that don't fail",
"Shape booleans work on the exact curves, so there is no \"cannot perform operation\", and undo is unlimited.",
),
feature("Live, editable effects", "Blends, envelope distort, gradient mesh, repeats, glows and drop shadows all stay editable."),
feature("Image tracing", "Turn raster images into clean vector paths, with presets for the common cases."),
feature("Open formats", "Native JSON documents, SVG and PDF import and export, and PNG, JPEG and WebP output."),
],
shots: &[
shot("neon", "The Neon Drive poster"),
shot("ribbons", "Live blends"),
shot("sheet", "A feature sheet and the Layers panel"),
shot("bezier", "Pathfinder and exact booleans"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "filmcraft",
headline: "Professional video editing, rebuilt from scratch.",
about: "A native video editor written in pure Rust, with source and program monitors, a real timeline and \
every trim mode.",
features: &[
feature("A timeline you already know", "Source and program monitors, three-point editing, every trim mode and J/K/L trimming."),
feature("Frame-exact time", "An integer timebase keeps 23.976 and 29.97 drop-frame footage exact, with no drift."),
feature("Its own codecs", "No FFmpeg inside: native support for H.264, HEVC, ProRes, VP9, AAC and Opus."),
feature("Color grading", "Curves, color wheels, HSL secondaries, LUTs and HDR and log workflows, with scopes to check the result."),
feature(
"Effects and keyframes",
"Video effects and transitions, keyframes with value and velocity graphs, and a linear-light GPU compositor.",
),
feature("Broadcast-ready audio", "EBU R128 loudness meters, a mixer with automation and native audio effects."),
],
shots: &[
shot("color", "The Color workspace, with scopes"),
shot("assembly", "The Assembly workspace"),
shot("keyframes", "Keyframes in Effect Controls"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "lightcraft",
headline: "Your photos. Your pixels. Your machine.",
about: "A fast photo library and non-destructive raw developer, written from scratch in pure Rust. \
No account, no cloud, no telemetry and no subscription.",
features: &[
feature("Non-destructive by design", "A wide-gamut, 32-bit float pipeline that never touches your originals."),
feature("Every control you reach for", "Light, color, effects, tone curve, a color mixer, color grading wheels and built-in presets."),
feature("Precise masking", "Brush, gradient and range masks, plus sky, subject and background masks."),
feature("Native raw decoding", "Pure-Rust decoders for the main camera raw formats: DNG, CR2, ARW, NEF, RAF, RW2, PEF and ORF."),
feature("A library that keeps up", "Albums, ratings, flags and search, with grids that stay smooth on large catalogs."),
feature("Instant feedback", "The GPU pipeline updates the preview as you move a slider, even on large raw files."),
],
shots: &[
shot("hero", "The Edit view"),
shot("curve", "The tone curve"),
shot("grading", "Color grading wheels"),
shot("masking", "A radial mask over a sky mask"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "pdfcraft",
headline: "The open-source PDF workbench.",
about: "Read, organize, combine, split and secure PDFs in a fast, native app written in Rust from the \
ground up. No account, no telemetry and no cloud.",
features: &[
feature("Faithful rendering", "World scripts, vertical Japanese, color emoji and transparency."),
feature("Search as you type", "Instant find, and text selection that follows the document's real reading order."),
feature("Organize like cards", "Rotate, delete, insert and reorder pages, with deep undo behind every change."),
feature("Combine, extract, split", "Assemble and break apart documents while keeping links, form fields, layers and bookmarks intact."),
feature("Fearless saves", "Incremental, atomic saves with autosave and crash recovery."),
feature("Built-in security", "Opens files from RC4 to AES-256 encryption and honors the author's permissions."),
],
shots: &[],
platforms: PLATFORMS,
},
AppDetails {
id: "effectcraft",
headline: "Motion graphics and visual effects, in pure Rust.",
about: "A native compositor with compositions, layers, keyframes, effects, expressions, 3D cameras and \
lights, and a render queue. Young, moving fast, and already usable.",
features: &[
feature(
"Animate the way you know",
"Familiar panels and keyframes (linear, bezier, hold and eased), with a graph editor for value and speed curves.",
),
feature(
"Layers of every kind",
"Solids, shapes, text, footage, nested compositions, nulls and adjustment layers, with parenting, track mattes and blend modes.",
),
feature(
"A large effects library",
"Blur, color correction, distortion, generators, keying, particles and simulation, stylize and time effects.",
),
feature("Real 3D", "3D layers with cameras, depth of field, and lights that cast soft ray-traced shadows."),
feature("Expressions", "JavaScript expressions with wiggle, loops and layer references, edited inline."),
feature("Export anywhere", "H.264 and ProRes with alpha, PNG, TIFF and EXR sequences, GIF, and Lottie import and export."),
],
shots: &[
shot("hero", "An animated composition"),
shot("graph", "The Graph Editor"),
shot("effects", "Particles and Effect Controls"),
shot("3d", "3D layers with cameras and lights"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "designcraft",
headline: "Page layout and publishing, rebuilt in pure Rust.",
about: "A native layout app with spreads, parent pages, threaded stories, styles, swatches, text wrap and \
a paragraph composer. No subscription, no license server and no telemetry.",
features: &[
feature("The layout tools you know", "Spreads and parent pages, frames and threaded stories, with paragraph and character styles."),
feature(
"Beautiful type",
"A Knuth–Plass paragraph composer, dictionary hyphenation, optical margin alignment and baseline grids, with line breaks that match on screen and in PDF.",
),
feature("Print-ready PDF", "Real selectable text with embedded fonts, CMYK and spot colors, bleed, crop marks and PDF/A output."),
feature("Fast", "Multithreaded rendering, instant undo and cached text composition."),
feature("Open formats", "A documented native format, PNG export and layout interchange import and export."),
feature("Built for automation", "Every menu item, tool, panel and dialog can be driven over a JSON control channel and an MCP server."),
],
shots: &[shot("spread", "A magazine spread with a threaded story")],
platforms: PLATFORMS,
},
AppDetails {
id: "wordcraft",
headline: "The familiar word processor, native and offline.",
about: "A word processor with the ribbon, shortcuts and behavior you already know, written in Rust. \
Open source, and built from public specifications.",
features: &[
feature(
"Familiar",
"Ribbon tabs and groups, a live Styles gallery and the editing behavior you expect, from list continuation to Format Painter.",
),
feature(
"Your files",
"Opens and saves .docx, and also .odt, .rtf, .html, .md, .tex and .txt. Opens older .doc files and exports PDF with real, selectable text.",
),
feature("Fast", "Paragraph layout is cached, so typing stays responsive even in long documents."),
feature("Private", "Spelling, grammar and everything else work offline."),
feature("Everywhere", "One Rust codebase for macOS, Windows, Linux, BSD and the web."),
feature(
"Built for agents",
"Every action is a command, driven from the ribbon, shortcuts, a command-line tool, a JSON control channel or an MCP server.",
),
],
shots: &[
shot("hero", "The Home tab and the Navigation pane"),
shot("review", "Track Changes and comments"),
shot("design", "The Design tab"),
shot("layout", "The Layout tab"),
],
platforms: "macOS, Windows, Linux, BSD, web",
},
AppDetails {
id: "deckcraft",
headline: "Presentations, rebuilt in Rust.",
about: "A presentation app with slide masters, shapes, animations and a presenter view, written in Rust. \
In alpha, heading for beta.",
features: &[
feature("Make decks", "Slide masters and layouts with placeholders that inherit formatting, themes, sections, notes and comments."),
feature(
"Draw",
"Preset shapes with adjust handles, connectors that stay glued to shapes, freeform tools, merged shapes, gradients, shadows and glow.",
),
feature("Write", "In-place text editing with full font and paragraph formatting, bullets and numbering, columns and autofit."),
feature("Add content", "Pictures with crop and corrections, styled tables, charts, audio and video."),
feature(
"Present",
"Transitions including Morph, animations and motion paths, a full-screen slide show, presenter view and rehearsed timings.",
),
feature("Exchange files", "Opens and saves PowerPoint .pptx and exports PDF (slides, notes pages and handouts), PNG and JPEG."),
],
shots: &[
shot("editor", "Editing a slide, with sections and notes"),
shot("shape", "The Shape Format tab"),
shot("sorter", "The slide sorter"),
shot("format", "The Format Shape pane"),
],
platforms: "macOS, Windows, Linux, BSD, web",
},
AppDetails {
id: "gridcraft",
headline: "Spreadsheets you already know how to use.",
about: "A spreadsheet with the ribbon, formula bar, sheet tabs and shortcuts you know, on a calculation engine \
written in Rust. No subscription, no license server and no telemetry.",
features: &[
feature(
"Familiar",
"The ribbon, formula bar, Name Box, fill handle, point-mode formulas, Format Cells, Paste Special, conditional formatting, tables and charts.",
),
feature(
"Compatible",
"XLSX is the native format, with formulas, tables, conditional formats, charts, pivot tables and print settings round-tripping. CSV and TSV too.",
),
feature(
"Capable",
"A dependency-graph calculation engine with dynamic arrays, LET and LAMBDA, structured table references, sort, AutoFilter and Flash Fill.",
),
feature("Fast", "Copy-on-write workbooks, incremental recalculation and a grid that only lays out what is on screen."),
feature(
"Agent-native",
"Every ribbon button, menu item, dialog and keystroke is a command, available over a JSON control channel, an MCP server or the CLI.",
),
feature("Everywhere", "One Rust codebase for the desktop and the web."),
],
shots: &[
shot("hero", "A quarterly sales dashboard"),
shot("budget", "A household budget"),
shot("pivot", "A pivot table"),
shot("chart", "Formatting a chart"),
],
platforms: "macOS, Windows, Linux, BSD, web",
},
AppDetails {
id: "cadcraft",
headline: "Computer-aided drafting, in pure Rust.",
about: "A drafting app with the command line, object snaps and workflow that drafters already know. Free, with no \
account and no subscription. In early, fast development.",
features: &[
feature(
"The workflow you know",
"Type L, click two points, type @5<45 and press Enter. Prompts with clickable keywords, polar tracking, direct distance entry and grips work the way drafting habit expects.",
),
feature(
"Open files",
"Reads and writes DXF natively (R12 through 2018) and opens and saves DWG (R13 through 2018) with the open-source acadrust library. Exports SVG and PNG.",
),
feature("Fast and native", "Pure Rust and egui, with no Electron and no web view."),
feature(
"A full drafting toolset",
"Draw, modify, dimension, hatch and block commands, layers with a properties manager, paper-space layouts and PDF plotting.",
),
feature("Built for agents", "Every menu item, tool and prompt is a command, available over MCP, a JSON control channel or the CLI."),
],
shots: &[shot("apartment", "An apartment floor plan"), shot("bracket", "A mechanical bracket drawing"), shot("layout", "A layout sheet")],
platforms: PLATFORMS,
},
AppDetails {
id: "soundcraft",
headline: "Recording, editing and mixing, in Rust.",
about: "A digital audio workstation that follows the workflow audio engineers already know: an Edit window for \
arranging and editing, and a Mix window for balancing. Pre-alpha.",
features: &[
feature(
"Editing",
"Shuffle, Slip, Spot and Grid modes, Smart, Grabber, Trim and other tools, fades and crossfades, playlists with comping lanes and clip gain.",
),
feature(
"Mixing",
"Faders and pans with automation, sends, busses, aux inputs, VCA masters, routing folders and automatic plugin delay compensation.",
),
feature(
"Automation",
"Volume, pan, mute, send and every plugin parameter, with Write, Touch, Latch and Trim passes recorded live from the faders.",
),
feature(
"Plugins",
"EQ, dynamics, reverbs, modulation effects, a subtractive synth and a drum synth, all original, and CLAP plugins load too.",
),
],
shots: &[
shot("edit", "The Edit window with a nine-track session"),
shot("mix", "The Mix window"),
shot("automation", "Automation and memory locations"),
shot("score", "The Score Editor"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "figcraft",
headline: "Interface design and prototyping, in pure Rust.",
about: "Design screens, components and prototypes in a native app that opens instantly and works offline, \
with no account and no telemetry. Early alpha.",
features: &[
feature(
"Auto layout",
"Rows, columns, wrap and grids with padding, gaps and Hug, Fill or Fixed sizing, plus constraints and layout grids.",
),
feature(
"Components",
"Instances with overrides, variants and properties, styles, and variables with modes bound to fills, strokes and effects.",
),
feature("Vectors", "Real vector networks, the pen and pencil, the shape builder, editable booleans and variable width strokes."),
feature(
"Prototypes",
"Connections, triggers and animated transitions, variables with conditionals, flows and a presentation view with device frames.",
),
feature(
"Hand off",
"Inspect mode with measurements, annotations, ready-for-dev status, and code for CSS, HTML, React, SwiftUI and Compose.",
),
feature(
"Your files",
"Plain .figcraft files on your own disk. SVG, PNG, JPEG, WebP and PDF in and out, and .fig files open, best effort.",
),
],
shots: &[],
platforms: PLATFORMS,
},
];
impl AppDetails {
/// Where the picture `key` of this app is published, or `None` if the page has no such picture.
/// The only way to turn a request into an address: callers never build one from their own text.
pub fn screenshot_url(&self, key: &str) -> Option<String> {
self.shots.iter().find(|s| s.key == key).map(|s| format!("{SCREENSHOTS_BASE}/{SCREENSHOTS_REV}/{}/{}.jpg", self.id, s.key))
}
pub fn by_id(id: &str) -> Option<&'static AppDetails> {
DETAILS.iter().find(|d| d.id == id)
}
pub fn paragraphs(&self) -> impl Iterator<Item = &'static str> {
self.about.split("\n\n").map(str::trim).filter(|p| !p.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CraftApp, SUITE};
#[test]
fn every_page_belongs_to_a_suite_app_once() {
let mut ids: Vec<_> = DETAILS.iter().map(|d| d.id).collect();
for id in &ids {
assert!(CraftApp::by_id(id).is_some(), "{id}");
}
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), DETAILS.len());
}
#[test]
fn every_app_has_a_page() {
for a in &SUITE {
assert!(AppDetails::by_id(a.id).is_some(), "{}", a.id);
}
}
#[test]
fn picture_addresses_come_only_from_the_pages_own_pictures() {
let page = AppDetails::by_id("photocraft").unwrap();
assert_eq!(
page.screenshot_url("demo").as_deref(),
Some("https://raw.githubusercontent.com/storytold/craft-launcher-assets/v1/photocraft/demo.jpg")
);
for key in ["", "nope", "../vectorcraft/neon", "demo/../masks", "demo.jpg", "/etc/passwd", "demo?x=1", "demo#"] {
assert_eq!(page.screenshot_url(key), None, "{key:?}");
}
// Every picture of every page has an address of the one shape, with nothing to escape.
for d in &DETAILS {
for s in d.shots {
let url = d.screenshot_url(s.key).unwrap();
let path = url.strip_prefix(&format!("{SCREENSHOTS_BASE}/{SCREENSHOTS_REV}/")).unwrap();
assert_eq!(path, format!("{}/{}.jpg", d.id, s.key));
assert!(path.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '/' | '-' | '.')), "{path}");
}
}
}
#[test]
fn only_jpeg_files_are_accepted() {
assert!(looks_like_jpeg(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10]));
assert!(!looks_like_jpeg(b""));
assert!(!looks_like_jpeg(&[0xFF, 0xD8]));
assert!(!looks_like_jpeg(b"<!DOCTYPE html>"));
// A PNG's signature.
assert!(!looks_like_jpeg(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]));
}
#[test]
fn pages_are_filled_in() {
for d in &DETAILS {
assert!(!d.headline.is_empty() && !d.features.is_empty() && d.paragraphs().next().is_some(), "{}", d.id);
}
}
}

View File

@ -1,106 +0,0 @@
//! Linux distributions: reading `os-release` and choosing the package formats that suit them.
//!
//! The distro decides the system package format, not which tools happen to be on `PATH`: Fedora
//! can have `apt-get` and `dpkg` installed (for building Debian images), and an `.rpm` system
//! must never be handed to apt.
use crate::platform::PackageKind;
/// The parts of `/etc/os-release` that say which distro family this is.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OsRelease {
/// `ID`, lower-cased (`fedora`, `ubuntu`, `opensuse-tumbleweed`, …). Empty when missing.
pub id: String,
/// `ID_LIKE`, lower-cased and split on spaces (`["rhel", "centos", "fedora"]`).
pub id_like: Vec<String>,
}
/// Reads `os-release` text (`KEY=value` lines, values optionally quoted).
pub fn parse_os_release(text: &str) -> OsRelease {
let mut out = OsRelease::default();
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else { continue };
let value = unquote(value.trim()).to_ascii_lowercase();
match key.trim() {
"ID" => out.id = value,
"ID_LIKE" => out.id_like = value.split_whitespace().map(str::to_string).collect(),
_ => {}
}
}
out
}
fn unquote(v: &str) -> &str {
for q in ['"', '\''] {
if let Some(inner) = v.strip_prefix(q).and_then(|v| v.strip_suffix(q)) {
return inner;
}
}
v
}
/// The distro's own package format: `.deb` for the Debian family, `.rpm` for the Fedora, Red Hat
/// and SUSE families, `None` for anything else (Arch, unknown distros), which gets AppImages.
pub fn native_package(os: &OsRelease) -> Option<PackageKind> {
std::iter::once(&os.id).chain(&os.id_like).find_map(|id| family(id))
}
fn family(id: &str) -> Option<PackageKind> {
match id {
"debian" | "ubuntu" => Some(PackageKind::Deb),
"fedora" | "rhel" | "centos" | "suse" | "sles" => Some(PackageKind::Rpm),
_ if id.starts_with("opensuse") => Some(PackageKind::Rpm),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const FEDORA: &str = r#"NAME="Fedora Linux"
VERSION="40 (Workstation Edition)"
ID=fedora
VERSION_ID=40
PRETTY_NAME="Fedora Linux 40 (Workstation Edition)"
"#;
const UBUNTU: &str = r#"PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
ID=ubuntu
ID_LIKE=debian
"#;
const MINT: &str = "NAME=\"Linux Mint\"\nID=linuxmint\nID_LIKE=\"ubuntu debian\"\n";
const ARCH: &str = "NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n";
const TUMBLEWEED: &str = "NAME=\"openSUSE Tumbleweed\"\n# a comment\nID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n";
const ROCKY: &str = "NAME=\"Rocky Linux\"\nID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n";
const UNKNOWN: &str = "NAME=\"Some OS\"\nID=someos\n";
fn native(text: &str) -> Option<PackageKind> {
native_package(&parse_os_release(text))
}
#[test]
fn os_release_is_parsed() {
assert_eq!(parse_os_release(FEDORA), OsRelease { id: "fedora".into(), id_like: vec![] });
assert_eq!(parse_os_release(MINT), OsRelease { id: "linuxmint".into(), id_like: vec!["ubuntu".into(), "debian".into()] });
assert_eq!(parse_os_release("ID='opensuse-leap'\n").id, "opensuse-leap");
assert_eq!(parse_os_release(""), OsRelease::default());
}
#[test]
fn the_distro_family_picks_the_package_format() {
assert_eq!(native(FEDORA), Some(PackageKind::Rpm));
assert_eq!(native(ROCKY), Some(PackageKind::Rpm));
assert_eq!(native(TUMBLEWEED), Some(PackageKind::Rpm));
assert_eq!(native(UBUNTU), Some(PackageKind::Deb));
assert_eq!(native(MINT), Some(PackageKind::Deb));
assert_eq!(native(ARCH), None);
assert_eq!(native(UNKNOWN), None);
assert_eq!(native(""), None);
}
}

View File

@ -8,16 +8,13 @@
//! The feed is input like any other: every field is optional, unknown fields are ignored, a field
//! of the wrong type reads as missing, one bad entry never spoils the others, and download links
//! must still point at the app's own GitHub releases.
//!
//! A developer can serve the feed and the builds from their own machine instead (see
//! [`Feed::with_mirror`]): then links under the mirror are accepted too, and entries never go stale.
use std::collections::BTreeMap;
use serde::Deserialize;
use serde::de::{DeserializeOwned, Deserializer};
use crate::catalog::{CraftApp, GITHUB_OWNER};
use crate::catalog::CraftApp;
use crate::release::{self, Asset, Release};
use crate::time::parse_rfc3339;
use crate::version;
@ -36,13 +33,9 @@ pub struct Feed {
/// When the least recently fetched app was fetched (Unix seconds).
pub last_fetched: Option<i64>,
pub app_renames: Vec<AppRename>,
/// Repositories that moved, both under [`GITHUB_OWNER`]: a rename anywhere else is dropped.
pub repo_renames: Vec<RepoRename>,
pub announcements: Vec<Notice>,
pub alerts: Vec<Notice>,
/// A local mirror's address (ending in `/`): downloads under it are accepted alongside GitHub's.
/// Never read from the feed itself; the caller sets it with [`Feed::with_mirror`].
pub mirror: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@ -118,7 +111,6 @@ impl Feed {
repo_renames: raw.repo_renames.into_iter().filter_map(RawRepoRename::into_rename).collect(),
announcements: raw.important_notices.announcements.into_iter().filter_map(|n| n.into_notice("announcement")).take(MAX_NOTICES).collect(),
alerts: raw.important_notices.alerts.into_iter().filter_map(|n| n.into_notice("alert")).take(MAX_NOTICES).collect(),
mirror: None,
};
// App renames are news too: show them with the announcements.
for r in &feed.app_renames {
@ -133,16 +125,7 @@ impl Feed {
Some(feed)
}
/// Trusts downloads under `base` (which must end in `/`) and keeps every entry fresh: a feed
/// served by `cargo xtask local serve` on the developer's own machine.
#[must_use]
pub fn with_mirror(mut self, base: &str) -> Self {
self.mirror = base.ends_with('/').then(|| base.to_string());
self
}
/// The repo to ask GitHub about for `app`, following the feed's repo renames. Always under
/// [`GITHUB_OWNER`]: the feed can move an app within ArtCraft's account, never out of it.
/// The repo to ask GitHub about for `app`, following the feed's repo renames.
pub fn repo_for(&self, app: &CraftApp) -> String {
let mut repo = app.repo.to_string();
// Follow a chain of moves (a → b → c), without looping forever.
@ -160,8 +143,7 @@ impl Feed {
let Some(entry) = self.apps.get(app.id) else { return FeedAnswer::AskGitHub };
let Some(fetched_at) = entry.fetched_at else { return FeedAnswer::AskGitHub };
// A timestamp from the future (clock skew) counts as just now; one too old doesn't count.
// A local mirror is as fresh as its last sync.
if self.mirror.is_none() && now.saturating_sub(fetched_at) > MAX_AGE {
if now.saturating_sub(fetched_at) > MAX_AGE {
return FeedAnswer::AskGitHub;
}
// The feed's repo must be the app's, or one the feed says it moved to.
@ -173,7 +155,7 @@ impl Feed {
}
match &entry.release {
None => FeedAnswer::Fresh(None),
Some(r) => match r.to_release(&repo, self.mirror.as_deref()) {
Some(r) => match r.to_release(&repo) {
Some(release) => FeedAnswer::Fresh(Some(release)),
None => FeedAnswer::AskGitHub,
},
@ -182,17 +164,16 @@ impl Feed {
}
impl FeedRelease {
/// The release with only the downloads that belong to `repo` (or sit under `mirror`). `None`
/// when it doesn't hold up (no tag, or not a version).
fn to_release(&self, repo: &str, mirror: Option<&str>) -> Option<Release> {
/// The release with only the downloads that belong to `repo`. `None` when it doesn't hold up
/// (no tag, or not a version).
fn to_release(&self, repo: &str) -> Option<Release> {
let version = version::parse(&self.tag)?;
let prefix = release::download_prefix(repo);
let allowed = |url: &str| url.starts_with(&prefix) || mirror.is_some_and(|m| url.starts_with(m) && !url.contains(".."));
let releases_page = format!("https://github.com/{repo}/releases/");
let assets = self
.assets
.iter()
.filter(|(name, _, url, _)| release::valid_file_name(name) && allowed(url) && !url.contains(['?', '#', '\\']))
.filter(|(name, _, url, _)| release::valid_file_name(name) && url.starts_with(&prefix) && !url.contains(['?', '#', '\\']))
.map(|(name, size, url, sha)| Asset {
name: name.clone(),
size: *size,
@ -366,12 +347,10 @@ struct RawRepoRename {
}
impl RawRepoRename {
/// `None` unless both names are `owner/name` under [`GITHUB_OWNER`]. A feed that could send
/// an app's downloads to someone else's repository would make its checksums worthless (#90).
fn into_rename(self) -> Option<RepoRename> {
let ok = |r: &str| {
let mut parts = r.split('/');
matches!((parts.next(), parts.next(), parts.next()), (Some(a), Some(b), None) if a.eq_ignore_ascii_case(GITHUB_OWNER) && !b.is_empty()
matches!((parts.next(), parts.next(), parts.next()), (Some(a), Some(b), None) if !a.is_empty() && !b.is_empty()
&& r.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_./".contains(&c)))
};
(ok(&self.old_name) && ok(&self.new_name)).then(|| RepoRename {
@ -528,60 +507,18 @@ mod tests {
let moved = sample()
.replace(
"\"repo_renames\": []",
r#""repo_renames": [{"old_name": "Storytold/photocraft", "new_name": "storytold/photocraft-app", "renamed_at": null}]"#,
)
.replace(
"\"name\": \"PhotoCraft\", \"repo\": \"storytold/photocraft\"",
"\"name\": \"PhotoCraft\", \"repo\": \"storytold/photocraft-app\"",
)
.replace("github.com/storytold/photocraft/releases", "github.com/storytold/photocraft-app/releases");
let feed = Feed::parse(&moved).unwrap();
assert_eq!(feed.repo_for(app("photocraft")), "storytold/photocraft-app");
let FeedAnswer::Fresh(Some(r)) = feed.answer(app("photocraft"), NOW) else { panic!() };
assert!(r.assets[0].url.starts_with("https://github.com/storytold/photocraft-app/releases/download/"));
// The same entry without the rename: the repo doesn't match, so GitHub decides.
let unannounced = moved.replace(r#"{"old_name": "Storytold/photocraft", "new_name": "storytold/photocraft-app", "renamed_at": null}"#, "");
let feed = Feed::parse(&unannounced).unwrap();
assert_eq!(feed.answer(app("photocraft"), NOW), FeedAnswer::AskGitHub);
}
#[test]
fn a_rename_out_of_storytold_is_dropped() {
// Even a feed that vouches for the new repository, with matching links and checksums,
// can't send an app's downloads to another account (#90).
let moved = sample()
.replace(
"\"repo_renames\": []",
r#""repo_renames": [{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null},
{"old_name": "art/photocraft", "new_name": "storytold/photocraft", "renamed_at": null},
{"old_name": "storytold/photocraft", "new_name": "storytold.evil.example/photocraft", "renamed_at": null}]"#,
r#""repo_renames": [{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null}]"#,
)
.replace("\"name\": \"PhotoCraft\", \"repo\": \"storytold/photocraft\"", "\"name\": \"PhotoCraft\", \"repo\": \"art/photocraft\"")
.replace("github.com/storytold/photocraft/releases", "github.com/art/photocraft/releases");
let feed = Feed::parse(&moved).unwrap();
assert!(feed.repo_renames.is_empty(), "{:?}", feed.repo_renames);
assert_eq!(feed.repo_for(app("photocraft")), "storytold/photocraft");
assert_eq!(feed.answer(app("photocraft"), NOW), FeedAnswer::AskGitHub);
}
#[test]
fn a_local_mirror_is_trusted_only_when_asked_for_and_never_goes_stale() {
const MIRROR: &str = "http://127.0.0.1:8787/";
let local = sample().replace("https://github.com/storytold/photocraft/releases/download/v0.6.0/", "http://127.0.0.1:8787/photocraft/v0.6.0/");
// Without the mirror, the local link is dropped like any other stranger's.
let FeedAnswer::Fresh(Some(r)) = Feed::parse(&local).unwrap().answer(app("photocraft"), NOW) else { panic!() };
assert!(r.assets.is_empty());
let feed = Feed::parse(&local).unwrap().with_mirror(MIRROR);
assert_eq!(feed.repo_for(app("photocraft")), "art/photocraft");
let FeedAnswer::Fresh(Some(r)) = feed.answer(app("photocraft"), NOW) else { panic!() };
assert_eq!(r.assets.len(), 1, "the mirror's file is kept, evil.example's still isn't");
assert_eq!(r.assets[0].url, "http://127.0.0.1:8787/photocraft/v0.6.0/photocraft-0.6.0-macos-universal.dmg");
// A week-old sync still counts.
assert!(matches!(feed.answer(app("photocraft"), NOW + 7 * 86_400), FeedAnswer::Fresh(Some(_))));
// Escaping the mirror's folder isn't allowed, and a base without its trailing slash isn't a mirror.
let escaping = local.replace("http://127.0.0.1:8787/photocraft/", "http://127.0.0.1:8787/../photocraft/");
let FeedAnswer::Fresh(Some(r)) = Feed::parse(&escaping).unwrap().with_mirror(MIRROR).answer(app("photocraft"), NOW) else { panic!() };
assert!(r.assets.is_empty());
assert_eq!(Feed::default().with_mirror("http://127.0.0.1:8787").mirror, None);
assert!(r.assets[0].url.starts_with("https://github.com/art/photocraft/releases/download/"));
// The same entry without the rename: the repo doesn't match, so GitHub decides.
let unannounced = moved.replace(r#"{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null}"#, "");
let feed = Feed::parse(&unannounced).unwrap();
assert_eq!(feed.answer(app("photocraft"), NOW), FeedAnswer::AskGitHub);
}
/// Contract check against what storyteller-web really serves: point `CRAFT_APPS_FEED_SAMPLE`

View File

@ -7,8 +7,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod catalog;
pub mod details;
pub mod distro;
pub mod feed;
pub mod platform;
pub mod protocol;

View File

@ -2,9 +2,6 @@
//!
//! 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`.
//!
//! Labels are English. The UI translates [`PackageKind::label`]; OS and architecture names are
//! shown as they are.
use serde::{Deserialize, Serialize};
@ -34,7 +31,6 @@ pub enum Arch {
X64,
X86,
Arm64,
Riscv64,
}
impl Arch {
@ -43,7 +39,6 @@ impl Arch {
Self::X64 => "x64",
Self::X86 => "x86",
Self::Arm64 => "ARM64",
Self::Riscv64 => "RISC-V",
}
}
}
@ -120,8 +115,6 @@ impl Platform {
Arch::X86
} else if cfg!(target_arch = "aarch64") {
Arch::Arm64
} else if cfg!(target_arch = "riscv64") {
Arch::Riscv64
} else {
return None;
};
@ -142,11 +135,9 @@ impl Platform {
(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::Windows, _) => &[],
(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::Riscv64) => &["linux-riscv64"],
(Os::Linux, Arch::X86) => &[],
(Os::FreeBsd, Arch::X64) => &["freebsd-x86_64"],
(Os::FreeBsd, _) => &[],
@ -240,7 +231,6 @@ mod tests {
"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-riscv64.tar.gz",
"photocraft-0.5.0-linux-x86_64.AppImage",
"photocraft-0.5.0-linux-x86_64.tar.gz",
"photocraft-0.5.0-macos-universal.dmg",
@ -263,9 +253,6 @@ mod tests {
{
assert_eq!(parse_asset_name(other, &["photocraft"]), None, "{other}");
}
// FigCraft also ships its collaboration server; that tarball is not the app.
assert_eq!(parse_asset_name("figcraft-server-0.5.1-linux-x86_64.tar.gz", &["figcraft"]), None);
assert!(parse_asset_name("figcraft-0.5.1-linux-x86_64.tar.gz", &["figcraft"]).is_some());
}
#[test]
@ -278,7 +265,6 @@ mod tests {
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::Linux, Arch::Riscv64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-linux-riscv64.tar.gz"));
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!(
@ -286,60 +272,5 @@ mod tests {
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"));
let p_riscv = Platform { os: Os::Linux, arch: Arch::Riscv64 };
assert_eq!(
pick_asset(&r, &["photocraft"], p_riscv, p_riscv.default_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.tar.gz")
);
assert_eq!(
pick_asset(&r, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.tar.gz")
);
let r_riscv_deb =
release(&["photocraft-0.5.0-linux-riscv64.tar.gz", "photocraft-0.5.0-linux-riscv64.deb", "photocraft-0.5.0-linux-riscv64.rpm"]);
assert_eq!(
pick_asset(&r_riscv_deb, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.deb")
);
}
#[test]
fn strictly_matches_architecture_for_all_linux_package_formats() {
let multi_arch_release = release(&[
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-x86_64.AppImage",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-x86_64.deb",
"photocraft-0.5.0-linux-aarch64.rpm",
"photocraft-0.5.0-linux-x86_64.rpm",
"photocraft-0.5.0-linux-aarch64.flatpak",
"photocraft-0.5.0-linux-x86_64.flatpak",
"photocraft-0.5.0-linux-aarch64.tar.gz",
"photocraft-0.5.0-linux-x86_64.tar.gz",
]);
let p_x64 = Platform { os: Os::Linux, arch: Arch::X64 };
let p_arm64 = Platform { os: Os::Linux, arch: Arch::Arm64 };
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz] {
let pick_x64 = pick_asset(&multi_arch_release, &["photocraft"], p_x64, &[kind]).map(|(a, _)| a.name.clone());
assert!(pick_x64.as_deref().unwrap().contains("linux-x86_64"), "Expected x86_64 asset for {kind:?}, got {pick_x64:?}");
let pick_arm64 = pick_asset(&multi_arch_release, &["photocraft"], p_arm64, &[kind]).map(|(a, _)| a.name.clone());
assert!(pick_arm64.as_deref().unwrap().contains("linux-aarch64"), "Expected aarch64 asset for {kind:?}, got {pick_arm64:?}");
}
// On x86_64 Linux, an aarch64-only asset must NEVER match or fall back
let arm_only_release = release(&[
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-aarch64.rpm",
"photocraft-0.5.0-linux-aarch64.flatpak",
"photocraft-0.5.0-linux-aarch64.tar.gz",
]);
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz] {
assert_eq!(pick_asset(&arm_only_release, &["photocraft"], p_x64, &[kind]), None, "x86_64 must never match an aarch64 asset for {kind:?}");
}
}
}

View File

@ -37,18 +37,12 @@ pub enum Command {
Pause {
app: String,
},
/// Stop a running download, or throw away a paused one or one kept after a failed install.
/// Stop a running download, or throw away a paused one.
Cancel {
app: String,
},
/// Download the newer ArtCraft Launcher, put it in place and restart into it.
UpdateLauncher,
/// Fetch the pictures of an app's details page (`core::details`), one [`Event::Screenshot`]
/// each, the lead picture first. Pictures already saved on this machine arrive without
/// touching the network. Asking again while they are on their way does nothing.
FetchScreenshots {
app: String,
},
/// Change how the backend behaves.
Configure(BackendOptions),
}
@ -85,8 +79,6 @@ pub enum JobPhase {
total: u64,
},
Verifying,
/// The system's installer is busy with another app; this one goes next.
Waiting,
/// Waiting on the OS installer (MSI) or copying files.
Installing,
Removing,
@ -104,9 +96,6 @@ pub struct PausedDownload {
pub const PAUSED: &str = "paused";
/// The `JobFinished` error a cancelled job ends with (not a failure).
pub const CANCELLED: &str = "cancelled";
/// The `JobFinished` error of an install whose system prompt (password, Windows Installer) the
/// user dismissed after the download finished (not a failure).
pub const INSTALL_CANCELLED: &str = "install cancelled";
/// Which service an app's release information came from (the footer names them).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@ -182,24 +171,11 @@ pub enum Event {
app: String,
download: Option<PausedDownload>,
},
/// Whether a finished download is waiting because its install step failed; `Install` checks
/// it again and goes straight to installing.
ReadyToInstall {
app: String,
ready: bool,
},
/// A job ended: `Ok` carries a short success message, `Err` what went wrong.
JobFinished {
app: String,
result: Result<String, String>,
},
/// One picture of an app's details page, or why it couldn't be had (offline, not published,
/// not a JPEG). `key` is the [`crate::details::Shot::key`].
Screenshot {
app: String,
key: String,
result: Result<Vec<u8>, String>,
},
CheckStarted,
CheckFinished {
/// Unix seconds.

View File

@ -1,37 +1,4 @@
//! Just enough calendar to read GitHub's timestamps and show them, without a date crate.
//!
//! # Internationalisation
//! [`format_ago`] and [`format_date`] are English-only fallbacks. Translatable UI must use
//! [`ago_kind`] plus [`month_short_en`] (or [`civil_from_unix`] directly) and render through
//! the UI crate's `tl!` / `trn` / `fmt` helpers, so this crate stays pure and wasm-safe with
//! no dependency on `ui-egui`:
//! - [`TimeAgo::JustNow`] → `tl!("just now")`
//! - [`TimeAgo::Minutes(n)`] → `trn(n, "{n} min ago", "{n} min ago")`
//! - [`TimeAgo::Hours(n)`] → `trn(n, "{n} h ago", "{n} h ago")`
//! - [`TimeAgo::Yesterday`] → `tl!("yesterday")`
//! - [`TimeAgo::Days(n)`] → `trn(n, "{n} day ago", "{n} days ago")`
//! - [`TimeAgo::Date(secs)`] → `fmt(tl!("{month} {day}, {year}"), …)` with the month from
//! [`month_short_en`] translated via `tr_ctx("month", …)` and `{day}` / `{year}` filled in.
/// What a relative timestamp means, without any language attached.
///
/// The UI translates each variant (`tl!` / `trn` / `fmt`); see the module docs. Counts are
/// non-negative. [`TimeAgo::Date`] carries the original Unix time for the date fallback.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TimeAgo {
/// Less than a minute old (or in the future).
JustNow,
/// Minutes old (1–59).
Minutes(u64),
/// Hours old (1–23).
Hours(u64),
/// Between one and two days old.
Yesterday,
/// Days old (2–29).
Days(u64),
/// Thirty days or older: show the calendar date; carries the original Unix time.
Date(i64),
}
/// 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> {
@ -94,51 +61,23 @@ pub fn civil_from_unix(secs: i64) -> (i64, u32, u32) {
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/// English three-letter month abbreviation for month `m` (1–12); `"?"` when out of range.
/// Source key for `tr_ctx("month", …)` at the call site; never shown untranslated in
/// non-English UI.
pub fn month_short_en(m: u32) -> &'static str {
if (1..=12).contains(&m) { MONTHS.get((m - 1) as usize).copied().unwrap_or("?") } else { "?" }
}
/// `Oct 8, 2026`
pub fn format_date(secs: i64) -> String {
let (y, m, d) = civil_from_unix(secs);
let month = month_short_en(m);
let month = MONTHS.get(m.saturating_sub(1) as usize).copied().unwrap_or("?");
format!("{month} {d}, {y}")
}
/// Classify a timestamp for translation-neutral rendering (no allocation, no English).
/// Thresholds match [`format_ago`]: <60s → [`TimeAgo::JustNow`], <1h → minutes,
/// <1d → hours, <2d → yesterday, <30d → days, older → [`TimeAgo::Date`].
pub fn ago_kind(secs: i64, now: i64) -> TimeAgo {
let dt = now.saturating_sub(secs);
if dt < 60 {
TimeAgo::JustNow
} else if dt < 3600 {
TimeAgo::Minutes(u64::try_from(dt / 60).unwrap_or(0))
} else if dt < 86_400 {
TimeAgo::Hours(u64::try_from(dt / 3600).unwrap_or(0))
} else if dt < 172_800 {
TimeAgo::Yesterday
} else if dt < 2_592_000 {
TimeAgo::Days(u64::try_from(dt / 86_400).unwrap_or(0))
} else {
TimeAgo::Date(secs)
}
}
/// `just now`, `5 min ago`, `3 h ago`, `yesterday`, `4 days ago`, or the date.
///
/// English-only fallback; translatable UI should match on [`ago_kind`] instead.
pub fn format_ago(secs: i64, now: i64) -> String {
match ago_kind(secs, now) {
TimeAgo::JustNow => "just now".into(),
TimeAgo::Minutes(n) => format!("{n} min ago"),
TimeAgo::Hours(n) => format!("{n} h ago"),
TimeAgo::Yesterday => "yesterday".into(),
TimeAgo::Days(n) => format!("{n} days ago"),
TimeAgo::Date(s) => format_date(s),
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),
}
}
@ -169,44 +108,4 @@ mod tests {
assert_eq!(format_ago(now - 5 * 86_400, now), "5 days ago");
assert_eq!(format_ago(now - 90 * 86_400, now), "Jul 10, 2026");
}
#[test]
fn ago_kind_matches_the_english_fallback() {
let now = 1_791_471_170;
let cases: [(i64, TimeAgo, &str); 7] = [
(5, TimeAgo::JustNow, "just now"),
(600, TimeAgo::Minutes(10), "10 min ago"),
(7200, TimeAgo::Hours(2), "2 h ago"),
(90_000, TimeAgo::Yesterday, "yesterday"),
(5 * 86_400, TimeAgo::Days(5), "5 days ago"),
(90 * 86_400, TimeAgo::Date(now - 90 * 86_400), "Jul 10, 2026"),
(-30, TimeAgo::JustNow, "just now"),
];
for (delta, kind, english) in cases {
assert_eq!(ago_kind(now - delta, now), kind, "{english}");
assert_eq!(format_ago(now - delta, now), english);
}
// Boundaries.
assert_eq!(ago_kind(now - 59, now), TimeAgo::JustNow);
assert_eq!(ago_kind(now - 60, now), TimeAgo::Minutes(1));
assert_eq!(ago_kind(now - 3599, now), TimeAgo::Minutes(59));
assert_eq!(ago_kind(now - 3600, now), TimeAgo::Hours(1));
assert_eq!(ago_kind(now - 86_399, now), TimeAgo::Hours(23));
assert_eq!(ago_kind(now - 86_400, now), TimeAgo::Yesterday);
assert_eq!(ago_kind(now - 172_799, now), TimeAgo::Yesterday);
assert_eq!(ago_kind(now - 172_800, now), TimeAgo::Days(2));
assert_eq!(ago_kind(now - 2_591_999, now), TimeAgo::Days(29));
assert!(matches!(ago_kind(now - 2_592_000, now), TimeAgo::Date(_)));
}
#[test]
fn month_keys_cover_the_calendar() {
let names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
for (i, name) in names.iter().enumerate() {
let m = u32::try_from(i + 1).unwrap_or(1);
assert_eq!(month_short_en(m), *name);
}
assert_eq!(month_short_en(0), "?");
assert_eq!(month_short_en(13), "?");
}
}

View File

@ -10,12 +10,6 @@ publish = false
[lints]
workspace = true
[features]
# Lets ARTCRAFT_LAUNCHER_DOWNLOAD_BASE point the launcher at a developer's own loopback mirror
# (`cargo xtask local serve`). Development only: release builds never enable it, so a shipped
# launcher can't be steered away from GitHub's releases by an environment variable.
local-mirror = []
[dependencies]
artcraft-launcher-core = { workspace = true }
serde = { workspace = true }
@ -23,9 +17,10 @@ serde_json = { workspace = true }
log = { workspace = true }
dirs = "6"
sha2 = "0.10"
# HTTPS over rustls, with the OS verifying certificates (rustls-platform-verifier). No bundled
# root list: webpki-roots and webpki-root-certs are CDLA-licensed.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring", "gzip", "platform-verifier"] }
# 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"

View File

@ -9,8 +9,7 @@ use artcraft_launcher_core::catalog::{LAUNCHER, LAUNCHER_ID};
use artcraft_launcher_core::feed::{Feed, FeedAnswer};
use artcraft_launcher_core::platform::{Arch, Os, Platform, pick_asset};
use artcraft_launcher_core::protocol::{
Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, LatestRelease, Origin, PAUSED, PausedDownload,
Source,
Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PAUSED, PausedDownload, Source,
};
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::{CraftApp, Installed, PackageKind, SUITE, version};
@ -22,9 +21,6 @@ use crate::manifest::Manifest;
use crate::paths::Paths;
use crate::source::{GitHubSource, ReleaseSource};
/// The longest the launcher's own update waits for other installs to end before it gives up
/// (it can be cancelled sooner).
const RESTART_WAIT: Duration = Duration::from_secs(10 * 60);
/// Automatic checks closer together than this reuse the last answers. The UI asks every five
/// minutes; this stops a burst of requests (refresh, focus, timers) from repeating work.
const FRESH_FOR: i64 = 4 * 60;
@ -55,8 +51,6 @@ struct Shared {
latest: Mutex<HashMap<String, Release>>,
installed: Mutex<HashMap<String, Installed>>,
jobs: Mutex<HashMap<String, Arc<Stop>>>,
/// The launcher is replacing itself and will restart: no new job starts.
restarting: AtomicBool,
manifest: Mutex<Manifest>,
options: Mutex<BackendOptions>,
checking: AtomicBool,
@ -77,8 +71,6 @@ struct Shared {
feed_ok_at: Mutex<Option<i64>>,
/// How often ArtCraft's API may be called.
feed_throttle: Mutex<FeedThrottle>,
/// Apps whose details pictures are on their way (a second request meanwhile does nothing).
pictures_busy: Mutex<std::collections::HashSet<&'static str>>,
}
impl Shared {
@ -121,7 +113,6 @@ impl NativeBackend {
latest: Mutex::new(HashMap::new()),
installed: Mutex::new(HashMap::new()),
jobs: Mutex::new(HashMap::new()),
restarting: AtomicBool::new(false),
manifest: Mutex::new(Manifest::load(&paths.manifest())),
options: Mutex::new(BackendOptions::default()),
checking: AtomicBool::new(false),
@ -133,7 +124,6 @@ impl NativeBackend {
feed_failures: Mutex::new(0),
feed_ok_at: Mutex::new(None),
feed_throttle: Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now())),
pictures_busy: Mutex::new(std::collections::HashSet::new()),
last_check: Mutex::new(None),
});
let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() };
@ -179,9 +169,8 @@ impl NativeBackend {
let platform = self.platform;
let options = self.options();
std::thread::spawn(move || {
// Leftovers from interrupted installs or older versions of the launcher. A paused
// download stays a month; a finished one kept after a failed install, a week.
http::clean_downloads(&paths.downloads(), Duration::from_secs(30 * 86_400), Duration::from_secs(7 * 86_400));
// Leftovers from interrupted installs or older versions of the launcher.
http::clean_downloads(&paths.downloads(), Duration::from_secs(30 * 86_400));
let snapshot = crate::detect::Snapshot::take();
let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
for app in &SUITE {
@ -224,15 +213,10 @@ impl NativeBackend {
}
let shared = self.shared.clone();
let paths = self.paths.clone();
let platform = self.platform;
let pre = self.options().prereleases;
let mirror = local_mirror();
std::thread::spawn(move || {
shared.emit(Event::CheckStarted);
let (feed, fallback) = if let Some(base) = &mirror {
// A local mirror answers for everything: GitHub is never asked.
(local_feed(&http, base), Fallback { trust_feed: true, github_every: GITHUB_FALLBACK_EVERY })
} else if pre {
let (feed, fallback) = if pre {
// The feed serves stable releases only; with pre-releases on, GitHub decides.
(None, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY })
} else {
@ -249,10 +233,7 @@ impl NativeBackend {
scope.spawn(|| {
loop {
let Some(app) = queue.lock().unwrap_or_else(PoisonError::into_inner).pop() else { break };
let (latest, from, used) = match &mirror {
Some(_) => mirror_latest(app, feed.as_ref(), now),
None => latest_for(app, feed.as_ref(), fallback, source.as_ref(), &shared, pre, force, now),
};
let (latest, from, used) = latest_for(app, feed.as_ref(), fallback, source.as_ref(), &shared, pre, force, now);
if let Some(used) = used {
sources.lock().unwrap_or_else(PoisonError::into_inner).push(used);
}
@ -287,11 +268,6 @@ impl NativeBackend {
shared.loaded.store(true, Ordering::SeqCst);
}
}
// A newer release changes which file an install would use.
let options = shared.options.lock().unwrap_or_else(PoisonError::into_inner).clone();
for app in &SUITE {
report_partial(app, &shared, &paths, platform, &options);
}
let sources = sources.into_inner().unwrap_or_else(PoisonError::into_inner);
let source_used = if sources.contains(&Source::Cache) {
Source::Cache
@ -305,30 +281,19 @@ impl NativeBackend {
let at = crate::now();
*shared.last_check.lock().unwrap_or_else(PoisonError::into_inner) = Some(at);
shared.checking.store(false, Ordering::SeqCst);
let rate_limited_until = limit_notice(source_used, source.rate_limited_until());
shared.emit(Event::CheckFinished { at, source: source_used, rate_limited_until });
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 };
log::info!("{}: starting {} job", app.name, if uninstall { "uninstall" } else { "install/update" });
let stop = Arc::new(Stop::default());
let restarting = {
{
let mut jobs = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner);
if jobs.contains_key(app.id) {
return;
}
let restarting = self.shared.restarting.load(Ordering::SeqCst);
if !restarting {
jobs.insert(app.id.to_string(), stop.clone());
}
restarting
};
if restarting {
let result = Err("ArtCraft Launcher is restarting to finish its update. Try again once it's back.".into());
self.shared.emit(Event::JobFinished { app: app.id.to_string(), result });
return;
jobs.insert(app.id.to_string(), stop.clone());
}
let shared = self.shared.clone();
let paths = self.paths.clone();
@ -340,9 +305,8 @@ impl NativeBackend {
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 });
let mut phase = |phase| shared.emit(Event::Progress { app: app.id.to_string(), phase });
match &existing {
Some(inst) => install::uninstall(app, inst, record.as_ref(), &paths, &stop, &mut phase).map(|()| {
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());
@ -365,17 +329,6 @@ impl NativeBackend {
// 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());
// One installer call can cover several apps, and fail after some of them went in.
let latest = shared.latest.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned();
let result = match (result, latest) {
(Err(e), Some(release))
if !uninstall && release.version.as_ref().is_some_and(|v| landed_despite(&e, existing.as_ref(), found.as_ref(), v)) =>
{
log::warn!("{}: {e}; the new version is installed all the same", app.id);
Ok(installed_text(app, existing.is_some(), &release))
}
(result, _) => result,
};
{
let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);
match &found {
@ -419,21 +372,14 @@ impl NativeBackend {
last_emit = Instant::now();
shared.emit(Event::Progress { app: LAUNCHER_ID.to_string(), phase: p });
};
let last = || {
let mut waiting = || shared.emit(Event::Progress { app: LAUNCHER_ID.to_string(), phase: JobPhase::Waiting });
claim_restart(&shared.jobs, &shared.restarting, &stop, RESTART_WAIT, &mut waiting)
};
let result = match http.as_ref() {
Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase, &last),
Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase),
None => Err("secure connections aren't available on this system".into()),
};
shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).remove(LAUNCHER_ID);
match result {
Ok(()) => shared.emit(Event::LauncherRestarting),
Err(e) => {
shared.restarting.store(false, Ordering::SeqCst);
shared.emit(Event::JobFinished { app: LAUNCHER_ID.to_string(), result: Err(e) });
}
Err(e) => shared.emit(Event::JobFinished { app: LAUNCHER_ID.to_string(), result: Err(e) }),
}
});
}
@ -479,7 +425,7 @@ fn run_install(
shared.emit(Event::Progress { app: app.id.to_string(), phase: p });
};
let mut job = Job { http, paths, stop, phase: &mut phase };
let new_record = install::install(app, &release, &asset, kind, existing, record, &mut job)?;
let (new_record, verified) = 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 {
@ -494,76 +440,12 @@ fn run_install(
log::warn!("couldn't save the install record: {e}");
}
}
Ok(installed_text(app, existing.is_some(), &release))
}
/// What a finished install tells the user.
fn installed_text(app: &CraftApp, update: bool, release: &Release) -> String {
let verb = if update { "updated to" } else { "installed:" };
format!("{} {verb} {}", app.name, release.version_label())
}
/// Whether an install whose job failed with `error` went in all the same. Only a failed call
/// shared by several apps (Linux packages installed together) can have installed some of them;
/// any other error, a cancellation or a pause stands, whatever is found afterwards.
fn landed_despite(error: &str, before: Option<&Installed>, after: Option<&Installed>, latest: &version::Version) -> bool {
error != PAUSED && error != CANCELLED && error != INSTALL_CANCELLED && install::failed_in_shared_call(error) && landed(before, after, latest)
}
/// Whether an install took, whatever its job reported: `latest` is there now and wasn't before.
fn landed(before: Option<&Installed>, after: Option<&Installed>, latest: &version::Version) -> bool {
let has_latest = |copy: Option<&Installed>| copy.and_then(|i| i.version.as_ref()).is_some_and(|v| !version::is_update(latest, v));
has_latest(after) && !has_latest(before)
}
/// For the launcher's own update, before it replaces itself: waits (saying so through `waiting`)
/// until every other job has ended, then sets `restarting`, which keeps new jobs from starting.
/// The restart that follows would otherwise cut their installs short. The user can cancel the
/// wait (`stop`); after `limit` it gives up and names the apps still busy, since one may be
/// stuck behind a password prompt nobody has answered.
fn claim_restart(
jobs: &Mutex<HashMap<String, Arc<Stop>>>,
restarting: &AtomicBool,
stop: &Stop,
limit: Duration,
waiting: &mut dyn FnMut(),
) -> Result<(), String> {
let started = Instant::now();
let mut told = false;
loop {
stop.check()?;
let busy: Vec<&'static str> = {
let jobs = jobs.lock().unwrap_or_else(PoisonError::into_inner);
let busy = jobs.keys().filter(|id| *id != LAUNCHER_ID).map(|id| CraftApp::by_id(id).map_or("another app", |a| a.name)).collect();
if jobs.keys().all(|id| id == LAUNCHER_ID) {
restarting.store(true, Ordering::SeqCst);
return Ok(());
}
busy
};
if started.elapsed() >= limit {
return Err(restart_blocked(busy));
}
if !told {
told = true;
waiting();
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// Why the launcher didn't restart: `busy` apps were still installing, and what to do about it.
fn restart_blocked(mut busy: Vec<&str>) -> String {
busy.sort_unstable();
busy.dedup();
let (names, were, them) = match busy.as_slice() {
[one] => ((*one).to_string(), "was", "it"),
_ => (busy.join(", "), "were", "them"),
let verb = if existing.is_some() { "updated to" } else { "installed:" };
let note = match verified {
http::Verified::Checksum => "",
http::Verified::SizeOnly => " (this release publishes no checksum, so only its size was checked)",
};
format!(
"ArtCraft Launcher didn't restart to update: {names} {were} still installing. Answer or close any password prompt, \
let {them} finish or cancel {them}, then press Update and restart again."
)
Ok(format!("{} {verb} {}{note}", app.name, release.version_label()))
}
/// The file an install of `app` would download now: same format as the installed copy, else
@ -581,28 +463,20 @@ fn planned_asset(
pick_asset(release, &stems, platform, &kinds).map(|(a, k)| (a.clone(), k))
}
/// Tells the UI whether `app` has a paused download waiting, or a finished one to install.
/// Tells the UI whether `app` has a paused download waiting.
fn report_partial(app: &CraftApp, shared: &Shared, paths: &Paths, platform: Option<Platform>, options: &BackendOptions) {
let planned = planned_download(app, shared, paths, platform, options);
let download = planned.as_ref().and_then(|(dest, size)| Some(PausedDownload { done: http::partial_bytes(dest, *size)?, total: *size }));
let download = partial_for(app, shared, paths, platform, options).map(|(asset, done)| PausedDownload { done, total: asset.size });
shared.emit(Event::Paused { app: app.id.to_string(), download });
let ready = planned.is_some_and(|(dest, size)| http::finished(&dest, size));
shared.emit(Event::ReadyToInstall { app: app.id.to_string(), ready });
}
/// Where the planned download of `app` is kept, and its size.
fn planned_download(
app: &CraftApp,
shared: &Shared,
paths: &Paths,
platform: Option<Platform>,
options: &BackendOptions,
) -> Option<(std::path::PathBuf, u64)> {
/// The planned download of `app` and how many bytes of it are already on disk.
fn partial_for(app: &CraftApp, shared: &Shared, paths: &Paths, platform: Option<Platform>, options: &BackendOptions) -> Option<(Asset, u64)> {
let release = shared.latest.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned()?;
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 (asset, _) = planned_asset(app, &release, platform?, options, existing.as_ref(), record.as_ref())?;
artcraft_launcher_core::release::valid_file_name(&asset.name).then(|| (install::download_path(paths, &asset), asset.size))
let done = http::partial_bytes(&install::download_path(paths, &asset), asset.size)?;
Some((asset, done))
}
/// How much the feed is trusted in one check, and how freely GitHub may be asked.
@ -698,13 +572,6 @@ fn latest_for(
}
}
/// Until when the footer says GitHub's limit holds details back: only while it does, that is
/// when this check had to settle for the release pages or saved answers. When the feed (or the
/// API itself) answered everything, nothing is missing, however recently GitHub said no.
fn limit_notice(source_used: Source, limited_until: Option<i64>) -> Option<i64> {
limited_until.filter(|_| matches!(source_used, Source::GitHubPages | Source::Cache))
}
/// The repo to ask GitHub about, following the feed's repo renames.
fn feed_repo(feed: Option<&Feed>, app: &CraftApp) -> String {
feed.map_or_else(|| app.repo.to_string(), |f| f.repo_for(app))
@ -779,68 +646,10 @@ fn fetch_feed(http: &Http, paths: &Paths, url: &str, throttle: &Mutex<FeedThrott
fn feed_url() -> String {
std::env::var("ARTCRAFT_LAUNCHER_FEED_URL")
.ok()
.filter(|u| u.starts_with("https://") || is_loopback(u))
.filter(|u| u.starts_with("https://") || u.starts_with("http://127.0.0.1:") || u.starts_with("http://localhost:"))
.unwrap_or_else(|| artcraft_launcher_core::feed::FEED_URL.to_string())
}
/// `http://<loopback host>:<port>` and an optional path: the host must be exactly 127.0.0.1,
/// localhost or [::1] (no `user@` in front of another host), with a numeric port.
fn is_loopback(url: &str) -> bool {
let Some(rest) = url.strip_prefix("http://") else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let Some((host, port)) = authority.rsplit_once(':') else { return false };
matches!(host, "127.0.0.1" | "localhost" | "[::1]") && !port.is_empty() && port.parse::<u16>().is_ok()
}
/// `ARTCRAFT_LAUNCHER_DOWNLOAD_BASE`: a mirror on this machine (`cargo xtask local serve`) that
/// serves `release_info.json` and the builds it lists. While it's set, releases come only from
/// there (never GitHub or ArtCraft's API), and its download links are trusted. Loopback only, so
/// nothing but the developer's own builds can be installed this way, and only in builds with the
/// `local-mirror` feature: release builds ignore the variable.
fn local_mirror() -> Option<String> {
if !cfg!(feature = "local-mirror") {
return None;
}
let base = std::env::var("ARTCRAFT_LAUNCHER_DOWNLOAD_BASE").ok()?;
let base = base.trim();
if !is_loopback(base) || base.contains(['?', '#', '\\']) {
log::warn!(
"ignoring ARTCRAFT_LAUNCHER_DOWNLOAD_BASE={base}: only http://127.0.0.1:<port>/, http://localhost:<port>/ or http://[::1]:<port>/"
);
return None;
}
Some(if base.ends_with('/') { base.to_string() } else { format!("{base}/") })
}
/// The local mirror's feed: `ARTCRAFT_LAUNCHER_FEED_URL` if set, else `release_info.json` beside
/// the builds. Asked every check (it's on this machine), and never saved over ArtCraft's.
fn local_feed(http: &Http, base: &str) -> Option<Feed> {
let url = std::env::var("ARTCRAFT_LAUNCHER_FEED_URL").ok().filter(|u| is_loopback(u)).unwrap_or_else(|| format!("{base}release_info.json"));
match http.get_feed(&url) {
Ok(body) => {
let feed = Feed::parse(&body).map(|f| f.with_mirror(base));
if feed.is_none() {
log::warn!("the local mirror's feed at {url} is unreadable");
}
feed
}
Err(e) => {
log::warn!("local mirror unavailable: {} (is `cargo xtask local serve` running?)", e.why);
None
}
}
}
/// `app`'s latest release according to the local mirror. An app it doesn't list has no release.
fn mirror_latest(app: &CraftApp, feed: Option<&Feed>, now: i64) -> (Option<LatestRelease>, Origin, Option<Source>) {
let latest = match feed.map(|f| f.answer(app, now)) {
Some(FeedAnswer::Fresh(Some(r))) => LatestRelease::Found(r),
Some(FeedAnswer::Fresh(None) | FeedAnswer::AskGitHub) => LatestRelease::None,
None => LatestRelease::Failed("the local mirror didn't answer".into()),
};
(Some(latest), Origin::ArtCraftApi, Some(Source::Feed))
}
/// The last saved feed answer, if any.
fn load_feed(paths: &Paths) -> Option<Feed> {
std::fs::read_to_string(paths.feed_cache()).ok().and_then(|body| Feed::parse(&body))
@ -861,36 +670,6 @@ fn native_platform() -> Option<Platform> {
Some(p)
}
/// Clears an app's "pictures on their way" mark when the thread fetching them ends, panic or not.
struct PicturesBusy {
shared: Arc<Shared>,
app: &'static str,
}
impl Drop for PicturesBusy {
fn drop(&mut self) {
self.shared.pictures_busy.lock().unwrap_or_else(PoisonError::into_inner).remove(self.app);
}
}
impl NativeBackend {
/// Starts fetching the pictures of an app's details page, unless they are already on their way.
/// An app without a page, or a name that isn't an app, is ignored.
fn fetch_screenshots(&self, app: &str) {
let Some(details) = artcraft_launcher_core::details::AppDetails::by_id(app) else { return };
if !self.shared.pictures_busy.lock().unwrap_or_else(PoisonError::into_inner).insert(details.id) {
return;
}
let (shared, http, paths) = (self.shared.clone(), self.http.clone(), self.paths.clone());
std::thread::spawn(move || {
let _busy = PicturesBusy { shared: shared.clone(), app: details.id };
crate::screenshots::fetch_all(http.as_ref(), &paths, details, |key, result| {
shared.emit(Event::Screenshot { app: details.id.to_string(), key: key.to_string(), result });
});
});
}
}
impl Backend for NativeBackend {
fn send(&mut self, command: Command) {
match command {
@ -907,22 +686,21 @@ impl Backend for NativeBackend {
let running = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app).cloned();
match running {
Some(stop) => stop.cancel(),
// A paused download, or one kept after a failed install: throw the file away.
// A paused download: throw its partial file away.
None => {
if let Some(def) = CraftApp::by_id(&app)
&& let Some((dest, size)) = planned_download(def, &self.shared, &self.paths, self.platform, &self.options())
&& let Some((asset, _)) = partial_for(def, &self.shared, &self.paths, self.platform, &self.options())
{
http::discard(&dest, size);
http::discard_partial(&install::download_path(&self.paths, &asset), asset.size);
}
self.shared.emit(Event::Paused { app: app.clone(), download: None });
self.shared.emit(Event::ReadyToInstall { app, ready: false });
self.shared.emit(Event::Paused { app, download: None });
}
}
}
Command::Launch { app } => {
let Some(def) = CraftApp::by_id(&app) else { return };
let result = match self.installed(&app) {
Some(inst) => install::launch(def, &inst, &self.paths),
Some(inst) => install::launch(def, &inst),
None => Err(format!("{} isn't installed", def.name)),
};
if let Err(e) = result {
@ -939,7 +717,6 @@ impl Backend for NativeBackend {
}
}
Command::UpdateLauncher => self.update_launcher(),
Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
Command::Configure(options) => {
let changed_pre = {
let mut o = self.shared.options.lock().unwrap_or_else(PoisonError::into_inner);
@ -947,11 +724,6 @@ impl Backend for NativeBackend {
*o = options;
changed
};
// Another package format means another file to download.
let options = self.options();
for app in &SUITE {
report_partial(app, &self.shared, &self.paths, self.platform, &options);
}
if changed_pre {
self.check(true);
}
@ -993,9 +765,6 @@ pub fn launcher_download(release: &Release, platform: Platform) -> Option<String
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use artcraft_launcher_core::InstallSource;
use artcraft_launcher_core::protocol::LatestRelease;
@ -1027,7 +796,6 @@ mod tests {
latest: Mutex::new(HashMap::new()),
installed: Mutex::new(HashMap::new()),
jobs: Mutex::new(HashMap::new()),
restarting: AtomicBool::new(false),
manifest: Mutex::new(Manifest::default()),
options: Mutex::new(BackendOptions::default()),
checking: AtomicBool::new(false),
@ -1040,7 +808,6 @@ mod tests {
feed_failures: Mutex::new(0),
feed_ok_at: Mutex::new(None),
feed_throttle: Mutex::new(FeedThrottle::new()),
pictures_busy: Mutex::new(std::collections::HashSet::new()),
}
}
@ -1110,18 +877,6 @@ mod tests {
.unwrap()
}
#[test]
fn the_limit_notice_shows_only_while_details_are_missing() {
let until = Some(NOW + 31 * 60);
// GitHub said no earlier, but the feed has since answered every app: nothing to warn about.
assert_eq!(limit_notice(Source::Feed, until), None);
assert_eq!(limit_notice(Source::GitHubApi, until), None);
// Some apps came from the release pages or saved answers: say when the rest returns.
assert_eq!(limit_notice(Source::GitHubPages, until), until);
assert_eq!(limit_notice(Source::Cache, until), until);
assert_eq!(limit_notice(Source::Cache, None), None);
}
#[test]
fn the_first_poll_tries_once_and_falls_back_at_once() {
// Nothing has ever loaded and the feed didn't answer: GitHub, freely.
@ -1152,25 +907,6 @@ mod tests {
assert_eq!(f, Fallback { trust_feed: false, github_every: GITHUB_FALLBACK_EVERY });
}
#[test]
fn a_local_mirror_answers_for_every_app_on_its_own() {
let photo = CraftApp::by_id("photocraft").unwrap();
let word = CraftApp::by_id("wordcraft").unwrap();
let feed = feed().with_mirror("http://127.0.0.1:8787/");
// Listed: found, even long after the sync.
let (latest, _, _) = mirror_latest(photo, Some(&feed), NOW + 30 * 86_400);
assert!(matches!(latest, Some(LatestRelease::Found(ref r)) if r.tag == "v0.6.0"));
// Not listed: no release (rather than asking GitHub).
assert!(matches!(mirror_latest(word, Some(&feed), NOW).0, Some(LatestRelease::None)));
// Not running: says so.
assert!(matches!(mirror_latest(photo, None, NOW).0, Some(LatestRelease::Failed(_))));
assert!(is_loopback("http://localhost:8787/") && is_loopback("http://[::1]:8787/"));
assert!(!is_loopback("http://192.168.1.2:8787/") && !is_loopback("http://localhost.evil.example/"));
assert!(!is_loopback("http://127.0.0.1:1@evil.example/") && !is_loopback("http://127.0.0.1:8787@evil.example:80/"));
assert!(!is_loopback("http://127.0.0.1/") && !is_loopback("http://127.0.0.1:x/") && !is_loopback("https://127.0.0.1:8787/"));
assert!(is_loopback("http://127.0.0.1:8787") && is_loopback("http://127.0.0.1:8787/release_info.json"));
}
#[test]
fn github_is_only_the_fallback_and_is_asked_rarely() {
let github = CountingGitHub::default();
@ -1209,210 +945,4 @@ mod tests {
let (_, _, used) = latest_for(photo, Some(&feed), LOADED, &github, &fresh_shared, false, false, NOW + 2 * 86_400);
assert_eq!(used, Some(Source::GitHubApi));
}
const APP: &str = "photocraft";
/// A PhotoCraft release with one file per platform and format, so whichever this machine
/// installs is there.
fn release(version: &str) -> Release {
let suffixes = ["-portable.zip", ".tar.gz", ".msi", ".dmg", ".AppImage", ".deb", ".rpm", ".flatpak"];
let tokens =
["windows-x64", "windows-x86", "windows-arm64", "macos-universal", "linux-x86_64", "linux-aarch64", "linux-riscv64", "freebsd-x86_64"];
let assets = tokens
.iter()
.flat_map(|t| suffixes.iter().map(move |s| format!("{APP}-{version}-{t}{s}")))
.map(|name| Asset { url: format!("https://example.invalid/{name}"), name, size: 3, sha256: None })
.collect();
Release { assets, ..Release::default() }
}
/// A backend that knows `release("0.5.0")` and keeps its files under `root`.
fn backend(root: &std::path::Path, platform: Platform, source: Option<Arc<dyn ReleaseSource>>) -> NativeBackend {
let shared = shared();
shared.latest.lock().unwrap().insert(APP.to_string(), release("0.5.0"));
let shared = Arc::new(shared);
NativeBackend { shared, paths: Paths::under(root), http: None, source, platform: Some(platform), self_install: None }
}
/// Reports again, and returns what the UI was last told about a kept download.
fn ready(backend: &mut NativeBackend) -> Option<bool> {
let app = CraftApp::by_id(APP).unwrap();
report_partial(app, &backend.shared, &backend.paths, backend.platform, &BackendOptions::default());
last_ready(backend.poll())
}
fn last_ready(events: Vec<Event>) -> Option<bool> {
events.into_iter().rev().find_map(|e| match e {
Event::ReadyToInstall { app, ready } if app == APP => Some(ready),
_ => None,
})
}
/// Puts the planned download on disk, as a failed install leaves it.
fn keep_file(backend: &NativeBackend) -> std::path::PathBuf {
let app = CraftApp::by_id(APP).unwrap();
let (dest, size) = planned_download(app, &backend.shared, &backend.paths, backend.platform, &BackendOptions::default()).unwrap();
std::fs::create_dir_all(backend.paths.downloads()).unwrap();
std::fs::write(&dest, vec![0u8; size as usize]).unwrap();
dest
}
#[test]
fn a_kept_download_is_reported_and_can_be_thrown_away() {
let Some(platform) = native_platform() else { return };
let dir = tempfile::tempdir().unwrap();
let mut backend = backend(dir.path(), platform, None);
assert_eq!(ready(&mut backend), Some(false));
let dest = keep_file(&backend);
assert_eq!(ready(&mut backend), Some(true));
backend.send(Command::Cancel { app: APP.to_string() });
assert!(!dest.exists());
assert_eq!(last_ready(backend.poll()), Some(false));
}
#[test]
fn changing_the_settings_reports_kept_downloads_again() {
let Some(platform) = native_platform() else { return };
let dir = tempfile::tempdir().unwrap();
let mut backend = backend(dir.path(), platform, None);
keep_file(&backend);
assert_eq!(ready(&mut backend), Some(true));
// The planned file changed under the UI; only a new report can tell it.
backend.shared.latest.lock().unwrap().insert(APP.to_string(), release("0.6.0"));
backend.send(Command::Configure(BackendOptions::default()));
assert_eq!(last_ready(backend.poll()), Some(false));
}
#[test]
fn a_check_that_finds_a_newer_release_ends_the_retry_offer() {
struct Newer;
impl ReleaseSource for Newer {
fn latest(&self, repo: &str, _prereleases: bool) -> crate::source::Answer {
let found = CraftApp::by_id(APP).is_some_and(|a| a.repo == repo);
let latest = if found { LatestRelease::Found(release("0.6.0")) } else { LatestRelease::None };
crate::source::Answer { latest, source: Source::GitHubApi }
}
fn cached(&self, _repo: &str, _prereleases: bool) -> Option<LatestRelease> {
None
}
fn rate_limited_until(&self) -> Option<i64> {
None
}
fn save(&self) {}
}
let Some(platform) = native_platform() else { return };
let dir = tempfile::tempdir().unwrap();
let mut backend = backend(dir.path(), platform, Some(Arc::new(Newer)));
// A check needs a client; with pre-releases on it asks only `Newer`, never ArtCraft's API.
backend.http = Some(Http::new().unwrap());
backend.shared.options.lock().unwrap().prereleases = true;
keep_file(&backend);
assert_eq!(ready(&mut backend), Some(true));
backend.send(Command::CheckForUpdates { force: true });
let mut events = Vec::new();
let deadline = Instant::now() + Duration::from_secs(20);
while !events.iter().any(|e| matches!(e, Event::CheckFinished { .. })) {
assert!(Instant::now() < deadline, "the check never finished");
std::thread::sleep(Duration::from_millis(10));
events.extend(backend.poll());
}
assert_eq!(last_ready(events), Some(false));
}
fn copy(v: &str) -> Installed {
Installed { version: version::parse(v), source: InstallSource::Deb, location: String::new(), package: None }
}
#[test]
fn a_failed_job_counts_as_installed_only_when_the_new_version_arrived() {
let latest = version::parse("0.5.0").unwrap();
// One app of a shared installer call went in before another failed the call.
assert!(landed(Some(&copy("0.4.1")), Some(&copy("0.5.0")), &latest));
assert!(landed(None, Some(&copy("0.5.0")), &latest));
// Nothing changed, so the error stands.
assert!(!landed(Some(&copy("0.4.1")), Some(&copy("0.4.1")), &latest));
assert!(!landed(None, None, &latest));
// It was current before the job ran: the job did not install it.
assert!(!landed(Some(&copy("0.5.0")), Some(&copy("0.5.0")), &latest));
// A copy whose version can't be read proves nothing.
let unreadable = Installed { version: None, ..copy("0.5.0") };
assert!(!landed(Some(&copy("0.4.1")), Some(&unreadable), &latest));
}
#[test]
fn only_a_failed_shared_call_can_count_as_installed() {
let latest = version::parse("0.5.0").unwrap();
let (before, after) = (Some(copy("0.4.1")), Some(copy("0.5.0")));
let landed_after = |error: &str| landed_despite(error, before.as_ref(), after.as_ref(), &latest);
// An app's own failure stands, even if its new version shows up (an MSI or AppImage that
// reported an error, a cancelled or dismissed prompt, a pause).
for error in ["the Windows Installer stopped with error 1603", "couldn't copy the AppImage", CANCELLED, INSTALL_CANCELLED, PAUSED] {
assert!(!landed_after(error), "{error}");
}
// A call shared with other apps may have installed this one before it failed.
let shared = "the system installer stopped with error 100 while handling 2 apps together. Try them one at a time.";
assert_eq!(landed_after(shared), cfg!(target_os = "linux"));
assert!(!landed_despite(shared, before.as_ref(), before.as_ref(), &latest));
}
#[test]
fn the_launcher_gives_up_waiting_and_names_the_busy_apps() {
let jobs = Mutex::new(HashMap::new());
for id in [LAUNCHER_ID, "photocraft"] {
jobs.lock().unwrap().insert(id.to_string(), Arc::new(Stop::default()));
}
let restarting = AtomicBool::new(false);
let error = claim_restart(&jobs, &restarting, &Stop::default(), Duration::from_millis(300), &mut || {}).unwrap_err();
assert!(error.contains("PhotoCraft was still installing") && error.contains("cancel it"), "{error}");
assert!(!restarting.load(Ordering::SeqCst));
assert!(restart_blocked(vec!["WordCraft", "FilmCraft"]).contains("FilmCraft, WordCraft were still installing"));
}
#[test]
fn the_launcher_restarts_only_once_every_other_job_has_ended() {
let jobs = Mutex::new(HashMap::new());
for id in [LAUNCHER_ID, "photocraft", "filmcraft"] {
jobs.lock().unwrap().insert(id.to_string(), Arc::new(Stop::default()));
}
let restarting = AtomicBool::new(false);
let stop = Stop::default();
let (waiting, waits) = mpsc::channel();
std::thread::scope(|scope| {
let (jobs, restarting, stop) = (&jobs, &restarting, &stop);
let claim = scope.spawn(move || claim_restart(jobs, restarting, stop, RESTART_WAIT, &mut || waiting.send(()).unwrap()));
waits.recv().unwrap();
// One install ends; another is still running or queued.
jobs.lock().unwrap().remove("photocraft");
std::thread::sleep(Duration::from_millis(250));
assert!(!claim.is_finished() && !restarting.load(Ordering::SeqCst));
jobs.lock().unwrap().remove("filmcraft");
assert_eq!(claim.join().unwrap(), Ok(()));
});
assert!(restarting.load(Ordering::SeqCst));
assert!(waits.try_recv().is_err(), "it says it is waiting once");
// With nothing else running there is nothing to wait for.
let restarting = AtomicBool::new(false);
assert_eq!(claim_restart(&jobs, &restarting, &stop, RESTART_WAIT, &mut || panic!("it never waits")), Ok(()));
assert!(restarting.load(Ordering::SeqCst));
}
#[test]
fn a_cancelled_launcher_update_stops_waiting_and_claims_nothing() {
let jobs =
Mutex::new(HashMap::from([(LAUNCHER_ID.to_string(), Arc::new(Stop::default())), ("photocraft".to_string(), Arc::new(Stop::default()))]));
let restarting = AtomicBool::new(false);
let stop = Stop::default();
let (waiting, waits) = mpsc::channel();
std::thread::scope(|scope| {
let (jobs, restarting, stop) = (&jobs, &restarting, &stop);
let claim = scope.spawn(move || claim_restart(jobs, restarting, stop, RESTART_WAIT, &mut || waiting.send(()).unwrap()));
waits.recv().unwrap();
stop.cancel();
assert_eq!(claim.join().unwrap(), Err(CANCELLED.to_string()));
});
assert!(!restarting.load(Ordering::SeqCst));
}
}

View File

@ -1,183 +0,0 @@
//! Menu entries (freedesktop `.desktop` files) for AppImages the launcher installs.
//!
//! An AppImage installs nothing by itself, so without an entry the app is missing from the
//! desktop's menu and, on Wayland, the dock can't match the window's app id to an icon. The
//! launcher writes `<data>/applications/ai.storyteller.<app>.desktop` (the id each app's window
//! reports) and marks it as its own, so it only ever replaces or removes entries it wrote.
//!
//! The text is built by pure functions, tested on every platform; only Linux writes the files.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::path::Path;
use artcraft_launcher_core::CraftApp;
/// The key that marks an entry as written by the launcher.
pub const MARKER: &str = "X-ArtCraft-Launcher=true";
/// The menu categories and the window class each app's own `.desktop` file declares.
pub fn meta(app: &CraftApp) -> (&'static str, Option<&'static str>) {
match app.id {
"photocraft" => ("Graphics;2DGraphics;RasterGraphics;Photography;", Some("photocraft")),
"pdfcraft" => ("Office;Viewer;", Some("pdfcraft")),
"lightcraft" => ("Graphics;2DGraphics;RasterGraphics;Photography;", None),
"filmcraft" => ("AudioVideo;Video;AudioVideoEditing;", None),
"designcraft" => ("Graphics;Publishing;", None),
"vectorcraft" => ("Graphics;2DGraphics;VectorGraphics;", None),
"effectcraft" => ("AudioVideo;Video;Graphics;2DGraphics;", None),
"wordcraft" => ("Office;WordProcessor;", None),
"deckcraft" => ("Office;Presentation;", None),
"gridcraft" => ("Office;Spreadsheet;", None),
"cadcraft" => ("Graphics;Engineering;VectorGraphics;", None),
"soundcraft" => ("AudioVideo;Audio;Midi;Mixer;Sequencer;Recorder;", None),
"figcraft" => ("Graphics;2DGraphics;VectorGraphics;X-Design;", Some("figcraft")),
_ => ("Graphics;", None),
}
}
/// `path` as one quoted argument of an `Exec=` key, per the Desktop Entry spec: inside double
/// quotes `"`, `` ` ``, `$` and `\` take a backslash, then the string-value rule doubles every
/// backslash, and `%` (field codes) is written `%%`. `None` for paths that can't be written
/// safely (not UTF-8, or holding a control character such as a line break).
pub fn exec_quote(path: &Path) -> Option<String> {
let s = path.to_str()?;
if s.chars().any(char::is_control) {
return None;
}
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' | '`' | '$' => {
out.push_str("\\\\");
out.push(c);
}
'\\' => out.push_str("\\\\\\\\"),
'%' => out.push_str("%%"),
c => out.push(c),
}
}
out.push('"');
Some(out)
}
/// A localestring/string value: backslashes and line breaks escaped.
fn value(s: &str) -> String {
s.replace('\\', "\\\\").replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t")
}
/// The entry for `app` installed as the AppImage `appimage`; `icon` when its icon is installed
/// under the app's id. `None` when the path can't be put in an `Exec=` line.
pub fn render(app: &CraftApp, appimage: &Path, icon: bool) -> Option<String> {
let exec = exec_quote(appimage)?;
let (categories, wm_class) = meta(app);
let mut text = String::from("[Desktop Entry]\nType=Application\n");
text.push_str(&format!("Name={}\n", value(app.name)));
text.push_str(&format!("Comment={}\n", value(app.tagline)));
text.push_str(&format!("Exec={exec} %F\n"));
if icon {
text.push_str(&format!("Icon={}\n", app.bundle_id()));
}
text.push_str("Terminal=false\n");
text.push_str(&format!("Categories={categories}\n"));
text.push_str(&format!("StartupWMClass={}\n", wm_class.map_or_else(|| app.bundle_id(), str::to_string)));
text.push_str(MARKER);
text.push('\n');
Some(text)
}
/// Whether the launcher wrote this entry.
pub fn is_ours(text: &str) -> bool {
text.lines().any(|l| l.trim() == MARKER)
}
/// The program an entry's `Exec=` runs: its first argument, unquoted. Reads the launcher's own
/// quoting and the plain `Exec="<path>" %F` the apps' AppImages write for themselves.
pub fn exec_program(text: &str) -> Option<String> {
let line = text.lines().find_map(|l| l.strip_prefix("Exec="))?;
// String-value escapes first, then the quoting rule.
let mut unescaped = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('s') => unescaped.push(' '),
Some('n') => unescaped.push('\n'),
Some('t') => unescaped.push('\t'),
Some('r') => unescaped.push('\r'),
Some(other) => unescaped.push(other),
None => unescaped.push('\\'),
}
} else {
unescaped.push(c);
}
}
let mut out = String::new();
let mut chars = unescaped.trim_start().chars();
if unescaped.trim_start().starts_with('"') {
chars.next();
loop {
match chars.next()? {
'"' => break,
'\\' => out.push(chars.next()?),
c => out.push(c),
}
}
} else {
out.extend(chars.take_while(|c| !c.is_whitespace()));
}
Some(out.replace("%%", "%"))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn app(id: &str) -> &'static CraftApp {
CraftApp::by_id(id).unwrap()
}
#[test]
fn writes_a_complete_entry() {
let text = render(app("photocraft"), Path::new("/home/ana/Applications/photocraft-0.6.0-linux-x86_64.AppImage"), true).unwrap();
let expected = format!(
"[Desktop Entry]\nType=Application\nName=PhotoCraft\nComment={}\n\
Exec=\"/home/ana/Applications/photocraft-0.6.0-linux-x86_64.AppImage\" %F\n\
Icon=ai.storyteller.photocraft\nTerminal=false\nCategories=Graphics;2DGraphics;RasterGraphics;Photography;\n\
StartupWMClass=photocraft\nX-ArtCraft-Launcher=true\n",
app("photocraft").tagline
);
assert_eq!(text, expected);
assert!(is_ours(&text));
let no_icon = render(app("wordcraft"), Path::new("/x/w.AppImage"), false).unwrap();
assert!(!no_icon.contains("Icon="));
assert!(no_icon.contains("StartupWMClass=ai.storyteller.wordcraft\n"));
assert!(no_icon.contains("Categories=Office;WordProcessor;\n"));
}
#[test]
fn quotes_exec_paths_per_the_spec() {
assert_eq!(exec_quote(Path::new("/home/ana/My Apps/p.AppImage")).unwrap(), "\"/home/ana/My Apps/p.AppImage\"");
assert_eq!(exec_quote(Path::new("/a/\"q\" $HOME `x`.AppImage")).unwrap(), r#""/a/\\"q\\" \\$HOME \\`x\\`.AppImage""#);
assert_eq!(exec_quote(Path::new(r"/a\b/100%.AppImage")).unwrap(), r#""/a\\\\b/100%%.AppImage""#);
assert_eq!(exec_quote(Path::new("/a/line\nbreak.AppImage")), None);
assert!(render(app("photocraft"), Path::new("/a/line\nbreak.AppImage"), true).is_none());
}
#[test]
fn reads_back_the_program_it_wrote_and_the_apps_own_entries() {
for p in ["/home/ana/My Apps/p.AppImage", "/a/\"q\" $HOME `x`.AppImage", r"/a\b/100%.AppImage", "/plain.AppImage"] {
let text = render(app("gridcraft"), Path::new(p), false).unwrap();
assert_eq!(exec_program(&text).map(PathBuf::from), Some(PathBuf::from(p)), "{p}");
}
// PhotoCraft's AppRun writes its own entry, unmarked.
let theirs = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/ana/Applications/photocraft-0.5.0-linux-x86_64.AppImage\" %F\n";
assert_eq!(exec_program(theirs).as_deref(), Some("/home/ana/Applications/photocraft-0.5.0-linux-x86_64.AppImage"));
assert!(!is_ours(theirs));
assert_eq!(exec_program("[Desktop Entry]\nExec=photocraft %F\n").as_deref(), Some("photocraft"));
assert_eq!(exec_program("[Desktop Entry]\nName=x\n"), None);
}
}

View File

@ -3,13 +3,9 @@
//! - 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` (where the launcher and AppImageLauncher put them),
//! `~/AppImages` (Gear Lever) and `~/.local/bin`, Debian/RPM packages, Flatpaks, and tarball
//! installs.
//! - Linux: AppImages in `~/Applications`, Debian/RPM packages, Flatpaks, and tarball installs.
//! - FreeBSD: tarball installs and `/usr/local/bin/<app>`.
use std::path::{Path, PathBuf};
use artcraft_launcher_core::{CraftApp, InstallSource, Installed, version};
use crate::manifest::Manifest;
@ -70,73 +66,6 @@ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option<Installed> {
})
}
/// Whether a file name is an AppImage of the app with asset stems `stems`, and the version its
/// name gives. Matches only the names a release AppImage ends up with: the release's own
/// (`photocraft-0.6.0-linux-x86_64.AppImage`) and the bare app name Gear Lever renames it to
/// (`photocraft.appimage`, version unknown). The extension's case doesn't matter.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn appimage_version(name: &str, stems: &[&str]) -> Option<Option<String>> {
let split = name.len().checked_sub(".appimage".len())?;
let (base, ext) = (name.get(..split)?, name.get(split..)?);
if !ext.eq_ignore_ascii_case(".appimage") {
return None;
}
if stems.iter().any(|s| base.eq_ignore_ascii_case(s)) {
return Some(None);
}
let parsed = artcraft_launcher_core::platform::parse_asset_name(&format!("{base}.AppImage"), stems)?;
(parsed.kind == artcraft_launcher_core::PackageKind::AppImage && parsed.token.starts_with("linux-")).then_some(Some(parsed.version))
}
/// Who our Windows Installer packages name as their publisher.
#[cfg_attr(not(windows), allow(dead_code))]
const PUBLISHER: &str = "Learning Machines LLC";
/// Whether an uninstall entry's `DisplayName` and `Publisher` are `app`'s, under any name its
/// installers have registered.
#[cfg_attr(not(windows), allow(dead_code))]
fn is_installer_entry(app: &CraftApp, display_name: &str, publisher: &str) -> bool {
publisher == PUBLISHER && app.names().any(|n| n == display_name)
}
/// Where a Windows install of `app` may keep its exe, in the order to try: the entry's
/// `InstallLocation`, then `<App>` under each Program Files root for each name the app has had,
/// and in each folder `<stem>.exe` for each stem, current first. Doesn't touch the disk.
#[cfg_attr(not(windows), allow(dead_code))]
fn exe_candidates(app: &CraftApp, location: Option<&Path>, program_files: &[PathBuf]) -> Vec<PathBuf> {
let in_program_files = program_files.iter().flat_map(|root| app.names().map(move |n| root.join(n)));
let dirs = location.map(Path::to_path_buf).into_iter().chain(in_program_files);
dirs.flat_map(|d| app.stems().map(move |s| d.join(format!("{s}.exe")))).collect()
}
/// Version from `<app> --version` output. Binaries print the machine id (`photocraft 0.7.0`),
/// the display name (`SoundCraft 0.5.0`), or a former id (`printcraft 0.2.1` for PdfCraft).
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
fn parse_cli_version(line: &str, app: &CraftApp) -> Option<version::Version> {
let line = line.trim();
for prefix in app.stems().chain(std::iter::once(app.name)) {
if let Some(rest) = strip_cli_prefix(line, prefix)
&& let Some(tok) = rest.split_whitespace().next()
&& let Some(v) = version::parse(tok)
{
return Some(v);
}
}
version::parse_package(line)
}
fn strip_cli_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
let (head, rest) = line.split_at_checked(prefix.len())?;
if !head.eq_ignore_ascii_case(prefix) {
return None;
}
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_ascii_whitespace() => Some(rest),
_ => None,
}
}
#[cfg(target_os = "macos")]
pub mod macos {
use std::path::{Path, PathBuf};
@ -183,6 +112,7 @@ pub mod windows {
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 {
@ -200,7 +130,7 @@ pub mod windows {
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 !is_installer_entry(app, &name, &publisher) || out.iter().any(|m: &MsiInstall| m.product_code == code) {
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));
@ -220,26 +150,25 @@ pub mod windows {
/// The app's exe: from `App Paths`, the install folder, or Program Files.
fn exe_path(app: &CraftApp, location: Option<PathBuf>) -> Option<PathBuf> {
for stem in app.stems() {
let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{stem}.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 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 roots = Vec::new();
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) {
roots.push(PathBuf::from(pf));
dirs.push(PathBuf::from(pf).join(app.name));
}
}
exe_candidates(app, location.as_deref(), &roots).into_iter().find(|p| p.exists())
dirs.into_iter().map(|d| d.join(&exe)).find(|p| p.exists())
}
pub fn detect(app: &CraftApp) -> Option<Installed> {
@ -255,9 +184,11 @@ pub mod windows {
#[cfg(target_os = "linux")]
pub mod linux {
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::time::Duration;
use artcraft_launcher_core::platform::parse_asset_name;
use super::*;
use crate::packages::{self, Package};
use crate::process::capture;
@ -267,7 +198,7 @@ pub mod linux {
pub fn packages() -> Vec<Package> {
let ours = |name: &str| {
artcraft_launcher_core::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
[InstallSource::Deb, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
@ -283,19 +214,14 @@ pub mod linux {
if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) {
add(InstallSource::Rpm, packages::parse_rpm(&list));
}
// Only run `pacman` where its database exists: elsewhere `pacman` on PATH may be an unrelated program
// (the Pac-Man game in /usr/games on Debian-family systems).
if pacman_db_present(Path::new(PACMAN_DB))
&& let Some(list) = capture("pacman", &["-Q"], t)
{
if let Some(list) = capture("pacman", &["-Q"], t) {
add(InstallSource::Pacman, packages::parse_pacman(&list));
}
let arch = format!("--arch={}", std::env::consts::ARCH);
if let Some(list) = crate::process::capture_flatpak(&["list", "--app", &arch, "--columns=application,version"], t) {
if let Some(list) = capture("flatpak", &["list", "--app", "--columns=application,version"], t) {
let mut flatpaks = packages::parse_flatpak_list(&list);
// Bundles built from a release tarball often list no version: ask Flatpak directly.
for (id, version) in flatpaks.iter_mut().filter(|(id, v)| v.is_empty() && id.starts_with("ai.storyteller.")) {
if let Some(v) = crate::process::capture_flatpak(&["info", &arch, id], t).as_deref().and_then(packages::parse_flatpak_info) {
if let Some(v) = capture("flatpak", &["info", id], t).as_deref().and_then(packages::parse_flatpak_info) {
*version = v;
}
}
@ -304,14 +230,6 @@ pub mod linux {
out
}
/// The local package database of Arch's pacman.
const PACMAN_DB: &str = "/var/lib/pacman/local";
/// True when `db` is a directory, i.e. this system really uses pacman.
pub(crate) fn pacman_db_present(db: &Path) -> bool {
db.is_dir()
}
/// Where a package put the app's binary: on `PATH`, else `/usr/bin/<id>`.
fn binary_on_path(id: &str) -> String {
std::env::var_os("PATH")
@ -326,82 +244,34 @@ pub mod linux {
dirs::home_dir().map(|h| h.join("Applications"))
}
/// Every folder searched for AppImages, the launcher's own first: `~/Applications`,
/// `~/AppImages` (Gear Lever's default) and `~/.local/bin`.
pub fn appimage_dirs() -> Vec<PathBuf> {
let Some(home) = dirs::home_dir() else { return Vec::new() };
vec![home.join("Applications"), home.join("AppImages"), home.join(".local").join("bin")]
}
/// AppImages of `app` in `dirs`, newest first (unknown versions last; earlier folders win
/// ties).
pub fn appimages_in(app: &CraftApp, dirs: &[PathBuf]) -> Vec<(PathBuf, Option<version::Version>)> {
/// 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>)> = Vec::new();
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
let mut found: Vec<(PathBuf, Option<version::Version>)> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let version = appimage_version(&name, &stems)?;
let path = e.path();
path.is_file().then(|| (path, version.as_deref().and_then(version::parse)))
})
.collect();
found.sort_by(|a, b| a.0.cmp(&b.0));
out.extend(found);
}
// Stable sort: equal versions keep the folder order above.
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),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
_ => std::cmp::Ordering::Equal,
});
out
}
/// AppImages of `app` in any of the usual folders, newest first.
pub fn appimages(app: &CraftApp) -> Vec<(PathBuf, Option<version::Version>)> {
appimages_in(app, &appimage_dirs())
}
/// AppImages of `app` in the launcher's own folder only (the ones an update replaces).
pub fn launcher_appimages(app: &CraftApp) -> Vec<PathBuf> {
appimage_dir().map(|d| appimages_in(app, &[d]).into_iter().map(|(p, _)| p).collect()).unwrap_or_default()
}
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(), package: None });
}
let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak];
if let Some(p) = packages::find(app, &snapshot.packages, &order) {
let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) };
return Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) });
}
detect_system_install(app)
}
/// When the package managers don't report the app (a query timed out or was locked, an
/// unlisted package name, or a sandbox that can't see them) but its binary is there. It is
/// reported as a system binary: the launcher can open it but leaves updating and removing it
/// to whatever installed it, since it can't tell which package manager that was.
fn detect_system_install(app: &CraftApp) -> Option<Installed> {
let bin = find_binary(app.id)?;
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
fn find_binary(id: &str) -> Option<PathBuf> {
let on_path = std::env::var_os("PATH").and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file()));
let usual = ["/usr/bin", "/usr/local/bin"].into_iter().map(PathBuf::from).chain(dirs::home_dir().map(|h| h.join(".local/bin")));
on_path.or_else(|| usual.map(|d| d.join(id)).find(|p| p.is_file()))
}
pub(crate) fn binary_version(bin: &Path, app: &CraftApp) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
parse_cli_version(out.trim(), app)
let p = packages::find(app, &snapshot.packages, &order)?;
let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) };
Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) })
}
}
@ -417,196 +287,13 @@ pub mod freebsd {
if !bin.exists() {
return None;
}
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
Some(Installed { version: binary_version(&bin, app.id), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
/// `<app> --version` prints `<app> 0.5.0 (sha, date)`.
fn binary_version(bin: &Path, app: &CraftApp) -> Option<version::Version> {
fn binary_version(bin: &Path, id: &str) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
parse_cli_version(out.trim(), app)
}
}
#[cfg(all(test, target_os = "linux"))]
mod linux_tests {
use super::*;
#[test]
fn ours_filter_matches_rpm_packages() {
let ours = |name: &str| {
artcraft_launcher_core::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
};
assert!(ours("photocraft"));
assert!(ours("ai.storyteller.photocraft"));
assert!(!ours("unrelated-package"));
}
#[test]
fn pacman_is_only_queried_where_its_database_exists() {
let root = tempfile::tempdir().unwrap();
assert!(!linux::pacman_db_present(&root.path().join("local")));
std::fs::create_dir(root.path().join("local")).unwrap();
assert!(linux::pacman_db_present(&root.path().join("local")));
}
#[test]
fn reads_binary_version_from_cli() {
let photo = CraftApp::by_id("photocraft").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let bin = temp_dir.path().join("photocraft");
std::fs::write(&bin, "#!/bin/sh\necho 'photocraft 0.6.0 (0c72d9542, 2026-10-10)'\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let ver = linux::binary_version(&bin, photo);
assert_eq!(ver.unwrap().to_string(), "0.6.0");
}
#[test]
fn reads_binary_version_when_the_cli_uses_the_display_name_or_a_former_id() {
let temp_dir = tempfile::tempdir().unwrap();
use std::os::unix::fs::PermissionsExt;
let write = |name: &str, script: &str| {
let bin = temp_dir.path().join(name);
std::fs::write(&bin, script).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
};
let sound = write("soundcraft", "#!/bin/sh\necho 'SoundCraft 0.5.0'\n");
assert_eq!(linux::binary_version(&sound, CraftApp::by_id("soundcraft").unwrap()).unwrap().to_string(), "0.5.0");
let pdf = write("pdfcraft", "#!/bin/sh\necho 'printcraft 0.2.1'\n");
assert_eq!(linux::binary_version(&pdf, CraftApp::by_id("pdfcraft").unwrap()).unwrap().to_string(), "0.2.1");
}
}
#[cfg(test)]
mod tests {
use artcraft_launcher_core::SUITE;
use super::*;
#[test]
fn recognises_appimage_names_conservatively() {
let pdf = ["pdfcraft", "printcraft"];
let v = |n: &str| appimage_version(n, &pdf);
assert_eq!(v("pdfcraft-0.3.0-linux-x86_64.AppImage"), Some(Some("0.3.0".into())));
assert_eq!(v("printcraft-0.2.1-linux-aarch64.appimage"), Some(Some("0.2.1".into())));
assert_eq!(v("pdfcraft-0.3.0-rc.1-linux-x86_64.AppImage"), Some(Some("0.3.0-rc.1".into())));
// Gear Lever's rename, and a plain rename by hand.
assert_eq!(v("pdfcraft.appimage"), Some(None));
assert_eq!(v("PdfCraft.AppImage"), Some(None));
for other in [
"pdfcraft-0.3.0-linux-x86_64.AppImage.zsync",
"pdfcraft-0.3.0-linux-x86_64.deb",
"pdfcraft-0.3.0-windows-x64.AppImage",
"pdfcraft-cli-0.3.0-linux-x86_64.AppImage",
"photocraft-0.6.0-linux-x86_64.AppImage",
"mypdfcraft.AppImage",
"pdfcraft-latest.AppImage",
".appimage",
"x",
] {
assert_eq!(v(other), None, "{other}");
}
}
#[test]
fn parse_cli_version_accepts_id_display_name_and_former_id() {
let photo = CraftApp::by_id("photocraft").unwrap();
let sound = CraftApp::by_id("soundcraft").unwrap();
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert_eq!(parse_cli_version("photocraft 0.7.0 (dev build)", photo).unwrap().to_string(), "0.7.0");
assert_eq!(parse_cli_version("SoundCraft 0.5.0", sound).unwrap().to_string(), "0.5.0");
assert_eq!(parse_cli_version("printcraft 0.2.1", pdf).unwrap().to_string(), "0.2.1");
assert_eq!(parse_cli_version("PdfCraft 0.6.0", pdf).unwrap().to_string(), "0.6.0");
assert!(strip_cli_prefix("photocraft-cli 1.0.0", "photocraft").is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn finds_appimages_in_every_folder_newest_first() {
let app = CraftApp::by_id("photocraft").unwrap();
let root = tempfile::tempdir().unwrap();
let (apps, gear, bin) = (root.path().join("Applications"), root.path().join("AppImages"), root.path().join("bin"));
for d in [&apps, &gear, &bin] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(apps.join("photocraft-0.5.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::write(apps.join("wordcraft-0.3.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::write(gear.join("photocraft.appimage"), b"").unwrap();
std::fs::write(bin.join("photocraft-0.6.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::create_dir(bin.join("photocraft.AppImage")).unwrap();
let found = linux::appimages_in(app, &[apps.clone(), gear.clone(), bin.clone(), root.path().join("missing")]);
let names: Vec<_> = found.iter().map(|(p, v)| (p.clone(), v.as_ref().map(ToString::to_string))).collect();
assert_eq!(
names,
[
(bin.join("photocraft-0.6.0-linux-x86_64.AppImage"), Some("0.6.0".to_string())),
(apps.join("photocraft-0.5.0-linux-x86_64.AppImage"), Some("0.5.0".to_string())),
(gear.join("photocraft.appimage"), None),
]
);
}
const LEARNING_MACHINES: &str = "Learning Machines LLC";
fn roots() -> Vec<PathBuf> {
vec![PathBuf::from(r"C:\Program Files"), PathBuf::from(r"C:\Program Files (x86)")]
}
#[test]
fn pdfcraft_matches_its_former_printcraft_installer_name() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert!(is_installer_entry(pdf, "PrintCraft", LEARNING_MACHINES), "a PrintCraft entry from {LEARNING_MACHINES} should be PdfCraft's");
}
#[test]
fn pdfcraft_still_matches_its_own_installer_name() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert!(is_installer_entry(pdf, "PdfCraft", LEARNING_MACHINES));
}
#[test]
fn printcraft_entries_match_nothing_else() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert!(!is_installer_entry(pdf, "PrintCraft", "Some Other Publisher"));
assert!(!is_installer_entry(pdf, "PhotoCraft", LEARNING_MACHINES));
for a in SUITE.iter().filter(|a| a.id != "pdfcraft") {
assert!(!is_installer_entry(a, "PrintCraft", LEARNING_MACHINES), "PrintCraft matched {}", a.name);
}
}
#[test]
fn pdfcraft_exe_is_sought_under_every_name_it_has_shipped_as() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
let location = PathBuf::from(r"D:\Apps\PrintCraft");
let roots = roots();
let mut want = vec![location.join("pdfcraft.exe"), location.join("printcraft.exe")];
for root in &roots {
for dir in ["PdfCraft", "PrintCraft"] {
want.push(root.join(dir).join("pdfcraft.exe"));
want.push(root.join(dir).join("printcraft.exe"));
}
}
assert_eq!(exe_candidates(pdf, Some(&location), &roots), want);
}
#[test]
fn apps_without_former_names_match_and_find_their_exe_as_before() {
let roots = roots();
let location = PathBuf::from(r"D:\Apps\Somewhere");
for a in SUITE.iter().filter(|a| a.former_ids.is_empty() && a.former_names.is_empty()) {
assert!(is_installer_entry(a, a.name, LEARNING_MACHINES), "{}", a.name);
assert!(!is_installer_entry(a, a.name, "Some Other Publisher"), "{}", a.name);
assert!(!is_installer_entry(a, &a.name.to_lowercase(), LEARNING_MACHINES), "{}", a.name);
let exe = format!("{}.exe", a.id);
let in_program_files: Vec<PathBuf> = roots.iter().map(|r| r.join(a.name).join(&exe)).collect();
assert_eq!(exe_candidates(a, None, &roots), in_program_files);
let with_location: Vec<PathBuf> = std::iter::once(location.join(&exe)).chain(in_program_files).collect();
assert_eq!(exe_candidates(a, Some(&location), &roots), with_location);
}
let rest = out.trim().strip_prefix(id)?.trim_start();
version::parse(rest.split_whitespace().next()?)
}
}

View File

@ -51,15 +51,10 @@ pub struct FeedError {
impl Http {
pub fn new() -> Result<Self, String> {
// The OS checks each certificate chain itself: Windows fetches roots it hasn't stored yet
// and trusts what antivirus HTTPS scanning installs. Reading the store ourselves missed
// both ("invalid peer certificate: UnknownIssuer").
let tls = || ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build();
// Chosen once: the proxy environment variables, else the OS proxy setting.
let proxy = crate::proxy::pick();
let roots = os_roots()?;
let tls = || ureq::tls::TlsConfig::builder().root_certs(roots.clone()).build();
let small = ureq::Agent::config_builder()
.tls_config(tls())
.proxy(proxy.clone())
.timeout_global(Some(Duration::from_secs(20)))
.http_status_as_error(false)
.user_agent(USER_AGENT)
@ -67,7 +62,6 @@ impl Http {
.new_agent();
let feed = ureq::Agent::config_builder()
.tls_config(tls())
.proxy(proxy.clone())
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_global(Some(Duration::from_secs(10)))
.http_status_as_error(false)
@ -76,7 +70,6 @@ impl Http {
.new_agent();
let no_redirect = ureq::Agent::config_builder()
.tls_config(tls())
.proxy(proxy.clone())
.timeout_global(Some(Duration::from_secs(20)))
.http_status_as_error(false)
.max_redirects(0)
@ -86,7 +79,6 @@ impl Http {
.new_agent();
let download = ureq::Agent::config_builder()
.tls_config(tls())
.proxy(proxy.clone())
.timeout_connect(Some(Duration::from_secs(20)))
.timeout_recv_response(Some(Duration::from_secs(60)))
// ureq has no idle timeout: cap each attempt instead, and resume where it stopped.
@ -126,11 +118,12 @@ impl Http {
404 => Ok(Fetched::NotFound),
403 | 429 => {
let remaining = header("x-ratelimit-remaining");
let reset = header("x-ratelimit-reset").and_then(|r| r.trim().parse::<i64>().ok());
let retry_after = header("retry-after").and_then(|r| r.trim().parse::<i64>().ok());
match refused_until(status, remaining.as_deref(), reset, retry_after, crate::now()) {
Some(reset) => Ok(Fetched::RateLimited { reset }),
None => Err(format!("GitHub refused the request (HTTP {status})")),
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}")),
@ -160,27 +153,6 @@ impl Http {
}
}
/// GET a small binary file (following redirects). `Ok(None)` for a 404. A file of more than
/// `max` bytes is refused: by its announced length before a byte is read, and by a hard limit
/// on the body when the length wasn't announced or was wrong.
pub fn get_bytes(&self, url: &str, max: usize) -> Result<Option<Vec<u8>>, 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 => {
let announced = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
let too_big = || format!("{} sent more than {max} bytes", host(url));
if announced.is_some_and(|n| n > max as u64) {
return Err(too_big());
}
// ureq errors once the limit is reached, so ask for one byte more than we accept.
let body = resp.body_mut().with_config().limit(max as u64 + 1).read_to_vec().map_err(|e| format!("unreadable answer ({e})"))?;
if body.len() > max { Err(too_big()) } else { Ok(Some(body)) }
}
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)))?;
@ -199,15 +171,23 @@ impl Http {
/// its partial file; a file that fails its checksum is fetched once more from scratch before
/// giving up. When the connection stays down, the download ends paused (`PAUSED`), never
/// losing what it has. `stop` pauses or cancels it between reads.
pub fn download(&self, url: &str, dest: &Path, size: u64, sha256: &str, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), String> {
pub fn download(
&self,
url: &str,
dest: &Path,
size: u64,
sha256: Option<&str>,
stop: &Stop,
progress: &mut dyn FnMut(Transfer),
) -> Result<Verified, String> {
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
}
// A finished copy from an earlier attempt that never got installed. Marking it as just
// used, before checking it, keeps `clean_downloads` away from it while it's installed.
if dest.is_file() && touch(dest) && matches_published(dest, sha256) {
let verified = if sha256.is_some() { Verified::Checksum } else { Verified::SizeOnly };
// A finished copy from an earlier attempt that never got installed.
if dest.is_file() && matches_published(dest, size, sha256) {
progress(Transfer::Bytes { done: size, total: size });
return Ok(());
return Ok(verified);
}
// The partial file is named after the release file and its size; if it still turns out
// to belong to something else, the checksum catches it and the download starts over.
@ -222,9 +202,9 @@ impl Http {
}
return Err(e);
}
if matches_published(&part, sha256) {
if matches_published(&part, size, sha256) {
std::fs::rename(&part, dest).map_err(|e| format!("couldn't save the download ({e})"))?;
return Ok(());
return Ok(verified);
}
let _ = std::fs::remove_file(&part);
if fresh_start_used {
@ -371,6 +351,15 @@ pub enum Transfer {
},
}
/// How a finished download was checked.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verified {
/// Against the release's published SHA-256.
Checksum,
/// The release publishes no checksum: only its size could be checked.
SizeOnly,
}
/// Asks a running download to pause (keeping its partial file) or cancel (deleting it).
#[derive(Debug, Default)]
pub struct Stop(AtomicU8);
@ -398,9 +387,12 @@ impl Stop {
}
}
/// Whether a file on disk is the published one: its SHA-256 matches.
fn matches_published(path: &Path, sha256: &str) -> bool {
sha256_file(path).is_ok_and(|h| h.eq_ignore_ascii_case(sha256))
/// Whether a file on disk is the published one: its checksum when there is one, else its size.
fn matches_published(path: &Path, size: u64, sha256: Option<&str>) -> bool {
match sha256 {
Some(sha) => sha256_file(path).is_ok_and(|h| h.eq_ignore_ascii_case(sha)),
None => size > 0 && std::fs::metadata(path).is_ok_and(|m| m.len() == size),
}
}
/// How much of a download is already on disk (a paused or interrupted one).
@ -408,22 +400,10 @@ pub fn partial_bytes(dest: &Path, size: u64) -> Option<u64> {
std::fs::metadata(part_path(dest, size)).ok().map(|m| m.len()).filter(|n| *n > 0)
}
/// Sets a file's modification time to now.
fn touch(path: &Path) -> bool {
std::fs::File::options().write(true).open(path).and_then(|f| f.set_modified(std::time::SystemTime::now())).is_ok()
}
/// Whether a finished download is waiting at `dest`. Judged by size alone: `download` checks it
/// against the published checksum before anything uses it.
pub fn finished(dest: &Path, size: u64) -> bool {
std::fs::metadata(dest).is_ok_and(|m| m.is_file() && m.len() == size)
}
/// Removes leftovers from the download folder: partial files in an old naming scheme, paused
/// downloads untouched for `partial_max_age`, and finished ones (kept after a failed install)
/// untouched for `finished_max_age` (they're for releases long since replaced, or given up on).
/// Files written within the last minute are always kept, so a running download is never touched.
pub fn clean_downloads(dir: &Path, partial_max_age: Duration, finished_max_age: Duration) {
/// Removes leftovers from the download folder: files in an old or unknown naming scheme, and
/// paused downloads untouched for `max_age` (they're for releases long since replaced). Files
/// written within the last minute are always kept, so a running download is never touched.
pub fn clean_downloads(dir: &Path, max_age: Duration) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let now = std::time::SystemTime::now();
for entry in entries.flatten() {
@ -442,19 +422,16 @@ pub fn clean_downloads(dir: &Path, partial_max_age: Duration, finished_max_age:
.strip_suffix(".part")
.and_then(|stem| stem.rsplit_once('.'))
.is_some_and(|(_, size)| !size.is_empty() && size.bytes().all(|b| b.is_ascii_digit()));
let legacy_part = name.ends_with(".part") && !current_part;
let max_age = if name.ends_with(".part") { partial_max_age } else { finished_max_age };
if legacy_part || age > max_age {
if !current_part || age > max_age {
log::info!("removing stale download {}", path.display());
let _ = std::fs::remove_file(&path);
}
}
}
/// Throws away a paused download, and a finished one kept for another try at installing.
pub fn discard(dest: &Path, size: u64) {
/// Throws away a paused download.
pub fn discard_partial(dest: &Path, size: u64) {
let _ = std::fs::remove_file(part_path(dest, size));
let _ = std::fs::remove_file(dest);
}
enum AttemptError {
@ -485,6 +462,16 @@ fn parse_content_range(v: &str) -> Option<(u64, Option<u64>)> {
Some((start, total))
}
/// 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| {
@ -501,36 +488,14 @@ pub fn sha256_file(path: &Path) -> Result<String, String> {
Ok(hex(&hasher.finalize()))
}
/// Whether a 403 or 429 from GitHub's API means "slow down", and until when (Unix seconds, when
/// it says). `None`: refused for some other reason.
///
/// GitHub sends `x-ratelimit-reset` with every answer: it's when the hourly quota refills, which
/// only matters once `x-ratelimit-remaining` is 0. A secondary limit says how long to wait in
/// `Retry-After` (seconds) and leaves the hourly quota alone; without either, GitHub's advice is
/// to wait a minute.
fn refused_until(status: u16, remaining: Option<&str>, reset: Option<i64>, retry_after: Option<i64>, now: i64) -> Option<Option<i64>> {
let retry = retry_after.map(|secs| now.saturating_add(secs.max(0)));
if remaining.map(str::trim) == Some("0") {
// `None` sorts first: whichever of the two is known, and the later if both are.
return Some(reset.max(retry));
}
if retry.is_some() {
return Some(retry);
}
(status == 429).then_some(Some(now.saturating_add(60)))
}
fn host(url: &str) -> &str {
url.split("://").nth(1).and_then(|r| r.split('/').next()).unwrap_or(url)
}
#[cfg(test)]
pub(crate) mod tests {
mod tests {
use super::*;
const DAYS_30: Duration = Duration::from_secs(30 * 86_400);
const DAYS_7: Duration = Duration::from_secs(7 * 86_400);
#[test]
fn cleaning_keeps_current_partial_files_and_removes_leftovers() {
let dir = tempfile::tempdir().unwrap();
@ -546,61 +511,13 @@ pub(crate) mod tests {
let finished = make("photocraft-0.5.0-macos-universal.dmg");
let fresh = dir.path().join("x.part");
std::fs::write(&fresh, b"x").unwrap();
clean_downloads(dir.path(), DAYS_30, DAYS_7);
assert!(keep.exists() && fresh.exists() && finished.exists());
assert!(!legacy.exists());
// A finished file goes sooner than a paused one.
clean_downloads(dir.path(), DAYS_30, Duration::from_secs(60));
assert!(keep.exists() && !finished.exists());
clean_downloads(dir.path(), Duration::from_secs(60), DAYS_7);
clean_downloads(dir.path(), Duration::from_secs(30 * 86_400));
assert!(keep.exists() && fresh.exists());
assert!(!legacy.exists() && !finished.exists());
clean_downloads(dir.path(), Duration::from_secs(60));
assert!(!keep.exists());
}
#[test]
fn a_kept_file_that_still_matches_is_not_fetched_again() {
let (body, sha) = body();
let len = body.len() as u64;
let (url, requests) = serve(body.clone(), vec![Reply::Full, Reply::Full]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("file.bin");
let http = Http::new().unwrap();
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
assert!(finished(&dest, len) && !finished(&dest, 0));
// Old enough to be cleaned up, were it not about to be used.
let old = std::time::SystemTime::now() - Duration::from_secs(40 * 86_400);
std::fs::File::options().write(true).open(&dest).unwrap().set_modified(old).unwrap();
let mut last = (0, 0);
let again = http.download(&url, &dest, len, &sha, &Stop::default(), &mut |t| {
if let Transfer::Bytes { done, total } = t {
last = (done, total);
}
});
assert_eq!(again, Ok(()));
assert_eq!(last, (len, len));
assert_eq!(requests.lock().unwrap().len(), 1);
// A cleanup while the installer runs leaves the reused file alone.
clean_downloads(dir.path(), DAYS_30, DAYS_7);
assert_eq!(std::fs::read(&dest).unwrap(), body);
discard(&dest, len);
assert!(!finished(&dest, len));
}
#[test]
fn a_kept_file_that_no_longer_matches_is_fetched_again() {
let (body, sha) = body();
let len = body.len() as u64;
let (url, requests) = serve(body.clone(), vec![Reply::Full, Reply::Full]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("file.bin");
let http = Http::new().unwrap();
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
// The same size, so only the checksum can tell.
std::fs::write(&dest, vec![b'x'; body.len()]).unwrap();
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), body);
assert_eq!(requests.lock().unwrap().len(), 2);
}
#[test]
fn reads_content_ranges() {
assert_eq!(parse_content_range("bytes 100-199/1000"), Some((100, Some(1000))));
@ -615,7 +532,7 @@ pub(crate) mod tests {
/// How the fake server answers one request.
#[derive(Clone, Copy, Debug)]
pub(crate) enum Reply {
enum Reply {
/// The whole file.
Full,
/// Promise the whole file, send `n` bytes, hang up.
@ -630,7 +547,7 @@ pub(crate) mod tests {
/// Serves `body` over plain HTTP following `script`, one reply per connection; records the
/// `Range` header each request carried.
pub(crate) fn serve(body: Vec<u8>, script: Vec<Reply>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
fn serve(body: Vec<u8>, script: Vec<Reply>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/file.bin", listener.local_addr().unwrap());
let ranges = Arc::new(Mutex::new(Vec::new()));
@ -676,7 +593,7 @@ pub(crate) mod tests {
(url, ranges)
}
pub(crate) fn body() -> (Vec<u8>, String) {
fn body() -> (Vec<u8>, String) {
let body: Vec<u8> = (0..700_000u32).map(|i| (i % 251) as u8).collect();
let sha = hex(&Sha256::digest(&body));
(body, sha)
@ -689,12 +606,15 @@ pub(crate) mod tests {
let dest = dir.path().join("file.bin");
let http = Http::new().unwrap();
let mut last = (0, 0);
let result = http.download(&url, &dest, body.len() as u64, &sha, &Stop::default(), &mut |t| {
let result = http.download(&url, &dest, body.len() as u64, Some(&sha), &Stop::default(), &mut |t| {
if let Transfer::Bytes { done, total } = t {
last = (done, total);
}
});
let result = result.map(|()| std::fs::read(&dest).unwrap());
let result = result.map(|v| {
assert_eq!(v, Verified::Checksum);
std::fs::read(&dest).unwrap()
});
if result.is_ok() {
assert_eq!(last, (body.len() as u64, body.len() as u64));
}
@ -702,25 +622,6 @@ pub(crate) mod tests {
(result, seen)
}
#[test]
fn small_binary_files_are_read_whole_and_a_404_is_none() {
let (body, _) = body();
let http = Http::new().unwrap();
let (url, _) = serve(body.clone(), vec![Reply::Full]);
assert_eq!(http.get_bytes(&url, body.len()).unwrap(), Some(body.clone()), "a body of exactly the cap is fine");
let (url, _) = serve(body, vec![Reply::Missing]);
assert_eq!(http.get_bytes(&url, 1 << 20).unwrap(), None);
}
#[test]
fn a_file_over_the_cap_is_refused() {
let (body, _) = body();
let http = Http::new().unwrap();
let (url, _) = serve(body.clone(), vec![Reply::Full]);
let err = http.get_bytes(&url, body.len() - 1).unwrap_err();
assert!(err.contains("more than"), "{err}");
}
#[test]
fn a_complete_body_of_exactly_the_published_size_succeeds() {
// The bug this guards: a reader limit equal to the size failed on the final read.
@ -749,6 +650,17 @@ pub(crate) mod tests {
assert!(result.unwrap_err().contains("checksum"));
}
#[test]
fn without_a_checksum_the_size_is_still_checked() {
let (body, _) = body();
let (url, _) = serve(body.clone(), vec![Reply::Full]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("file.bin");
let v = Http::new().unwrap().download(&url, &dest, body.len() as u64, None, &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(v, Verified::SizeOnly);
assert_eq!(std::fs::read(&dest).unwrap(), body);
}
#[test]
fn pausing_keeps_the_partial_file_and_resuming_continues_it() {
let (body, sha) = body();
@ -760,7 +672,7 @@ pub(crate) mod tests {
let stop = Stop::default();
// Pause as soon as the first bytes arrive.
let err = http
.download(&url, &dest, len, &sha, &stop, &mut |t| {
.download(&url, &dest, len, Some(&sha), &stop, &mut |t| {
if matches!(t, Transfer::Bytes { done, .. } if done > 0) {
stop.pause();
}
@ -769,7 +681,7 @@ pub(crate) mod tests {
assert_eq!(err, PAUSED);
let kept = partial_bytes(&dest, len).unwrap();
assert!(kept > 0 && kept < len, "{kept}");
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
http.download(&url, &dest, len, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), body);
assert_eq!(ranges.lock().unwrap()[1], Some(kept.to_string()));
}
@ -784,7 +696,7 @@ pub(crate) mod tests {
let stop = Stop::default();
let err = Http::new()
.unwrap()
.download(&url, &dest, len, &sha, &stop, &mut |t| {
.download(&url, &dest, len, Some(&sha), &stop, &mut |t| {
if matches!(t, Transfer::Bytes { done, .. } if done > 0) {
stop.cancel();
}
@ -809,7 +721,7 @@ pub(crate) mod tests {
let dest = dir.path().join("file.bin");
std::fs::write(part_path(&dest, body.len() as u64), &body[..500_000]).unwrap();
assert_eq!(partial_bytes(&dest, body.len() as u64), Some(500_000));
Http::new().unwrap().download(&url, &dest, body.len() as u64, &sha, &Stop::default(), &mut |_| {}).unwrap();
Http::new().unwrap().download(&url, &dest, body.len() as u64, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), body);
assert_eq!(*ranges.lock().unwrap(), [Some("500000".to_string())]);
assert!(!part_path(&dest, body.len() as u64).exists());
@ -830,65 +742,10 @@ pub(crate) mod tests {
let sha = asset.sha256.clone().or_else(|| sums.iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h.clone())).unwrap();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join(&asset.name);
http.download(&asset.url, &dest, asset.size, &sha, &Stop::default(), &mut |_| {}).unwrap();
http.download(&asset.url, &dest, asset.size, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(std::fs::metadata(&dest).unwrap().len(), asset.size);
}
/// Answers one request with `head` (status line and headers) and an empty body.
fn answer_once(head: String) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/repos/storytold/photocraft/releases/latest", listener.local_addr().unwrap());
std::thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else { return };
let mut reader = BufReader::new(stream.try_clone().unwrap());
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
}
let _ = write!(stream, "{head}Content-Length: 0\r\nConnection: close\r\n\r\n");
});
url
}
#[test]
fn a_secondary_limit_waits_for_retry_after_not_the_hourly_reset() {
// GitHub's secondary limit: quota left, an hourly reset 50 minutes away, and "try again
// in a minute". That's a minute, not 50 minutes of "the hourly limit was reached".
let now = crate::now();
let url = answer_once(format!(
"HTTP/1.1 403 Forbidden\r\nx-ratelimit-remaining: 41\r\nx-ratelimit-reset: {}\r\nretry-after: 60\r\n",
now + 50 * 60
));
let Fetched::RateLimited { reset: Some(until) } = Http::new().unwrap().get_api(&url, None).unwrap() else { panic!("not rate limited") };
assert!((now + 60..now + 120).contains(&until), "limited for {} s", until - now);
}
#[test]
fn an_empty_hourly_quota_waits_for_the_reset() {
let now = crate::now();
let reset = now + 31 * 60;
let url = answer_once(format!("HTTP/1.1 403 Forbidden\r\nx-ratelimit-remaining: 0\r\nx-ratelimit-reset: {reset}\r\n"));
assert_eq!(Http::new().unwrap().get_api(&url, None).unwrap(), Fetched::RateLimited { reset: Some(reset) });
}
#[test]
fn github_refusals_are_read_the_way_github_documents_them() {
const NOW: i64 = 1_791_500_000;
// Out of quota: until the reset, or Retry-After if that's later.
assert_eq!(refused_until(403, Some("0"), Some(NOW + 1800), None, NOW), Some(Some(NOW + 1800)));
assert_eq!(refused_until(403, Some("0"), Some(NOW + 30), Some(90), NOW), Some(Some(NOW + 90)));
assert_eq!(refused_until(403, Some("0"), None, None, NOW), Some(None));
// A secondary limit: Retry-After, never the hourly reset.
assert_eq!(refused_until(429, Some("12"), Some(NOW + 1800), Some(30), NOW), Some(Some(NOW + 30)));
// A 429 that doesn't say: a minute.
assert_eq!(refused_until(429, Some("12"), Some(NOW + 1800), None, NOW), Some(Some(NOW + 60)));
// A plain 403 (blocked, private, …) isn't a rate limit.
assert_eq!(refused_until(403, Some("12"), Some(NOW + 1800), None, NOW), None);
assert_eq!(refused_until(403, None, None, None, NOW), None);
}
#[test]
fn partial_files_are_named_after_the_file_and_size() {
assert_eq!(part_path(Path::new("/d/photocraft.dmg"), 9), Path::new("/d/photocraft.dmg.9.part"));

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod backend;
pub mod desktop_entry;
pub mod detect;
pub mod feed_throttle;
pub mod http;
@ -18,9 +17,6 @@ pub mod manifest;
pub mod packages;
pub mod paths;
pub mod process;
pub mod proxy;
pub mod queue;
pub mod screenshots;
pub mod self_update;
pub mod single_instance;
pub mod source;

View File

@ -21,10 +21,7 @@ pub fn parse_dpkg(text: &str) -> Vec<(String, String)> {
.filter_map(|line| {
let mut f = line.split('|');
match (f.next(), f.next(), f.next()) {
(Some(name), Some("installed"), Some(ver)) => {
let name = name.split_once(':').map(|(n, _)| n).unwrap_or(name);
Some((name.to_string(), ver.to_string()))
}
(Some(name), Some("installed"), Some(ver)) => Some((name.to_string(), ver.to_string())),
_ => None,
}
})
@ -65,15 +62,11 @@ pub fn parse_flatpak_info(text: &str) -> Option<String> {
/// The package names a Craft App may be installed under with `manager`. On Arch the AUR carries
/// each app three ways: built from source (`photocraft`), the release binary (`photocraft-bin`)
/// and the latest commit (`photocraft-git`). Debian and RPM packages may use either the short stem
/// (`photocraft`) or the reverse-DNS bundle id (`ai.storyteller.photocraft`).
/// and the latest commit (`photocraft-git`).
pub fn candidate_names(app: &CraftApp, manager: InstallSource) -> Vec<String> {
match manager {
InstallSource::Flatpak => vec![app.bundle_id()],
InstallSource::Pacman => {
app.stems().flat_map(|s| [s.to_string(), format!("{s}-bin"), format!("{s}-git")]).chain(std::iter::once(app.bundle_id())).collect()
}
InstallSource::Deb | InstallSource::Rpm => app.stems().map(str::to_string).chain(std::iter::once(app.bundle_id())).collect(),
InstallSource::Pacman => app.stems().flat_map(|s| [s.to_string(), format!("{s}-bin"), format!("{s}-git")]).collect(),
_ => app.stems().map(str::to_string).collect(),
}
}
@ -101,10 +94,7 @@ mod tests {
#[test]
fn reads_each_package_managers_listing() {
assert_eq!(
parse_dpkg("photocraft|installed|0.5.0-1\nphotocraft:amd64|installed|0.6.0-1\nwordcraft|config-files|0.3.0-1\n"),
[("photocraft".into(), "0.5.0-1".into()), ("photocraft".into(), "0.6.0-1".into())]
);
assert_eq!(parse_dpkg("photocraft|installed|0.5.0-1\nwordcraft|config-files|0.3.0-1\n"), [("photocraft".into(), "0.5.0-1".into())]);
assert_eq!(parse_rpm("gridcraft|0.3.0\n"), [("gridcraft".into(), "0.3.0".into())]);
assert_eq!(parse_pacman("photocraft-bin 0.5.0-1\nlinux 6.11.1.arch1-1\n").len(), 2);
assert_eq!(
@ -116,23 +106,6 @@ mod tests {
assert_eq!(parse_flatpak_info("Version: \n"), None);
}
#[test]
fn candidate_names_cover_bundle_ids_for_deb_rpm_and_pacman() {
let photo = CraftApp::by_id("photocraft").unwrap();
let deb_names = candidate_names(photo, InstallSource::Deb);
assert!(deb_names.contains(&"photocraft".to_string()));
assert!(deb_names.contains(&"ai.storyteller.photocraft".to_string()));
let rpm_names = candidate_names(photo, InstallSource::Rpm);
assert!(rpm_names.contains(&"photocraft".to_string()));
assert!(rpm_names.contains(&"ai.storyteller.photocraft".to_string()));
let pacman_names = candidate_names(photo, InstallSource::Pacman);
assert!(pacman_names.contains(&"photocraft".to_string()));
assert!(pacman_names.contains(&"photocraft-bin".to_string()));
assert!(pacman_names.contains(&"ai.storyteller.photocraft".to_string()));
}
#[test]
fn finds_aur_packages_in_every_flavour() {
let photo = CraftApp::by_id("photocraft").unwrap();

View File

@ -46,34 +46,14 @@ impl Paths {
self.cache.join("feed-throttle.json")
}
/// A picture of an app's details page, saved after its first download: under the revision of the
/// pictures repository it was fetched from, so a new revision never reads an old picture.
pub fn screenshot(&self, app: &str, key: &str) -> PathBuf {
self.cache.join("screenshots").join(artcraft_launcher_core::details::SCREENSHOTS_REV).join(app).join(format!("{key}.jpg"))
}
pub fn manifest(&self) -> PathBuf {
self.data.join("installed.json")
}
/// The launcher's own log files (`artcraft-launcher.log` and the previous runs').
pub fn logs(&self) -> PathBuf {
self.data.join("logs")
}
/// Where tarball installs (FreeBSD, Linux without AppImage) are unpacked: `<data>/apps/<app>`.
pub fn apps(&self) -> PathBuf {
self.data.join("apps")
}
/// The folder an app unpacked in [`Self::apps`] sees as its home: `<data>/apps/<app>/home`.
/// A Craft App keeps its settings in the XDG folders (`~/.config`, `~/.local/share`,
/// `~/.cache`), so pointing `HOME` and those variables here keeps every app's configuration
/// beside it, inside the folder the launcher owns.
#[cfg(target_os = "linux")]
pub fn app_home(&self, id: &str) -> PathBuf {
self.apps().join(id).join("home")
}
}
/// Writes `bytes` to `path` via a temporary file and a rename, so a crash never leaves half a file.

View File

@ -4,8 +4,7 @@ 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, and clears AppImage-injected
/// environment variables that corrupt system helper binaries and external applications.
/// 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);
@ -15,51 +14,18 @@ pub fn command(program: impl AsRef<std::ffi::OsStr>) -> Command {
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(unix)]
clean_appimage_env(&mut cmd);
cmd
}
/// Cleans AppImage runtime environment variables so child processes don't load incompatible
/// libraries from the launcher's bundled AppImage.
#[cfg(unix)]
pub fn clean_appimage_env(cmd: &mut Command) {
if std::env::var_os("APPIMAGE").is_some() {
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",
] {
cmd.env_remove(var);
}
}
}
/// Runs a helper and returns its standard output when it succeeds within `timeout`.
pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String> {
run(program, args, timeout).ok()
}
/// Runs a helper and returns its standard output when it succeeds within `timeout`; otherwise
/// the reason, in the helper's own words (its standard error) where it gave one.
pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, String> {
let mut child = command(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("couldn't start {program} ({e})"))?;
// Read both pipes while it runs, so a chatty helper can't block on a full one.
let (stdout, stderr) = (read_all(child.stdout.take()), read_all(child.stderr.take()));
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() {
@ -68,28 +34,12 @@ pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, St
_ => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("{program} didn't finish within {} seconds", timeout.as_secs()));
return None;
}
}
};
let text = |pipe: std::thread::JoinHandle<Vec<u8>>| String::from_utf8_lossy(&pipe.join().unwrap_or_default()).into_owned();
let (out, err) = (text(stdout), text(stderr));
if status.success() {
return Ok(out);
}
let reason = err.trim();
Err(if reason.is_empty() { format!("{program} failed ({status})") } else { reason.to_string() })
}
/// Reads a child's pipe to the end (at most 8 MB) on its own thread.
fn read_all<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut out = Vec::new();
if let Some(pipe) = pipe {
let _ = std::io::Read::read_to_end(&mut std::io::Read::take(pipe, 8 << 20), &mut out);
}
out
})
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.
@ -101,60 +51,31 @@ pub fn spawn_detached(mut cmd: Command) -> Result<(), String> {
Ok(())
}
/// Whether this process is running inside a Flatpak container sandbox.
pub fn in_sandbox() -> bool {
std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists()
}
/// A command to run `flatpak`, delegating to the host via `flatpak-spawn --host` if inside a sandbox.
pub fn flatpak_command() -> Command {
if in_sandbox() {
let mut cmd = command("flatpak-spawn");
cmd.args(["--host", "flatpak"]);
cmd
} else {
command("flatpak")
}
}
/// Runs a flatpak command and captures its standard output.
pub fn capture_flatpak(args: &[&str], timeout: Duration) -> Option<String> {
if in_sandbox() {
let mut spawn_args = vec!["--host", "flatpak"];
spawn_args.extend_from_slice(args);
capture("flatpak-spawn", &spawn_args, timeout)
} else {
capture("flatpak", args, timeout)
}
}
/// Whether Flatpak is available on the machine (or on the host when sandboxed).
pub fn is_flatpak_available() -> bool {
if in_sandbox() {
capture("flatpak-spawn", &["--host", "flatpak", "--version"], Duration::from_secs(3)).is_some()
} else {
which_on_path("flatpak").is_some()
}
}
fn which_on_path(name: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())
}
/// Starts an app binary from its own folder.
pub fn launch_binary(exe: &Path, args: &[&str]) -> Result<(), String> {
let mut cmd = if in_sandbox() {
let mut c = command("flatpak-spawn");
c.arg("--host").arg(exe);
c
} else {
command(exe)
};
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)
}
@ -166,10 +87,6 @@ pub fn open(target: &std::ffi::OsStr) -> Result<(), String> {
let mut c = command("explorer");
c.arg(target);
return spawn_detached(c);
} else if in_sandbox() {
let mut c = command("flatpak-spawn");
c.arg("--host").arg("xdg-open");
c
} else {
command("xdg-open")
};
@ -184,45 +101,16 @@ pub fn reveal(path: &Path) -> Result<(), String> {
cmd.arg("-R").arg(path);
spawn_detached(cmd)
} else if cfg!(windows) {
reveal_in_explorer(path)
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())
}
}
#[cfg(windows)]
fn reveal_in_explorer(path: &Path) -> Result<(), String> {
use std::os::windows::process::CommandExt;
let mut cmd = command("explorer");
// Passed raw: `arg` would wrap all of `/select,...` in quotes when the path has a space
// (`C:\Program Files\...`), and Explorer then ignores the switch and opens Documents.
// A quote in the path would end the quoted part early and let the rest reach Explorer as
// switches; no Windows path has one, so refuse it.
let arg = explorer_select_arg(path).ok_or_else(|| format!("{} isn't a usable path", path.display()))?;
cmd.raw_arg(arg);
spawn_detached(cmd)
}
#[cfg(not(windows))]
fn reveal_in_explorer(_path: &Path) -> Result<(), String> {
Err("Explorer is only on Windows".into())
}
/// Explorer's `/select,"<path>"` argument as it goes on the command line: only the path is quoted,
/// and forward slashes become backslashes, which Explorer needs to find the item. `None` for a path
/// with a `"` in it.
#[cfg_attr(not(windows), allow(dead_code))]
fn explorer_select_arg(path: &Path) -> Option<std::ffi::OsString> {
let s = path.to_str()?;
if s.contains('"') {
return None;
}
let mut arg = std::ffi::OsString::from("/select,\"");
arg.push(s.replace('/', "\\"));
arg.push("\"");
Some(arg)
}
/// 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) {
@ -232,126 +120,7 @@ pub fn is_running(path: &Path) -> bool {
.is_some_and(|out| out.to_ascii_lowercase().contains(&name.to_ascii_lowercase()))
} else {
let Some(p) = path.to_str() else { return false };
let pattern = pgrep_pattern(p);
// pgrep exits 0 when something matches; it never matches itself.
if in_sandbox() {
capture("flatpak-spawn", &["--host", "pgrep", "-f", "--", &pattern], Duration::from_secs(5)).is_some()
} else {
capture("pgrep", &["-f", "--", &pattern], Duration::from_secs(5)).is_some()
}
}
}
/// Quote the path's metacharacters for pgrep's extended regular expression.
fn pgrep_pattern(path: &str) -> String {
let mut pattern = String::with_capacity(path.len());
for c in path.chars() {
if matches!(c, '.' | '^' | '$' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\') {
pattern.push('\\');
}
pattern.push(c);
}
pattern
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use std::io::{BufRead, BufReader};
use std::process::Child;
use super::*;
struct RunningProbe(Child);
impl RunningProbe {
fn start(path: &str) -> Self {
let child = command("/bin/sh")
.args(["-c", "printf 'ready\\n'; read line", path])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut probe = Self(child);
let mut ready = String::new();
BufReader::new(probe.0.stdout.take().unwrap()).read_line(&mut ready).unwrap();
assert_eq!(ready, "ready\n");
probe
}
}
impl Drop for RunningProbe {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn running_paths_are_matched_literally() {
let root = format!("/tmp/artcraft-launcher-process-{}", std::process::id());
for (actual, requested, expected) in [
("plain/PhotoCraft.app", "plain/PhotoCraft.app", true),
("Apps (work)/PhotoCraft.app", "Apps (work)/PhotoCraft.app", true),
("Apps [work]/PhotoCraft.app", "Apps [work]/PhotoCraft.app", true),
("other/PhotoCraftXapp", "other/PhotoCraft.app", false),
("symbols/应用.^$*+?()[]{}|\\.app", "symbols/应用.^$*+?()[]{}|\\.app", true),
] {
let actual = format!("{root}/{actual}");
let requested = format!("{root}/{requested}");
let _probe = RunningProbe::start(&actual);
assert_eq!(is_running(Path::new(&requested)), expected, "{requested}");
}
}
}
#[cfg(test)]
mod run_tests {
use super::*;
/// A shell command line, for the platform's shell.
fn shell(script: &str) -> (&'static str, [&str; 2]) {
if cfg!(windows) { ("cmd", ["/C", script]) } else { ("/bin/sh", ["-c", script]) }
}
#[test]
fn a_failing_helper_reports_its_own_error() {
let (program, args) = shell(if cfg!(windows) { "echo no such image 1>&2 & exit /b 3" } else { "echo no such image >&2; exit 3" });
assert_eq!(run(program, &args, Duration::from_secs(30)), Err("no such image".into()));
assert_eq!(capture(program, &args, Duration::from_secs(30)), None);
}
#[test]
fn a_silent_failure_and_a_success() {
let (program, args) = shell("exit 4");
let err = run(program, &args, Duration::from_secs(30)).unwrap_err();
assert!(err.starts_with(&format!("{program} failed")), "{err}");
let (program, args) = shell("echo hello");
assert_eq!(run(program, &args, Duration::from_secs(30)).map(|out| out.trim().to_string()), Ok("hello".into()));
}
}
#[cfg(test)]
mod explorer_tests {
use super::*;
#[test]
fn explorer_select_quotes_only_the_path() {
let arg = explorer_select_arg(Path::new(r"C:\Program Files\PhotoCraft\photocraft.exe"));
assert_eq!(arg.unwrap(), r#"/select,"C:\Program Files\PhotoCraft\photocraft.exe""#);
}
#[test]
fn explorer_select_without_spaces() {
let arg = explorer_select_arg(Path::new(r"C:\Apps\photocraft.exe"));
assert_eq!(arg.unwrap(), r#"/select,"C:\Apps\photocraft.exe""#);
assert_eq!(explorer_select_arg(Path::new(r#"C:\Apps" /root,C:\x.exe"#)), None);
}
#[test]
fn explorer_select_uses_backslashes() {
let arg = explorer_select_arg(Path::new("C:/Users/A B/AppData/Local/ArtCraft Launcher/apps/photocraft.exe"));
assert_eq!(arg.unwrap(), r#"/select,"C:\Users\A B\AppData\Local\ArtCraft Launcher\apps\photocraft.exe""#);
capture("pgrep", &["-f", "--", p], Duration::from_secs(5)).is_some()
}
}

View File

@ -1,259 +0,0 @@
//! Which HTTP proxy the launcher uses: the proxy environment variables first, else the proxy set
//! in the OS (Windows Internet Settings, macOS network settings). Automatic configuration (PAC
//! scripts, WPAD) is not supported.
use ureq::{Proxy, ProxyProtocol};
/// A manually configured proxy read from the OS settings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemProxy {
pub host: String,
pub port: Option<u16>,
/// Hosts that skip the proxy, in `NO_PROXY` form (`example.com`, `*.example.com`, `10.*`).
pub bypass: Vec<String>,
/// Where it came from, for the log.
pub source: &'static str,
}
/// The proxy every agent uses, or `None` to connect directly. Logs the choice at info level,
/// without any user name or password.
pub fn pick() -> Option<Proxy> {
if let Some(proxy) = Proxy::try_from_env() {
log::info!("HTTP proxy: {} {}:{} (from environment variables)", proxy.protocol(), proxy.host(), proxy.port());
return Some(proxy);
}
let system = system()?;
match to_proxy(&system) {
Ok(proxy) => {
log::info!("HTTP proxy: {} {}:{} (from {})", proxy.protocol(), proxy.host(), proxy.port(), system.source);
Some(proxy)
}
Err(e) => {
log::warn!("ignoring the proxy in {} ({e}); connecting directly", system.source);
None
}
}
}
/// A ureq proxy for `system`. Loopback addresses always go direct, as they do on Windows and
/// macOS.
pub fn to_proxy(system: &SystemProxy) -> Result<Proxy, ureq::Error> {
let mut builder = Proxy::builder(ProxyProtocol::Http).host(&system.host);
if let Some(port) = system.port {
builder = builder.port(port);
}
for entry in ["localhost", "127.0.0.1", "[::1]"].iter().copied().chain(system.bypass.iter().map(String::as_str)) {
builder = builder.no_proxy(entry);
}
builder.build()
}
#[cfg(windows)]
fn system() -> Option<SystemProxy> {
use winreg::RegKey;
use winreg::enums::HKEY_CURRENT_USER;
let key = RegKey::predef(HKEY_CURRENT_USER).open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings").ok()?;
let enabled: u32 = key.get_value("ProxyEnable").unwrap_or(0);
if enabled == 0 {
return None;
}
let server: String = key.get_value("ProxyServer").ok()?;
let overrides: String = key.get_value("ProxyOverride").unwrap_or_default();
let (host, port) = windows_proxy_server(&server)?;
Some(SystemProxy { host, port, bypass: windows_bypass(&overrides), source: "Windows proxy settings" })
}
#[cfg(target_os = "macos")]
fn system() -> Option<SystemProxy> {
let out = crate::process::capture("/usr/sbin/scutil", &["--proxy"], std::time::Duration::from_secs(3))?;
parse_scutil(&out)
}
#[cfg(not(any(windows, target_os = "macos")))]
fn system() -> Option<SystemProxy> {
// Linux and the BSDs: desktop proxy settings reach programs through the environment.
None
}
/// Parses Windows' `ProxyServer` value: either `host:port` for every protocol, or a list such as
/// `http=h:p;https=h:p;socks=h:p`. Prefers the HTTPS entry (every request is HTTPS), then HTTP.
/// SOCKS-only settings are ignored.
pub fn windows_proxy_server(value: &str) -> Option<(String, Option<u16>)> {
let entries: Vec<&str> = value.split([';', ' ']).map(str::trim).filter(|e| !e.is_empty()).collect();
let chosen = if entries.iter().any(|e| e.contains('=')) {
let find = |scheme: &str| {
entries.iter().find_map(|e| e.split_once('=').filter(|(k, _)| k.trim().eq_ignore_ascii_case(scheme)).map(|(_, v)| v.trim()))
};
find("https").or_else(|| find("http"))?
} else {
entries.first().copied()?
};
host_port(chosen)
}
/// Splits `host:port`, `http://host:port/`, `[::1]:8080` and similar into host and port.
fn host_port(value: &str) -> Option<(String, Option<u16>)> {
let rest = match value.split_once("://") {
Some((scheme, rest)) if scheme.eq_ignore_ascii_case("http") => rest,
Some(_) => return None,
None => value,
};
let rest = rest.trim_end_matches('/');
// Credentials never come from these settings; refuse rather than mis-parse.
if rest.is_empty() || rest.contains(['@', '/', '?', '#']) {
return None;
}
if rest.starts_with('[') {
let (host, after) = rest.split_once(']')?;
let host = format!("{host}]");
return match after.strip_prefix(':') {
Some(port) => Some((host, Some(port.parse().ok()?))),
None if after.is_empty() => Some((host, None)),
None => None,
};
}
match rest.split_once(':') {
Some((host, port)) if !host.is_empty() && !port.contains(':') => Some((host.to_string(), Some(port.parse().ok()?))),
Some(_) => None,
None => Some((rest.to_string(), None)),
}
}
/// Parses Windows' `ProxyOverride` bypass list (`*.local;10.*;<local>`). `<local>` (plain host
/// names without a dot) has no `NO_PROXY` equivalent and is dropped.
pub fn windows_bypass(value: &str) -> Vec<String> {
value.split([';', ' ']).map(str::trim).filter(|e| !e.is_empty() && !e.eq_ignore_ascii_case("<local>")).map(str::to_string).collect()
}
/// Parses `scutil --proxy` output: the HTTPS proxy when enabled, else the HTTP proxy, plus the
/// exceptions list.
pub fn parse_scutil(out: &str) -> Option<SystemProxy> {
let mut fields: Vec<(&str, &str)> = Vec::new();
let mut bypass = Vec::new();
let mut in_exceptions = false;
for line in out.lines().map(str::trim) {
if in_exceptions {
if line.starts_with('}') {
in_exceptions = false;
} else if let Some((_, v)) = line.split_once(" : ") {
let v = v.trim();
if !v.is_empty() {
bypass.push(v.to_string());
}
}
continue;
}
let Some((k, v)) = line.split_once(" : ") else { continue };
let (k, v) = (k.trim(), v.trim());
if k == "ExceptionsList" && v.starts_with("<array>") {
in_exceptions = !v.ends_with('}');
} else {
fields.push((k, v));
}
}
let get = |key: &str| fields.iter().find(|(k, _)| *k == key).map(|(_, v)| *v);
["HTTPS", "HTTP"].into_iter().find_map(|kind| {
if get(&format!("{kind}Enable")) != Some("1") {
return None;
}
let host = get(&format!("{kind}Proxy")).filter(|h| !h.is_empty() && !h.contains(['@', '/', ' ']))?;
let port = match get(&format!("{kind}Port")) {
Some(p) => Some(p.parse().ok()?),
None => None,
};
Some(SystemProxy { host: host.to_string(), port, bypass: bypass.clone(), source: "macOS proxy settings" })
})
}
#[cfg(test)]
mod tests {
use super::*;
fn hp(host: &str, port: Option<u16>) -> Option<(String, Option<u16>)> {
Some((host.to_string(), port))
}
#[test]
fn windows_single_proxy_for_everything() {
assert_eq!(windows_proxy_server("proxy.corp:8080"), hp("proxy.corp", Some(8080)));
assert_eq!(windows_proxy_server("127.0.0.1:7890"), hp("127.0.0.1", Some(7890)));
assert_eq!(windows_proxy_server("proxy.corp"), hp("proxy.corp", None));
assert_eq!(windows_proxy_server("http://proxy.corp:3128/"), hp("proxy.corp", Some(3128)));
assert_eq!(windows_proxy_server("[::1]:8080"), hp("[::1]", Some(8080)));
}
#[test]
fn windows_per_protocol_list_prefers_https() {
assert_eq!(windows_proxy_server("http=h1:80;https=h2:443;ftp=h3:21;socks=h4:1080"), hp("h2", Some(443)));
assert_eq!(windows_proxy_server("ftp=h3:21;http=h1:8080"), hp("h1", Some(8080)));
assert_eq!(windows_proxy_server("HTTPS=h2:8443 ; "), hp("h2", Some(8443)));
}
#[test]
fn windows_settings_that_cannot_be_used() {
assert_eq!(windows_proxy_server(""), None);
assert_eq!(windows_proxy_server("socks=h4:1080"), None);
assert_eq!(windows_proxy_server("socks5://h4:1080"), None);
assert_eq!(windows_proxy_server("proxy.corp:notaport"), None);
assert_eq!(windows_proxy_server("proxy.corp:99999"), None);
assert_eq!(windows_proxy_server("user:pw@proxy.corp:8080"), None);
}
#[test]
fn windows_bypass_list() {
assert_eq!(windows_bypass("*.local;10.*; localhost;<local>"), ["*.local", "10.*", "localhost"]);
assert!(windows_bypass("").is_empty());
}
const SCUTIL: &str = "<dictionary> {
ExceptionsList : <array> {
0 : *.local
1 : 169.254/16
}
FTPPassive : 1
HTTPEnable : 1
HTTPPort : 8080
HTTPProxy : http.corp
HTTPSEnable : 1
HTTPSPort : 3128
HTTPSProxy : https.corp
SOCKSEnable : 0
}
";
#[test]
fn scutil_https_proxy_with_exceptions() {
let p = parse_scutil(SCUTIL).unwrap();
assert_eq!((p.host.as_str(), p.port), ("https.corp", Some(3128)));
assert_eq!(p.bypass, ["*.local", "169.254/16"]);
}
#[test]
fn scutil_falls_back_to_http_proxy() {
let out = SCUTIL.replace("HTTPSEnable : 1", "HTTPSEnable : 0");
let p = parse_scutil(&out).unwrap();
assert_eq!((p.host.as_str(), p.port), ("http.corp", Some(8080)));
}
#[test]
fn scutil_without_a_proxy() {
let out = "<dictionary> {\n ExceptionsList : <array> {\n 0 : *.local\n }\n FTPPassive : 1\n HTTPEnable : 0\n HTTPSEnable : 0\n}\n";
assert_eq!(parse_scutil(out), None);
assert_eq!(parse_scutil(""), None);
assert_eq!(parse_scutil("HTTPSEnable : 1\nHTTPSProxy : \nHTTPSPort : 1\n"), None);
}
#[test]
fn system_proxy_becomes_a_ureq_proxy_that_skips_loopback() {
let p = to_proxy(&SystemProxy { host: "proxy.corp".into(), port: Some(8080), bypass: vec!["*.local".into()], source: "test" }).unwrap();
assert_eq!((p.protocol(), p.host(), p.port()), (ProxyProtocol::Http, "proxy.corp", 8080));
for (url, direct) in
[("http://127.0.0.1:1/", true), ("http://localhost/", true), ("https://nas.local/", true), ("https://github.com/", false)]
{
assert_eq!(p.is_no_proxy(&url.parse().unwrap()), direct, "{url}");
}
let p = to_proxy(&SystemProxy { host: "proxy.corp".into(), port: None, bypass: vec![], source: "test" }).unwrap();
assert_eq!(p.port(), 80);
}
}

View File

@ -1,512 +0,0 @@
//! One system installer at a time.
//!
//! apt, dnf, rpm and the Windows Installer each hold a system-wide lock: an install started
//! while another runs fails. Jobs wait their turn here instead. Where the installer takes
//! several packages, the jobs waiting for the same command share one call, so the password is
//! asked for once.
use std::ffi::OsString;
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use artcraft_launcher_core::protocol::CANCELLED;
type Outcome = Result<(), String>;
struct Waiting {
id: u64,
command: Vec<OsString>,
target: OsString,
}
struct State {
busy: bool,
next_id: u64,
waiting: Vec<Waiting>,
/// The jobs of the call in progress.
running: Vec<u64>,
finished: Vec<(u64, Outcome)>,
}
pub struct Queue {
state: Mutex<State>,
changed: Condvar,
}
/// Frees the installer when a call ends (even by unwinding) and hands its outcome to the other
/// jobs of the call.
struct Turn<'a> {
queue: &'a Queue,
others: Vec<u64>,
outcome: Outcome,
}
impl Drop for Turn<'_> {
fn drop(&mut self) {
let mut state = self.queue.lock();
state.busy = false;
state.running.clear();
for id in self.others.drain(..) {
state.finished.push((id, self.outcome.clone()));
}
self.queue.changed.notify_all();
}
}
impl Default for Queue {
fn default() -> Self {
Self::new()
}
}
impl Queue {
pub const fn new() -> Self {
let state = State { busy: false, next_id: 0, waiting: Vec::new(), running: Vec::new(), finished: Vec::new() };
Self { state: Mutex::new(state), changed: Condvar::new() }
}
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Until something changes, or long enough to ask again whether the job was cancelled.
fn wait<'a>(&self, state: MutexGuard<'a, State>) -> MutexGuard<'a, State> {
self.changed.wait_timeout(state, Duration::from_millis(100)).unwrap_or_else(PoisonError::into_inner).0
}
/// Runs `work` once no other installer is running. `turn(true)` says it has to wait,
/// `turn(false)` that the wait is over. `None` when `cancelled` (asked a few times a second,
/// so it must not block) said so during the wait; `work` then never ran.
pub fn alone<R>(&self, cancelled: &dyn Fn() -> bool, turn: &mut dyn FnMut(bool), work: impl FnOnce() -> R) -> Option<R> {
let mut state = self.lock();
let mut waited = false;
loop {
if waited && cancelled() {
return None;
}
if !state.busy {
break;
}
if waited {
state = self.wait(state);
} else {
waited = true;
drop(state);
turn(true);
state = self.lock();
}
}
state.busy = true;
drop(state);
if waited {
turn(false);
}
let _turn = Turn { queue: self, others: Vec::new(), outcome: Ok(()) };
Some(work())
}
/// Runs `exec(command, targets)` once no other installer is running, with the targets of
/// every job waiting for the same `command`, and gives each of those jobs its outcome.
/// `turn(true)` says the job has to wait, `turn(false)` that its call has started.
///
/// A job that is waiting leaves with [`CANCELLED`] once `cancelled` (asked a few times a
/// second, so it must not block) says so; once its call has started it sees it through.
pub fn batched(
&self,
command: Vec<OsString>,
target: OsString,
cancelled: &dyn Fn() -> bool,
turn: &mut dyn FnMut(bool),
exec: &dyn Fn(&[OsString], &[OsString]) -> Outcome,
) -> Outcome {
let mut state = self.lock();
let id = state.next_id;
state.next_id = state.next_id.wrapping_add(1);
state.waiting.push(Waiting { id, command: command.clone(), target });
let (mut waited, mut started) = (false, false);
loop {
if let Some(at) = state.finished.iter().position(|(done, _)| *done == id) {
return state.finished.swap_remove(at).1;
}
// Another job took this one into its call.
let running = state.running.contains(&id);
if !running && waited && cancelled() {
state.waiting.retain(|w| w.id != id);
return Err(CANCELLED.into());
}
if !running && !state.busy {
break;
}
let tell = if running { waited && !started } else { !waited };
if tell {
(waited, started) = (true, running);
drop(state);
turn(!running);
state = self.lock();
} else {
state = self.wait(state);
}
}
let (batch, rest): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut state.waiting).into_iter().partition(|w| w.command == command);
state.waiting = rest;
state.busy = true;
state.running = batch.iter().map(|w| w.id).collect();
self.changed.notify_all();
drop(state);
if waited {
turn(false);
}
let others = batch.iter().map(|w| w.id).filter(|other| *other != id).collect();
let targets: Vec<OsString> = batch.into_iter().map(|w| w.target).collect();
let mut call = Turn { queue: self, others, outcome: Err("the system installer was interrupted".into()) };
let outcome = exec(&command, &targets);
call.outcome.clone_from(&outcome);
outcome
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use super::*;
fn os(s: &str) -> OsString {
OsString::from(s)
}
fn line(parts: &[&str]) -> Vec<OsString> {
parts.iter().map(|p| os(p)).collect()
}
fn install() -> Vec<OsString> {
line(&["apt-get", "install", "-y"])
}
fn remove() -> Vec<OsString> {
line(&["apt-get", "remove", "-y"])
}
/// Every call made, as `command + targets` with the targets sorted.
#[derive(Default)]
struct Calls(Mutex<Vec<Vec<OsString>>>);
impl Calls {
fn record(&self, command: &[OsString], targets: &[OsString]) {
let mut targets = targets.to_vec();
targets.sort();
self.0.lock().unwrap().push(command.iter().cloned().chain(targets).collect());
}
fn taken(&self) -> Vec<Vec<OsString>> {
let mut calls = std::mem::take(&mut *self.0.lock().unwrap());
calls.sort();
calls
}
}
/// Starts `jobs` (command, target) while an install is running, so they all wait; lets that
/// install finish once every one of them has said it is waiting. Returns their outcomes.
fn behind_a_running_install(
queue: &Queue,
jobs: &[(Vec<OsString>, &str)],
exec: &(dyn Fn(&[OsString], &[OsString]) -> Outcome + Sync),
) -> Vec<Option<Outcome>> {
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let handles: Vec<_> = jobs
.iter()
.map(|(command, target)| {
let turned = turned.clone();
scope.spawn(move || {
let mut turn = |waiting: bool| {
if waiting {
turned.send(()).unwrap();
}
};
queue.batched(command.clone(), os(target), &|| false, &mut turn, exec)
})
})
.collect();
for _ in jobs {
turns.recv().unwrap();
}
release.send(()).unwrap();
handles.into_iter().map(|h| h.join().ok()).collect()
})
}
#[test]
fn jobs_that_wait_together_share_one_call_per_command() {
let queue = Queue::new();
let calls = Calls::default();
let jobs = [(install(), "a.deb"), (install(), "b.deb"), (remove(), "photocraft"), (install(), "c.deb")];
let results = behind_a_running_install(&queue, &jobs, &|command, targets| {
calls.record(command, targets);
Ok(())
});
assert_eq!(results, vec![Some(Ok(())); 4]);
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "a.deb", "b.deb", "c.deb"]), line(&["apt-get", "remove", "-y", "photocraft"])]);
}
#[test]
fn installers_never_run_at_the_same_time() {
let queue = Queue::new();
let (inside, most, handled) = (AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0));
let enter = || {
most.fetch_max(inside.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(3));
inside.fetch_sub(1, Ordering::SeqCst);
};
std::thread::scope(|scope| {
for i in 0..24 {
let (queue, enter, handled) = (&queue, &enter, &handled);
scope.spawn(move || {
std::thread::sleep(Duration::from_millis(i % 5));
let exec = |_: &[OsString], targets: &[OsString]| {
enter();
handled.fetch_add(targets.len(), Ordering::SeqCst);
Ok(())
};
match i % 3 {
0 => queue.batched(install(), os(&format!("{i}.deb")), &|| false, &mut |_| {}, &exec).unwrap(),
1 => queue.batched(remove(), os(&format!("app{i}")), &|| false, &mut |_| {}, &exec).unwrap(),
_ => queue
.alone(&|| false, &mut |_| {}, || {
enter();
handled.fetch_add(1, Ordering::SeqCst);
})
.unwrap(),
}
});
}
});
assert_eq!(most.load(Ordering::SeqCst), 1);
assert_eq!(handled.load(Ordering::SeqCst), 24);
}
#[test]
fn a_job_arriving_mid_install_waits_then_runs() {
let queue = Queue::new();
let calls = Calls::default();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
let (queue, calls) = (&queue, &calls);
let first = scope.spawn(move || {
queue.batched(install(), os("a.deb"), &|| false, &mut |_| panic!("the first job never waits"), &|command, targets| {
calls.record(command, targets);
started.send(()).unwrap();
released.recv().unwrap();
Ok(())
})
});
has_started.recv().unwrap();
let second = scope.spawn(move || {
queue.batched(install(), os("b.deb"), &|| false, &mut |waiting| turned.send(waiting).unwrap(), &|command, targets| {
calls.record(command, targets);
Ok(())
})
});
assert!(turns.recv().unwrap(), "the second job says it is waiting");
assert_eq!(calls.0.lock().unwrap().len(), 1, "and does not start while the first runs");
release.send(()).unwrap();
assert_eq!(first.join().unwrap(), Ok(()));
assert_eq!(second.join().unwrap(), Ok(()));
assert!(!turns.recv().unwrap(), "then that its turn came");
});
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "a.deb"]), line(&["apt-get", "install", "-y", "b.deb"])]);
}
#[test]
fn a_job_taken_into_another_jobs_call_hears_it_started() {
let queue = Queue::new();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
let installing = AtomicUsize::new(0);
std::thread::scope(|scope| {
let (queue, installing) = (&queue, &installing);
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
for file in ["a.deb", "b.deb"] {
let turned = turned.clone();
scope.spawn(move || {
let mut turn = |waiting: bool| {
if !waiting {
installing.fetch_add(1, Ordering::SeqCst);
}
turned.send(waiting).unwrap();
};
// The call lasts until both jobs know it is under way.
let exec = |_: &[OsString], targets: &[OsString]| {
assert_eq!(targets.len(), 2);
while installing.load(Ordering::SeqCst) < 2 {
std::thread::sleep(Duration::from_millis(1));
}
Ok(())
};
assert_eq!(queue.batched(install(), os(file), &|| false, &mut turn, &exec), Ok(()));
});
}
assert!(turns.recv().unwrap() && turns.recv().unwrap());
release.send(()).unwrap();
});
assert_eq!(turns.try_iter().collect::<Vec<_>>(), [false, false]);
}
#[test]
fn a_dismissed_prompt_cancels_every_job_of_the_call() {
let queue = Queue::new();
let calls = Calls::default();
let jobs = [(install(), "a.deb"), (install(), "b.deb"), (install(), "c.deb")];
let results = behind_a_running_install(&queue, &jobs, &|command, targets| {
calls.record(command, targets);
Err("cancelled".into())
});
assert_eq!(calls.taken().len(), 1);
assert_eq!(results, vec![Some(Err("cancelled".to_string())); 3]);
}
#[test]
fn a_waiting_job_can_be_cancelled_and_is_left_out_of_the_call() {
let queue = Queue::new();
let calls = Calls::default();
let cancel = AtomicBool::new(false);
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
let (queue, calls, cancel) = (&queue, &calls, &cancel);
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let exec = |command: &[OsString], targets: &[OsString]| {
calls.record(command, targets);
Ok(())
};
let turned_too = turned.clone();
let leaving = scope.spawn(move || {
queue.batched(install(), os("a.deb"), &|| cancel.load(Ordering::SeqCst), &mut |waiting| turned.send(waiting).unwrap(), &exec)
});
let staying =
scope.spawn(move || queue.batched(install(), os("b.deb"), &|| false, &mut |waiting| turned_too.send(waiting).unwrap(), &exec));
assert!(turns.recv().unwrap() && turns.recv().unwrap());
cancel.store(true, Ordering::SeqCst);
// It leaves while the other install is still running, without waiting for its turn.
assert_eq!(leaving.join().unwrap(), Err("cancelled".to_string()));
assert!(calls.0.lock().unwrap().is_empty());
release.send(()).unwrap();
assert_eq!(staying.join().unwrap(), Ok(()));
});
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "b.deb"])]);
}
#[test]
fn waiting_alone_for_the_installer_can_be_cancelled() {
let queue = Queue::new();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
std::thread::scope(|scope| {
let queue = &queue;
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let ran = queue.alone(&|| true, &mut |_| {}, || panic!("a cancelled job never runs"));
assert_eq!(ran, None);
release.send(()).unwrap();
});
}
#[test]
fn a_job_whose_call_has_started_sees_it_through() {
let queue = Queue::new();
let cancel = AtomicBool::new(false);
let (in_call, is_in_call) = mpsc::channel();
let (finish, finished) = mpsc::channel::<()>();
let finished = Mutex::new(finished);
let jobs = [(install(), "a.deb"), (install(), "b.deb")];
std::thread::scope(|scope| {
let (queue, cancel, finished) = (&queue, &cancel, &finished);
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let handles: Vec<_> = jobs
.iter()
.map(|(command, target)| {
let (turned, in_call) = (turned.clone(), in_call.clone());
scope.spawn(move || {
let mut turn = |waiting: bool| {
if waiting {
turned.send(()).unwrap();
}
};
queue.batched(command.clone(), os(target), &|| cancel.load(Ordering::SeqCst), &mut turn, &|_, targets| {
assert_eq!(targets.len(), 2);
in_call.send(()).unwrap();
finished.lock().unwrap().recv().unwrap();
Ok(())
})
})
})
.collect();
turns.recv().unwrap();
turns.recv().unwrap();
release.send(()).unwrap();
is_in_call.recv().unwrap();
// Cancelled mid-call: the job taken into the other's call has time to notice, and must not leave.
cancel.store(true, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(300));
finish.send(()).unwrap();
for h in handles {
assert_eq!(h.join().unwrap(), Ok(()));
}
});
}
#[test]
fn an_installer_that_dies_frees_the_queue_and_fails_its_jobs() {
let queue = Queue::new();
let jobs = [(install(), "a.deb"), (install(), "b.deb")];
let results = behind_a_running_install(&queue, &jobs, &|_, targets| {
assert_eq!(targets.len(), 2);
panic!("the installer died")
});
// The job that made the call unwound; the other was told.
assert!(results.contains(&None), "{results:?}");
assert!(results.contains(&Some(Err("the system installer was interrupted".to_string()))), "{results:?}");
assert_eq!(queue.batched(install(), os("c.deb"), &|| false, &mut |_| {}, &|_, _| Ok(())), Ok(()));
}
}

View File

@ -1,149 +0,0 @@
//! The pictures on an app's details page. They are not part of the launcher: opening a page
//! fetches them once and keeps them in the cache folder, so the next time (and any time offline)
//! they come from disk.
use std::path::Path;
use artcraft_launcher_core::details::{AppDetails, MAX_SCREENSHOT_BYTES, looks_like_jpeg};
use crate::http::Http;
use crate::paths::{Paths, write_atomic};
/// The picture saved at `saved`, if there is one and it's still a JPEG of a sane size. A damaged or
/// swapped file is ignored, and the picture is fetched again over it.
fn saved_copy(saved: &Path) -> Option<Vec<u8>> {
if std::fs::metadata(saved).ok()?.len() > MAX_SCREENSHOT_BYTES as u64 {
return None;
}
std::fs::read(saved).ok().filter(|bytes| looks_like_jpeg(bytes))
}
/// Why a picture couldn't be had.
#[derive(Debug, PartialEq, Eq)]
enum Failure {
/// This picture only: it isn't published, or what came back isn't a picture.
Picture(String),
/// The network or the server: the other pictures would fail the same way.
Network(String),
}
/// Downloads the picture at `url` and saves it at `saved`. A saved file that can't be written is
/// only a missed cache: the picture is still shown.
fn download(http: &Http, url: &str, saved: &Path) -> Result<Vec<u8>, Failure> {
let bytes = match http.get_bytes(url, MAX_SCREENSHOT_BYTES) {
Ok(Some(bytes)) => bytes,
Ok(None) => return Err(Failure::Picture("this picture isn't published".into())),
Err(why) => return Err(Failure::Network(why)),
};
// A captive portal's login page or a proxy's error page arrives as a 200: don't keep it.
if !looks_like_jpeg(&bytes) {
return Err(Failure::Picture("the answer isn't a JPEG picture".into()));
}
if let Err(e) = write_atomic(saved, &bytes) {
log::warn!("couldn't keep {}: {e}", saved.display());
}
Ok(bytes)
}
/// Every picture of `details`, lead first, handed to `emit` one by one as it is had. Saved copies
/// need no network. Once the network or the server has failed, the pictures still to fetch fail at
/// once with the same reason instead of each waiting for its own timeout; saved ones are still
/// served. A picture that is merely missing doesn't hold the others back.
pub fn fetch_all(http: Option<&Http>, paths: &Paths, details: &AppDetails, mut emit: impl FnMut(&'static str, Result<Vec<u8>, String>)) {
let mut failed: Option<String> = None;
for shot in details.shots {
let saved = paths.screenshot(details.id, shot.key);
if let Some(bytes) = saved_copy(&saved) {
emit(shot.key, Ok(bytes));
continue;
}
if let Some(why) = &failed {
emit(shot.key, Err(why.clone()));
continue;
}
let result = match (http, details.screenshot_url(shot.key)) {
(Some(http), Some(url)) => download(http, &url, &saved),
(None, _) => Err(Failure::Network("HTTPS isn't available".into())),
(_, None) => Err(Failure::Picture("this page has no such picture".into())),
};
let result = result.map_err(|failure| {
let why = match failure {
Failure::Network(why) => {
failed = Some(why.clone());
why
}
Failure::Picture(why) => why,
};
log::warn!("picture {}/{} unavailable: {why}", details.id, shot.key);
why
});
emit(shot.key, result);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::tests::{Reply, serve};
/// A body that starts like a JPEG, and its address on the test server.
fn jpeg() -> Vec<u8> {
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
bytes.extend((0..5_000u32).map(|i| (i % 251) as u8));
bytes
}
#[test]
fn a_downloaded_picture_is_saved_where_it_will_be_read_from() {
let dir = tempfile::tempdir().unwrap();
let saved = dir.path().join("x").join("pic.jpg");
let http = Http::new().unwrap();
let (url, _) = serve(jpeg(), vec![Reply::Full]);
assert_eq!(download(&http, &url, &saved).unwrap(), jpeg());
assert_eq!(saved_copy(&saved), Some(jpeg()));
}
#[test]
fn what_is_not_a_picture_is_not_kept() {
let dir = tempfile::tempdir().unwrap();
let saved = dir.path().join("pic.jpg");
let http = Http::new().unwrap();
let (url, _) = serve(b"<html>Sign in to the Wi-Fi</html>".to_vec(), vec![Reply::Full]);
assert_eq!(download(&http, &url, &saved), Err(Failure::Picture("the answer isn't a JPEG picture".into())));
assert!(!saved.exists());
let (url, _) = serve(Vec::new(), vec![Reply::Missing]);
assert_eq!(download(&http, &url, &saved), Err(Failure::Picture("this picture isn't published".into())));
assert!(!saved.exists());
// A server that can't be reached is the network's failure, not this picture's.
let gone = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
assert!(matches!(download(&http, &format!("http://{gone}/pic.jpg"), &saved), Err(Failure::Network(_))));
}
#[test]
fn a_damaged_saved_copy_is_ignored() {
let dir = tempfile::tempdir().unwrap();
let saved = dir.path().join("pic.jpg");
assert_eq!(saved_copy(&saved), None, "nothing saved");
std::fs::write(&saved, b"<html>oops</html>").unwrap();
assert_eq!(saved_copy(&saved), None, "not a JPEG");
std::fs::write(&saved, vec![0xFF; MAX_SCREENSHOT_BYTES + 1]).unwrap();
assert_eq!(saved_copy(&saved), None, "too big");
std::fs::write(&saved, jpeg()).unwrap();
assert_eq!(saved_copy(&saved), Some(jpeg()));
}
#[test]
fn saved_pictures_need_no_network_and_a_failure_ends_the_run() {
let paths = Paths::under(tempfile::tempdir().unwrap().path());
let page = AppDetails::by_id("photocraft").unwrap();
// The second picture is saved already; the first and the others need the network, which is not there.
let second = paths.screenshot(page.id, page.shots[1].key);
write_atomic(&second, &jpeg()).unwrap();
let mut seen = Vec::new();
fetch_all(None, &paths, page, |key, result| seen.push((key, result)));
let keys: Vec<_> = seen.iter().map(|(k, _)| *k).collect();
assert_eq!(keys, page.shots.iter().map(|s| s.key).collect::<Vec<_>>(), "every picture is answered, in order");
assert!(seen[0].1.is_err() && seen[2].1.is_err() && seen[3].1.is_err());
assert_eq!(seen[1].1.as_ref().unwrap(), &jpeg());
}
}

View File

@ -127,7 +127,7 @@ fn msi_install_dir() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
fn detect(exe: &Path) -> Option<Install> {
if process::in_sandbox() {
if std::env::var_os("FLATPAK_ID").is_some() {
return None;
}
if let Some(appimage) = std::env::var_os("APPIMAGE") {
@ -152,9 +152,7 @@ fn detect(_exe: &Path) -> Option<Install> {
}
/// Downloads `release` for `platform`, puts it in place and starts the helper that relaunches
/// it once this process has exited. On success the caller must quit promptly. `last` is called
/// once the download is ready and returns when the launcher may replace itself and restart.
#[allow(clippy::too_many_arguments)]
/// it once this process has exited. On success the caller must quit promptly.
pub fn update(
install: &Install,
release: &Release,
@ -163,15 +161,12 @@ pub fn update(
paths: &Paths,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
last: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
let (asset, _) = pick_asset(release, &[LAUNCHER.id], platform, &[install.kind()])
.ok_or_else(|| format!("ArtCraft Launcher {} has no {} for {}", release.version_label(), install.kind().label(), platform.label()))?;
let asset = asset.clone();
let mut job = Job { http, paths, stop, phase };
let file = install::download(release, &asset, &mut job)?;
// The restart ends this process: nothing else may be under way.
last()?;
let (file, _) = install::download(release, &asset, &mut job)?;
phase(JobPhase::Installing);
if let Err(e) = crate::paths::write_atomic(&marker(paths), format!("{}\n{}\n", release.tag, crate::now()).as_bytes()) {
log::warn!("couldn't record the self-update: {e}");
@ -181,7 +176,7 @@ pub fn update(
#[cfg(target_os = "macos")]
Install::MacApp(bundle) => {
let existing = Installed { version: None, source: InstallSource::MacApp, location: bundle.display().to_string(), package: None };
let placed = install::macos::install_dmg(&LAUNCHER, &file, Some(&existing), paths, &install::macos::Expect::release(release));
let placed = install::macos::install_dmg(&LAUNCHER, &file, Some(&existing), paths);
let _ = std::fs::remove_file(&file);
let placed = placed?;
relaunch_after_exit(pid, &[Path::new("/usr/bin/open").as_os_str(), placed.as_os_str()])
@ -194,7 +189,7 @@ pub fn update(
}
#[cfg(target_os = "linux")]
Install::Package(kind, exe) => {
let result = install::linux::install_package(*kind, &file, stop, phase);
let result = install::linux::install_package(*kind, &file);
let _ = std::fs::remove_file(&file);
result?;
relaunch_after_exit(pid, &[exe.as_os_str()])
@ -357,7 +352,6 @@ mod tests {
let src = dir.path().join("src");
bundle(&src, "0.2.0");
let dmg = dir.path().join("artcraft-launcher-0.2.0-macos-universal.dmg");
let one_at_a_time = install::macos::DISK_IMAGES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let ok = std::process::Command::new("hdiutil")
.args(["create", "-quiet", "-format", "UDZO", "-volname", "ArtCraft Launcher 0.2.0", "-srcfolder"])
.arg(&src)
@ -366,12 +360,9 @@ mod tests {
.unwrap()
.success();
assert!(ok, "hdiutil create failed");
drop(one_at_a_time);
let current = apps.join("ArtCraft Launcher.app");
let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };
// An unsigned test bundle: the signature check has its own tests in `install`.
let expect = install::macos::Expect { team: None, version: None };
let placed = install::macos::install_dmg(&LAUNCHER, &dmg, Some(&existing), &Paths::under(dir.path()), &expect).unwrap();
let placed = install::macos::install_dmg(&LAUNCHER, &dmg, Some(&existing), &Paths::under(dir.path())).unwrap();
assert_eq!(placed, current);
assert_eq!(std::fs::read_to_string(current.join("Contents/MacOS/ArtCraft Launcher")).unwrap(), "0.2.0");
assert!(!dir.path().join("Applications/.ArtCraft Launcher.app.partial").exists());

View File

@ -1,246 +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. A second start says hello with its
//! version, and the first answers with its own:
//!
//! - the newcomer is the same version or older: the running one shows its window and the newcomer
//! exits, as before;
//! - the newcomer is newer: the running one quits (unless it's in the middle of installing
//! something; then it shows itself) and the newcomer takes over. Otherwise starting a new
//! version while an old one sat in the tray brought back the old window, which still offered
//! whatever update it had found back then (issue #29: 0.1.2 showed "0.1.1 is available");
//! - the running one is from before this handshake (0.1.3 or older) and closes without an
//! answer: it can't be asked to quit, so the newcomer starts anyway.
//!
//! Launchers from before the handshake send a bare `show`, which is still understood. The worst
//! another local program can do with any of this is bring the window forward or make the launcher
//! quit.
//! 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::{ErrorKind, Read, Write};
use std::io::{Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, channel};
use std::time::{Duration, Instant};
use artcraft_launcher_core::version::{self, Version};
use std::time::Duration;
const PORT: u16 = 47_413;
/// What launchers before the version handshake send (and still the answer to an unknown peer).
const SHOW: &[u8] = b"artcraft-launcher:show";
/// `hello <version>`: a second start introduces itself.
const HELLO: &str = "artcraft-launcher:hello ";
/// `running <version>`: the first instance's answer.
const RUNNING: &str = "artcraft-launcher:running ";
/// How long a newer start waits for the old one to quit and free the port.
const TAKE_OVER_WITHIN: Duration = Duration::from_secs(10);
/// What another start asks of the running launcher.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Request {
/// Bring the window forward.
Show,
/// A newer launcher is starting: quit so it can take over (or show the window when quitting
/// now would cut short an install).
Replace,
}
const MESSAGE: &[u8] = b"artcraft-launcher:show";
pub enum Instance {
/// We're the only one. A request arrives on the receiver whenever another start asks
/// something; `wake` is called to repaint the (perhaps hidden) window.
Primary(Receiver<Request>),
/// 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,
}
/// How the running launcher answered our hello.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Answer {
/// It's as new as we are, or newer, and shows its window: we exit.
Defer,
/// It's older and quits: we take over the port.
TakeOver,
/// It closed without an answer: a launcher from before the handshake.
Legacy,
}
type Wake = std::sync::Arc<std::sync::OnceLock<Box<dyn Fn() + Send + Sync>>>;
/// This launcher's own version.
fn ours() -> Option<Version> {
version::parse(env!("CARGO_PKG_VERSION"))
}
/// Claims the instance; `wake` is set later (the UI context doesn't exist yet).
pub fn claim(wake: Wake) -> Instance {
pub fn claim(wake: std::sync::Arc<std::sync::OnceLock<Box<dyn Fn() + Send + Sync>>>) -> Instance {
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, PORT));
let ours = ours();
if let Ok(listener) = TcpListener::bind(addr) {
return Instance::Primary(listen(listener, ours, wake));
}
match ask(addr, ours.as_ref()) {
Ok(Answer::Defer) => Instance::Secondary,
Ok(Answer::TakeOver) => {
let deadline = Instant::now() + TAKE_OVER_WITHIN;
while Instant::now() < deadline {
if let Ok(listener) = TcpListener::bind(addr) {
return Instance::Primary(listen(listener, ours, wake));
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();
}
}
}
std::thread::sleep(Duration::from_millis(100));
}
// It stayed (busy installing) and showed its window instead.
Instance::Secondary
});
Instance::Primary(rx)
}
Ok(Answer::Legacy) => {
log::warn!("an older ArtCraft Launcher is running and can't be asked to quit; starting anyway");
Instance::Primary(channel().1)
}
// The port is taken by something else: run anyway rather than refuse to start.
Err(_) => Instance::Primary(channel().1),
}
}
/// Answers other starts on `listener` until the receiver is dropped.
fn listen(listener: TcpListener, ours: Option<Version>, wake: Wake) -> Receiver<Request> {
let (tx, rx) = channel();
std::thread::spawn(move || {
for mut 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);
let Some((request, reply)) = buf.get(..n).and_then(|m| answer(m, ours.as_ref())) else { continue };
if let Some(reply) = reply {
let _ = stream.write_all(reply.as_bytes());
Err(_) => match TcpStream::connect_timeout(&addr, Duration::from_secs(1)) {
Ok(mut stream) => {
let _ = stream.write_all(MESSAGE);
Instance::Secondary
}
drop(stream);
if tx.send(request).is_err() {
break;
}
if let Some(wake) = wake.get() {
wake();
}
}
});
rx
}
/// The running launcher's side: what `message` asks for, and what to answer.
fn answer(message: &[u8], ours: Option<&Version>) -> Option<(Request, Option<String>)> {
if message == SHOW {
return Some((Request::Show, None));
}
let theirs = std::str::from_utf8(message).ok()?.strip_prefix(HELLO)?;
let newer = match (version::parse(theirs), ours) {
(Some(theirs), Some(ours)) => version::is_newer(&theirs, ours),
_ => false,
};
let reply = format!("{RUNNING}{}", ours.map_or_else(|| "unknown".to_string(), ToString::to_string));
Some((if newer { Request::Replace } else { Request::Show }, Some(reply)))
}
/// The new start's side: says hello to whatever holds the port and reads the answer.
fn ask(addr: SocketAddr, ours: Option<&Version>) -> std::io::Result<Answer> {
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(1))?;
let Some(ours) = ours else {
// Can't compare without our own version: ask it to show itself, as before.
stream.write_all(SHOW)?;
return Ok(Answer::Defer);
};
stream.write_all(format!("{HELLO}{ours}").as_bytes())?;
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let mut reply = Vec::new();
match (&mut stream).take(64).read_to_end(&mut reply) {
Ok(_) => {}
// An old launcher may reset the connection rather than close it.
Err(e) if reply.is_empty() && matches!(e.kind(), ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted) => {}
// No answer in time: leave the running one be.
Err(_) if reply.is_empty() => return Ok(Answer::Defer),
Err(_) => {}
}
Ok(verdict(&reply, ours))
}
/// Reads the running launcher's answer.
fn verdict(reply: &[u8], ours: &Version) -> Answer {
if reply.is_empty() {
return Answer::Legacy;
}
let running = std::str::from_utf8(reply).ok().and_then(|r| r.strip_prefix(RUNNING)).and_then(version::parse);
match running {
Some(running) if version::is_newer(ours, &running) => Answer::TakeOver,
_ => Answer::Defer,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn v(s: &str) -> Version {
version::parse(s).unwrap()
}
/// A primary on a free loopback port, as version `running`.
fn primary(running: &str) -> (SocketAddr, Receiver<Request>) {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let addr = listener.local_addr().unwrap();
(addr, listen(listener, Some(v(running)), Wake::default()))
}
fn next(rx: &Receiver<Request>) -> Request {
rx.recv_timeout(Duration::from_secs(5)).unwrap()
}
// Issue #29: 0.1.2 started while 0.1.0 sat in the tray, and the 0.1.0 window came up
// offering 0.1.1. A newer start must replace the older one, not show it.
#[test]
fn a_newer_start_replaces_an_older_running_launcher() {
let (addr, rx) = primary("0.1.0");
assert_eq!(ask(addr, Some(&v("0.1.2"))).ok(), Some(Answer::TakeOver));
assert_eq!(next(&rx), Request::Replace);
}
#[test]
fn the_same_or_an_older_start_shows_the_running_launcher() {
let (addr, rx) = primary("0.1.2");
assert_eq!(ask(addr, Some(&v("0.1.2"))).ok(), Some(Answer::Defer));
assert_eq!(next(&rx), Request::Show);
assert_eq!(ask(addr, Some(&v("0.1.0"))).ok(), Some(Answer::Defer));
assert_eq!(next(&rx), Request::Show);
}
#[test]
fn a_start_from_before_the_handshake_still_shows_the_window() {
let (addr, rx) = primary("0.1.4");
let mut s = TcpStream::connect(addr).unwrap();
s.write_all(SHOW).unwrap();
assert_eq!(next(&rx), Request::Show);
}
#[test]
fn a_running_launcher_from_before_the_handshake_is_recognised() {
// What 0.1.0 to 0.1.3 do with a message they don't know: read it and hang up.
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let addr = listener.local_addr().unwrap();
let old = std::thread::spawn(move || {
if let Ok((mut s, _)) = listener.accept() {
let mut buf = [0u8; 64];
let _ = s.read(&mut buf);
}
});
assert_eq!(ask(addr, Some(&v("0.1.4"))).ok(), Some(Answer::Legacy));
let _ = old.join();
}
#[test]
fn answers_are_read_defensively() {
assert_eq!(answer(b"artcraft-launcher:hello 9.9.9", Some(&v("0.1.0"))).map(|a| a.0), Some(Request::Replace));
assert_eq!(answer(b"artcraft-launcher:hello nonsense", Some(&v("0.1.0"))).map(|a| a.0), Some(Request::Show));
assert_eq!(answer(b"artcraft-launcher:hello 9.9.9", None).map(|a| a.0), Some(Request::Show));
assert_eq!(answer(b"hello", Some(&v("0.1.0"))), None);
assert_eq!(answer(&[0xff, 0xfe], Some(&v("0.1.0"))), None);
assert_eq!(verdict(b"artcraft-launcher:running 0.1.0", &v("0.1.2")), Answer::TakeOver);
assert_eq!(verdict(b"artcraft-launcher:running 0.1.2", &v("0.1.2")), Answer::Defer);
assert_eq!(verdict(b"artcraft-launcher:running unknown", &v("0.1.2")), Answer::Defer);
assert_eq!(verdict(b"garbage", &v("0.1.2")), Answer::Defer);
assert_eq!(verdict(b"", &v("0.1.2")), Answer::Legacy);
// The port is taken by something else: run anyway rather than refuse to start.
Err(_) => Instance::Primary(channel().1),
},
}
}

View File

@ -14,17 +14,10 @@ workspace = true
artcraft-launcher-core = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true, features = ["image", "svg"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
image = { version = "0.25", default-features = false, features = ["png"] }
serde = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The OS's preferred UI languages, without spawning `defaults` or `reg.exe`.
sys-locale = "0.3"
# Finding the Simplified Chinese face in a system font collection; egui already uses it.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
[dev-dependencies]
serde_json = { workspace = true }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
image = { version = "0.25", default-features = false, features = ["png"] }

View File

@ -2,16 +2,12 @@
use std::time::Duration;
use artcraft_launcher_core::protocol::{
Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, LatestRelease, PAUSED, Source,
};
use artcraft_launcher_core::protocol::{Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, LatestRelease, PAUSED, Source};
use artcraft_launcher_core::release::Release;
use artcraft_launcher_core::status::AppStatus;
use artcraft_launcher_core::{Platform, SUITE};
use crate::i18n::set_current;
use crate::model::AppEntry;
use crate::motion;
use crate::settings::{Filter, Settings, SortKey, ViewMode};
use crate::theme::{self, Tokens};
@ -53,11 +49,6 @@ pub trait Host {
fn set_login_item(&mut self, _on: bool) -> Result<(), String> {
Err("not supported here".into())
}
/// The OS asks for fewer animations (Windows' "Animation effects" switched off, macOS'
/// "Reduce motion"). Asked about once a second while the window draws, so it must be quick.
fn reduce_motion(&self) -> bool {
false
}
/// Quit the launcher for real (not just hide to the tray).
fn quit(&mut self) {}
}
@ -73,20 +64,6 @@ pub(crate) enum Dialog {
Notes(String),
ConfirmUninstall(String),
Settings,
LauncherUpdate(artcraft_launcher_core::Release),
}
impl Dialog {
/// What sets this dialog apart from the others, for egui's memory: each one keeps its own
/// size there, so a small prompt never opens at the height of the last dialog shown (#88).
pub(crate) fn key(&self) -> (&'static str, &str) {
match self {
Dialog::Notes(id) => ("notes", id),
Dialog::ConfirmUninstall(id) => ("uninstall", id),
Dialog::Settings => ("settings", ""),
Dialog::LauncherUpdate(r) => ("launcher-update", &r.tag),
}
}
}
pub struct LauncherUi {
@ -110,46 +87,16 @@ pub struct LauncherUi {
pub(crate) search: String,
pub(crate) toasts: Vec<Toast>,
pub(crate) dialog: Option<Dialog>,
/// The app whose details drawer is open (or closing: see `detail_shown`).
pub(crate) detail: Option<String>,
/// The app the drawer is drawn for: it outlives `detail` while the drawer slides out.
pub(crate) detail_shown: Option<String>,
/// Which screenshot the drawer shows.
pub(crate) detail_shot: usize,
/// The screenshot is enlarged over the whole window.
pub(crate) detail_zoom: bool,
/// The details pictures the backend has sent (or been asked for), by app and picture key.
pub(crate) pictures: std::collections::HashMap<(String, String), crate::details::Picture>,
started: bool,
applied_theme: Option<theme::ThemeChoice>,
/// The OS asks for fewer animations: what the host last said, and the context's time to ask
/// it again at.
pub(crate) reduce_motion: bool,
motion_recheck: f64,
/// What the context's styles were last set for.
applied_motion: Option<bool>,
/// Fonts registered with `set_fonts` only exist from the next frame on; drawing with the named
/// weights before then would abort.
fonts_ready: bool,
/// The CJK language whose system font is installed, if any.
/// Set when such a language comes into use, then kept for the session.
pub(crate) cjk_font: Option<crate::i18n::Lang>,
sent_options: Option<BackendOptions>,
/// Unix seconds of the last look at what's installed (startup counts).
last_detect: i64,
/// Whether the window had focus last frame, to re-check when the user comes back to it.
was_focused: bool,
/// Versions of apps or the launcher already queued for auto-update during this session,
/// preventing repeated install loops if an update fails or is in progress.
pub(crate) auto_update_attempted: std::collections::BTreeSet<(String, String)>,
}
/// The language to draw in: the preference, or English where it needs a CJK font the system
/// doesn't have (Settings says so), rather than boxes for every character. The only place the
/// CJK font gets loaded: when such a language is selected, or Auto resolves to it.
pub(crate) fn effective_lang(pref: &str) -> crate::i18n::Lang {
let lang = crate::i18n::Lang::from_pref(pref);
if lang.needs_cjk_font() && !theme::cjk_font_available(lang) { crate::i18n::Lang::EN } else { lang }
}
/// How often to look again at what's installed, to notice apps installed or removed outside
@ -160,9 +107,7 @@ const RESCAN_ON_FOCUS_AFTER: i64 = 30;
impl LauncherUi {
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
let lang = effective_lang(&settings.language);
let cjk_font = lang.needs_cjk_font().then_some(lang);
theme::install_fonts(ctx, cjk_font);
theme::install_fonts(ctx);
egui_extras::install_image_loaders(ctx);
let caps = backend.capabilities();
let platform = backend.platform();
@ -183,61 +128,22 @@ impl LauncherUi {
search: String::new(),
toasts: Vec::new(),
dialog: None,
detail: None,
detail_shown: None,
detail_shot: 0,
detail_zoom: false,
pictures: std::collections::HashMap::new(),
started: false,
applied_theme: None,
reduce_motion: false,
motion_recheck: 0.0,
applied_motion: None,
fonts_ready: false,
cjk_font,
sent_options: None,
last_detect: 0,
was_focused: true,
auto_update_attempted: std::collections::BTreeSet::new(),
}
}
/// Moves the UI to another window's context: fonts, image loaders, the theme and the animation
/// timings belong to a context, and toasts and the next look at the OS's animation setting are
/// timed on its clock.
pub fn attach(&mut self, ctx: &egui::Context) {
theme::install_fonts(ctx, self.cjk_font);
egui_extras::install_image_loaders(ctx);
self.fonts_ready = false;
self.applied_theme = None;
self.applied_motion = None;
self.motion_recheck = 0.0;
self.toasts.clear();
}
/// Input on its way to egui (the shell's `raw_input_hook`): with fewer animations asked for,
/// a mouse wheel scrolls at once instead of gliding.
pub fn raw_input(&self, ctx: &egui::Context, raw: &mut egui::RawInput) {
if self.reduce_motion {
motion::unsmooth_wheel(ctx, raw);
}
}
/// Installed apps with an update waiting (the tray shows the count). Hidden apps are left out,
/// so the badge matches the Updates list.
/// Installed apps with an update waiting (the tray shows the count).
pub fn update_count(&self) -> usize {
self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates)).count()
}
/// Something is downloading or installing, the launcher's own update included: quitting now
/// would cut it short.
pub fn busy(&self) -> bool {
self.launcher_job.is_some() || self.entries.iter().any(|e| e.job.is_some())
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.auto_update_attempted.clear();
self.backend.send(Command::CheckForUpdates { force: true });
self.backend.send(Command::Detect);
self.last_detect = self.backend.now();
@ -265,7 +171,6 @@ impl LauncherUi {
/// 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) {
set_current(crate::i18n::Lang::from_pref(&self.settings.language));
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()));
@ -299,9 +204,6 @@ impl LauncherUi {
}
if changed {
self.announce_updates(host);
if self.settings.auto_update {
self.trigger_auto_updates();
}
}
if self.restart_requested {
// The helper is waiting for this process to exit before it starts the new launcher.
@ -325,10 +227,6 @@ impl LauncherUi {
}
true
}
Event::Screenshot { app, key, result } => {
self.pictures.insert((app, key), crate::details::Picture::from_result(result));
true
}
Event::Detected { app, installed } => {
if let Some(e) = self.entry_mut(&app) {
e.installed = installed;
@ -339,9 +237,6 @@ impl LauncherUi {
Event::Progress { app, phase } => {
if app == artcraft_launcher_core::catalog::LAUNCHER_ID {
self.launcher_job = Some(phase);
if matches!(self.dialog, Some(Dialog::LauncherUpdate(_))) {
self.dialog = None;
}
} else if let Some(e) = self.entry_mut(&app) {
e.job = Some(phase);
}
@ -362,48 +257,25 @@ impl LauncherUi {
}
false
}
Event::ReadyToInstall { app, ready } => {
if let Some(e) = self.entry_mut(&app) {
e.ready = ready;
}
false
}
Event::JobFinished { app, result } => {
let launcher = app == artcraft_launcher_core::catalog::LAUNCHER_ID;
// What the job was doing when it ended says what a cancellation called off.
let last = if launcher { self.launcher_job.take() } else { self.entry(&app).and_then(|e| e.job) };
if app == artcraft_launcher_core::catalog::LAUNCHER_ID {
self.launcher_job = None;
}
if let Some(e) = self.entry_mut(&app) {
e.job = None;
}
let kept = self.entry(&app).is_some_and(|e| e.ready);
match result {
Ok(msg) => self.toast(ctx, msg, false),
// The paused card says it all.
Err(e) if e == PAUSED => {}
Err(e) if e == CANCELLED && launcher => self.toast(ctx, tl!("Update cancelled"), false),
// An install's own cancellations end as INSTALL_CANCELLED, so past the
// download this is a removal called off while it waited for the installer.
Err(e) if e == CANCELLED && matches!(last, Some(JobPhase::Waiting | JobPhase::Removing)) => {
self.toast(ctx, tl!("Uninstall cancelled"), false);
}
Err(e) if e == CANCELLED => self.toast(ctx, tl!("Download cancelled"), false),
Err(e) if e == INSTALL_CANCELLED && kept => {
self.toast(ctx, tl!("Install cancelled. The download is kept for the next try."), false);
}
Err(e) if e == INSTALL_CANCELLED => self.toast(ctx, tl!("Install cancelled"), false),
Err(e) if e == CANCELLED => self.toast(ctx, "Download cancelled", false),
Err(e) => self.toast(ctx, e, true),
}
true
}
Event::LauncherUpdate(r) => {
let fresh = self.launcher_update.as_ref().is_none_or(|cur| cur.version != r.version);
self.launcher_update = Some(r.clone());
// Ask once per new version, unless automatic updates will install it anyway.
let automatic = self.settings.auto_update && self.caps.self_update;
if fresh && !automatic && self.dialog.is_none() && self.launcher_job.is_none() {
self.dialog = Some(Dialog::LauncherUpdate(r));
}
fresh
self.launcher_update = Some(r);
false
}
Event::CheckStarted => {
self.checking = true;
@ -423,7 +295,7 @@ impl LauncherUi {
fn announce_updates(&mut self, host: &mut dyn Host) {
let mut fresh = Vec::new();
for e in &self.entries {
if !self.in_filter(e, Filter::Updates) {
if e.status(self.platform, self.caps.manage_apps) != AppStatus::UpdateAvailable {
continue;
}
let Some(v) = e.latest_label() else { continue };
@ -436,13 +308,9 @@ impl LauncherUi {
}
if self.settings.notify {
let (title, body) = match fresh.as_slice() {
[(_, name, v)] => {
let body: String = tl!("Open ArtCraft Launcher to update.").to_string();
(crate::i18n::fmt(tl!("{name} {v} is available"), &[("name", name), ("v", v)]), body)
}
[(_, name, v)] => (format!("{name} {v} is available"), "Open ArtCraft Launcher to update.".to_string()),
many => {
let body: String = many.iter().map(|(_, n, v)| format!("{n} {v}")).collect::<Vec<_>>().join(", ");
(crate::i18n::fmt(tl!("{count} updates are available"), &[("count", &many.len().to_string())]), body)
(format!("{} updates are available", many.len()), many.iter().map(|(_, n, v)| format!("{n} {v}")).collect::<Vec<_>>().join(", "))
}
};
host.notify(&title, &body);
@ -452,51 +320,9 @@ impl LauncherUi {
}
}
/// When auto-updates are enabled, automatically downloads and installs pending updates.
pub(crate) fn trigger_auto_updates(&mut self) {
if !self.caps.manage_apps || !self.settings.auto_update {
return;
}
let to_update: Vec<(String, String)> = self
.entries
.iter()
.filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none() && !e.ready)
.filter_map(|e| {
let v = e.latest_label()?;
if self.auto_update_attempted.contains(&(e.app.id.to_string(), v.clone())) { None } else { Some((e.app.id.to_string(), v)) }
})
.collect();
for (id, version) in to_update {
log::info!("auto-update: queuing update for {id} to {version}");
self.auto_update_attempted.insert((id.clone(), version));
self.send(Command::Install { app: id });
}
if self.caps.self_update
&& let Some(r) = &self.launcher_update
&& self.launcher_job.is_none()
{
let id = artcraft_launcher_core::catalog::LAUNCHER_ID.to_string();
let v = r.version_label();
if !self.auto_update_attempted.contains(&(id.clone(), v.clone())) {
log::info!("auto-update: queuing launcher self-update to {v}");
self.auto_update_attempted.insert((id, v));
self.send(Command::UpdateLauncher);
}
}
}
/// Draws the launcher.
pub fn ui(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) {
let ctx = ui.ctx().clone();
// Set the current language for this frame based on settings.
let lang = effective_lang(&self.settings.language);
set_current(lang);
// `effective_lang` only picks a CJK language once its font has loaded.
if lang.needs_cjk_font() && self.cjk_font != Some(lang) {
theme::install_fonts(&ctx, Some(lang));
self.cjk_font = Some(lang);
self.fonts_ready = false;
}
if !self.fonts_ready {
self.fonts_ready = true;
ctx.request_repaint();
@ -506,33 +332,24 @@ impl LauncherUi {
theme::apply(&ctx, self.settings.theme);
self.applied_theme = Some(self.settings.theme);
}
let now = ctx.input(|i| i.time);
if now >= self.motion_recheck {
self.reduce_motion = host.reduce_motion();
self.motion_recheck = now + motion::RECHECK_EVERY;
}
if self.applied_motion != Some(self.reduce_motion) {
motion::apply(&ctx, self.reduce_motion);
self.applied_motion = Some(self.reduce_motion);
}
let tokens = Tokens::get(&ctx);
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));
}
// The bars' separator lines are the hairlines under the header and over the footer.
egui::Panel::top("header")
.frame(egui::Frame::new().fill(tokens.bg))
.frame(egui::Frame::new().fill(t.bg))
.exact_size(crate::views::HEADER_H)
.resizable(false)
.show(ui, |ui| self.header(ui, host.title_bar()));
egui::Panel::bottom("footer")
.frame(egui::Frame::new().fill(tokens.bg).inner_margin(egui::Margin::symmetric(20, 0)))
.frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin::symmetric(20, 0)))
.exact_size(crate::views::FOOTER_H)
.resizable(false)
.show(ui, |ui| self.footer(ui));
egui::CentralPanel::default()
.frame(egui::Frame::new().fill(tokens.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 }))
.frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 }))
.show(ui, |ui| {
self.toolbar(ui);
ui.add_space(18.0);
@ -546,7 +363,7 @@ impl LauncherUi {
if ids.is_empty() {
ui.add_space(40.0);
ui.vertical_centered(|ui| {
crate::widgets::hud(ui, tl!("No apps match"), tokens.muted);
crate::widgets::hud(ui, "No apps match", t.muted);
});
} else {
match self.settings.view {
@ -554,10 +371,10 @@ impl LauncherUi {
ViewMode::Table => self.table(ui, &ids),
}
}
self.trademark_notice(ui);
ui.add_space(16.0);
});
});
self.details_drawer(&ctx);
self.dialogs(&ctx, host);
self.draw_toasts(&ctx);
if host.title_bar() == TitleBar::Custom {
@ -573,8 +390,17 @@ impl LauncherUi {
.iter()
.enumerate()
.filter(|(_, e)| {
self.in_filter(e, self.settings.filter)
&& (needle.is_empty() || e.app.name.to_lowercase().contains(&needle) || e.app.tagline.to_lowercase().contains(&needle))
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;
@ -594,6 +420,7 @@ impl LauncherUi {
let ord = match self.settings.sort {
SortKey::Suite => dir(ia.cmp(ib)),
SortKey::Name => dir(a.app.name.cmp(b.app.name)),
SortKey::InspiredBy => dir(a.app.like.cmp(b.app.like)),
SortKey::Status => {
dir(crate::model::status_rank(&a.status(platform, manage)).cmp(&crate::model::status_rank(&b.status(platform, manage))))
}
@ -633,7 +460,7 @@ impl LauncherUi {
}
}
match (api, github) {
(true, true) => Some(crate::i18n::fmt(tl!("{a} and {b}"), &[("a", Origin::ArtCraftApi.label()), ("b", Origin::GitHub.label())])),
(true, true) => Some(format!("{} and {}", Origin::ArtCraftApi.label(), Origin::GitHub.label())),
(true, false) => Some(Origin::ArtCraftApi.label().to_string()),
(false, true) => Some(Origin::GitHub.label().to_string()),
(false, false) => None,
@ -641,20 +468,18 @@ impl LauncherUi {
}
pub(crate) fn count(&self, filter: Filter) -> usize {
self.entries.iter().filter(|e| self.in_filter(e, filter)).count()
}
/// Whether `entry` belongs in `filter`. Hidden apps stay out of every list except Hidden.
pub(crate) fn in_filter(&self, entry: &AppEntry, filter: Filter) -> bool {
let hidden = self.settings.hidden.contains(entry.app.id);
let status = entry.status(self.platform, self.caps.manage_apps);
match filter {
Filter::Hidden => hidden,
Filter::All => !hidden,
Filter::Installed => !hidden && status.is_installed(),
Filter::Updates => !hidden && status == AppStatus::UpdateAvailable,
Filter::NotInstalled => !hidden && !status.is_installed(),
}
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) {
@ -680,7 +505,7 @@ impl LauncherUi {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, accent);
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::msg(&toast.text)).color(t.ink)).wrap());
ui.add(egui::Label::new(egui::RichText::new(&toast.text).color(t.ink)).wrap());
});
});
if r.response.interact(egui::Sense::click()).clicked() {

View File

@ -1,354 +0,0 @@
//! A CJK font from the operating system, for the CJK interface languages. The bundled faces have
//! no CJK glyphs, and a CJK font is too large to ship (10 MB or more), so the desktop build
//! borrows the one every system that can show them already has. Desktop only.
use std::path::{Path, PathBuf};
use egui::epaint::text::VariationCoords;
use egui::{FontData, FontTweak};
use skrifa::instance::{LocationRef, Size};
use skrifa::{FontRef, MetadataProvider, string::StringId};
use crate::i18n::Script;
/// Larger files aren't read: PingFang, the largest candidate, is about 80 MB.
const MAX_FONT_BYTES: u64 = 100 * 1024 * 1024;
/// Faces looked at in one collection file (PingFang has 24).
const MAX_FACES: u32 = 64;
/// The bundled body font (Inter), whose baseline a fallback face is nudged onto.
const BODY_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
/// `(ascent, ascent + |descent| + leading) / units_per_em` of face `index` in `data`.
///
/// egui puts a fallback glyph's baseline at that face's own ascent and only centres the two line
/// boxes (`galley_from_rows`), so a face whose metrics differ from the body font's rides off the
/// baseline: a face with a half-em line gap, for one, sat about a quarter em high. [`load`]
/// cancels the difference with a `FontTweak`.
fn face_metrics(data: &[u8], index: u32) -> (f32, f32) {
let Ok(font) = FontRef::from_index(data, index) else { return (0.0, 0.0) };
let metrics = font.metrics(Size::unscaled(), LocationRef::default());
let upem = f32::from(metrics.units_per_em);
if upem <= 0.0 {
return (0.0, 0.0);
}
(metrics.ascent / upem, (metrics.ascent - metrics.descent + metrics.leading) / upem)
}
/// The script's name, for the log lines.
fn script_name(script: Script) -> &'static str {
match script {
Script::Chinese => "Chinese",
Script::Japanese => "Japanese",
}
}
/// The families looked for, for the "none found" log line.
fn script_families(script: Script) -> &'static str {
match script {
Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC and WenQuanYi",
Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic",
}
}
/// A font file that may be installed, and the family of its face for this script.
struct Candidate {
path: PathBuf,
family: &'static str,
}
/// The system's font for `script`, or `None` (logged) when it has none this knows about.
/// Reads the file (once per process: the caller caches the answer).
pub(crate) fn load(script: Script) -> Option<FontData> {
for Candidate { path, family } in candidates(script) {
let Some(bytes) = read(&path) else { continue };
let Some(index) = face_index(&bytes, family) else {
log::debug!("no {family} face in {}", path.display());
continue;
};
log::info!("{} text uses {family} from {} (face {index})", script_name(script), path.display());
// Regular weight, in case the face is variable (Noto Sans CJK VF), and a nudge onto the
// body font's baseline (see `face_metrics`).
let (body_ascent, body_height) = face_metrics(BODY_FONT, 0);
let (face_ascent, face_height) = face_metrics(&bytes, index);
let tweak = FontTweak {
coords: VariationCoords::new([(*b"wght", 400.0)]),
y_offset_factor: (body_ascent - face_ascent) + 0.5 * (face_height - body_height),
..Default::default()
};
let mut font = FontData::from_owned(bytes).tweak(tweak);
font.index = index;
return Some(font);
}
log::warn!("no {} font found on this system (looked for {})", script_name(script), script_families(script));
None
}
fn read(path: &Path) -> Option<Vec<u8>> {
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() {
return None;
}
if meta.len() > MAX_FONT_BYTES {
log::warn!("{} is too large to load as a font ({} bytes)", path.display(), meta.len());
return None;
}
std::fs::read(path).map_err(|e| log::warn!("couldn't read {}: {e}", path.display())).ok()
}
/// The face of `family` in a font file or collection: its Regular face, else the first face of
/// that family. Matches the (typographic) family name in any language the file gives it in.
pub(crate) fn face_index(data: &[u8], family: &str) -> Option<u32> {
let names = |font: &FontRef<'_>, ids: [StringId; 2]| -> Vec<String> {
ids.into_iter().flat_map(|id| font.localized_strings(id).map(|s| s.to_string())).collect()
};
let mut first = None;
for index in 0..MAX_FACES {
let Ok(font) = FontRef::from_index(data, index) else { break };
let families = names(&font, [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]);
if !families.iter().any(|f| f.eq_ignore_ascii_case(family)) {
continue;
}
let styles = names(&font, [StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]);
if styles.iter().any(|s| s.eq_ignore_ascii_case("Regular")) {
return Some(index);
}
first.get_or_insert(index);
}
first
}
/// Where each system keeps a font for `script`, best first.
fn candidates(script: Script) -> Vec<Candidate> {
match script {
Script::Chinese => chinese_candidates(),
Script::Japanese => japanese_candidates(),
}
}
/// Where each system keeps a Simplified Chinese font, best first.
fn chinese_candidates() -> Vec<Candidate> {
let mut out = Vec::new();
let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });
#[cfg(target_os = "macos")]
{
// PingFang moved from the system fonts into a downloaded asset in later macOS versions.
add("/System/Library/Fonts/PingFang.ttc".into(), "PingFang SC");
for path in mac_font_assets("PingFang.ttc") {
add(path, "PingFang SC");
}
add("/System/Library/Fonts/Hiragino Sans GB.ttc".into(), "Hiragino Sans GB W3");
add("/System/Library/Fonts/STHeiti Medium.ttc".into(), "Heiti SC");
}
#[cfg(target_os = "windows")]
{
let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
add(root.join("Fonts").join("msyh.ttc"), "Microsoft YaHei");
add(root.join("Fonts").join("simsun.ttc"), "SimSun");
}
#[cfg(all(unix, not(target_os = "macos")))]
{
// Debian/Ubuntu, Arch, Fedora (static and variable) and FreeBSD's ports.
const NOTO_DIRS: [&str; 8] = [
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/noto-cjk",
"/usr/share/fonts/google-noto-cjk",
"/usr/share/fonts/google-noto-sans-cjk-fonts",
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts",
"/usr/share/fonts/noto",
"/usr/local/share/fonts/noto",
"/usr/local/share/fonts/noto-cjk",
];
for file in ["NotoSansCJK-Regular.ttc", "NotoSansCJKsc-Regular.otf", "NotoSansCJK-VF.ttc"] {
for dir in NOTO_DIRS {
add(Path::new(dir).join(file), "Noto Sans CJK SC");
}
}
const WQY_DIRS: [&str; 5] = [
"/usr/share/fonts/truetype/wqy",
"/usr/share/fonts/wenquanyi/wqy-microhei",
"/usr/share/fonts/wqy-microhei",
"/usr/share/fonts/wqy-zenhei",
"/usr/local/share/fonts/wqy",
];
for (file, family) in [("wqy-microhei.ttc", "WenQuanYi Micro Hei"), ("wqy-zenhei.ttc", "WenQuanYi Zen Hei")] {
for dir in WQY_DIRS {
add(Path::new(dir).join(file), family);
}
}
}
out
}
/// Where each system keeps a Japanese font, best first.
fn japanese_candidates() -> Vec<Candidate> {
let mut out = Vec::new();
let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });
#[cfg(target_os = "macos")]
{
// Hiragino Sans (its file keeps the Japanese name); it may be a downloaded font asset.
for file in ["ヒラギノ角ゴシック W3.ttc", "ヒラギノ角ゴシック W4.ttc", "ヒラギノ角ゴシック W6.ttc"] {
add(Path::new("/System/Library/Fonts").join(file), "Hiragino Sans");
for path in mac_font_assets(file) {
add(path, "Hiragino Sans");
}
}
add("/System/Library/Fonts/ヒラギノ丸ゴ ProN W4.ttc".into(), "Hiragino Maru Gothic ProN");
}
#[cfg(target_os = "windows")]
{
let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
add(root.join("Fonts").join("YuGothR.ttc"), "Yu Gothic");
add(root.join("Fonts").join("meiryo.ttc"), "Meiryo");
add(root.join("Fonts").join("msgothic.ttc"), "MS Gothic");
}
#[cfg(all(unix, not(target_os = "macos")))]
{
// Debian/Ubuntu, Arch, Fedora (static and variable) and FreeBSD's ports.
const NOTO_DIRS: [&str; 8] = [
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/noto-cjk",
"/usr/share/fonts/google-noto-cjk",
"/usr/share/fonts/google-noto-sans-cjk-fonts",
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts",
"/usr/share/fonts/noto",
"/usr/local/share/fonts/noto",
"/usr/local/share/fonts/noto-cjk",
];
for file in ["NotoSansCJK-Regular.ttc", "NotoSansCJKjp-Regular.otf", "NotoSansCJK-VF.ttc"] {
for dir in NOTO_DIRS {
add(Path::new(dir).join(file), "Noto Sans CJK JP");
}
}
// Standalone Noto Sans JP, and the classic Japanese faces (ipafont-gothic, Takao, VL).
const JP_DIRS: [&str; 8] = [
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/truetype/noto",
"/usr/share/fonts/noto",
"/usr/share/fonts/truetype/ipafont-gothic",
"/usr/share/fonts/truetype/ipafont",
"/usr/share/fonts/truetype/takao-gothic",
"/usr/share/fonts/truetype/vlgothic",
"/usr/local/share/fonts",
];
for file in ["NotoSansJP-Regular.otf", "NotoSansJP-VF.ttf"] {
for dir in JP_DIRS {
add(Path::new(dir).join(file), "Noto Sans JP");
}
}
for (file, family) in [("ipag.ttf", "IPAGothic"), ("ipagp.ttf", "IPAPGothic")] {
for dir in JP_DIRS {
add(Path::new(dir).join(file), family);
}
}
for (file, family) in [("TakaoGothic.ttf", "TakaoGothic"), ("TakaoPGothic.ttf", "TakaoPGothic")] {
for dir in JP_DIRS {
add(Path::new(dir).join(file), family);
}
}
for (file, family) in [("VL-Gothic-Regular.ttf", "VL Gothic"), ("VL-PGothic-Regular.ttf", "VL PGothic")] {
for dir in JP_DIRS {
add(Path::new(dir).join(file), family);
}
}
}
out
}
/// `file` in macOS's downloaded font assets:
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/*.asset/AssetData/<file>`.
#[cfg(target_os = "macos")]
fn mac_font_assets(file: &str) -> Vec<PathBuf> {
let dirs = |path: &Path| -> Vec<PathBuf> {
std::fs::read_dir(path).map(|entries| entries.filter_map(Result::ok).map(|e| e.path()).collect()).unwrap_or_default()
};
let mut out = Vec::new();
for catalog in dirs(Path::new("/System/Library/AssetsV2")) {
if !catalog.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("com_apple_MobileAsset_Font")) {
continue;
}
for asset in dirs(&catalog) {
let path = asset.join("AssetData").join(file);
if asset.extension().is_some_and(|e| e == "asset") && path.is_file() {
out.push(path);
}
}
}
out.sort();
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn face_index_rejects_what_isnt_a_font() {
assert_eq!(face_index(b"", "PingFang SC"), None);
assert_eq!(face_index(b"ttcf\0\0\0\0garbage", "PingFang SC"), None);
assert_eq!(face_index(&[0u8; 64], "PingFang SC"), None);
}
/// The baseline nudge reads a face's ascent and height; the reference is Inter's.
#[test]
fn face_metrics_reads_font_metrics() {
let (ascent, height) = face_metrics(BODY_FONT, 0);
assert!((ascent - 0.9688).abs() < 0.005, "{ascent}");
assert!((height - 1.21).abs() < 0.005, "{height}");
assert_eq!(face_metrics(b"not a font", 0), (0.0, 0.0));
}
/// A system CJK face sits on the Latin baseline, thanks to the nudge in [`load`]. Without a
/// CJK font (CI, the web) there is nothing to check.
#[test]
fn cjk_sits_on_the_latin_baseline() {
use egui::{Color32, FontFamily, FontId};
for info in crate::i18n::LANGUAGES.iter().filter(|l| l.script.is_some()) {
let Some(lang) = crate::i18n::Lang::from_code(info.code) else { continue };
let Some(font) = crate::theme::cjk_font(lang) else { continue };
let y_offset_factor = font.tweak.y_offset_factor;
// A character the language's own catalog uses, so the sample fits its script: the face
// must draw it, on the Latin baseline.
let Some(sample) =
info.source.lines().filter_map(|line| line.split('\t').nth(2)).flat_map(str::chars).find(|c| !c.is_ascii() && c.is_alphabetic())
else {
continue;
};
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, Some(lang));
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
let size = 20.0;
let font_id = FontId::new(size, FontFamily::Proportional);
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(format!("A{sample}"), font_id.clone(), Color32::WHITE));
assert!(ctx.fonts_mut(|f| f.has_glyphs(&font_id, &sample.to_string())), "{}: no glyph for {sample:?}", lang.code());
let glyphs = &galley.rows[0].row.glyphs;
let pos = |c: char| glyphs.iter().find(|g| g.chr == c).map(|g| g.pos.y);
// `pos.y` is the baseline before the nudge (which moves the ink, not `pos`).
if let (Some(latin), Some(cjk)) = (pos('A'), pos(sample)) {
let rendered = cjk + y_offset_factor * size;
assert!((rendered - latin).abs() < 1.0, "{}: latin {latin}, cjk {rendered}, nudge {y_offset_factor}", lang.code());
}
}
}
#[test]
fn face_index_finds_families_in_real_fonts() {
// The bundled Latin faces stand in for system fonts the test machine may not have.
let inter: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
assert_eq!(face_index(inter, "Inter"), Some(0));
assert_eq!(face_index(inter, "PingFang SC"), None);
}
/// Whatever this machine has loads without panicking, and a found face really is the family's.
#[test]
fn system_font_loads_when_present() {
for script in Script::ALL {
if let Some(font) = load(script) {
let family = candidates(script).into_iter().find(|c| c.path.is_file()).map(|c| c.family);
assert!(family.is_some());
assert!(FontRef::from_index(&font.font, font.index).is_ok());
}
}
}
}

View File

@ -1,475 +0,0 @@
//! An app's details drawer: it slides in from the right over a dimmed grid. Hand-written copy and
//! screenshots come from `core::details`; the live facts (version, date, download size, release
//! notes) from the app's latest GitHub release.
use artcraft_launcher_core::CraftApp;
use std::sync::Arc;
use artcraft_launcher_core::details::AppDetails;
use artcraft_launcher_core::platform::pick_asset;
use artcraft_launcher_core::protocol::Command;
use egui::{Color32, CornerRadius, Id, Margin, Order, Rect, RichText, Sense, Stroke, Ui, emath::easing, pos2, vec2};
use crate::app::LauncherUi;
use crate::model::AppEntry;
use crate::theme::{self, Tokens, rgb, tint};
use crate::views::{FOOTER_H, HEADER_H, date_text};
use crate::widgets::{self, Glyph, Kind};
/// Screenshots are 8:5.
const SHOT_ASPECT: f32 = 0.625;
const PAD: f32 = 24.0;
/// As tall as the name, headline, "Like …" and version lines beside it, so the icon fills the row.
const ICON: f32 = 112.0;
/// The previous / next buttons over a picture.
const ARROW: f32 = 36.0;
const SLIDE_SECS: f32 = 0.22;
/// A details picture, as the backend has sent it.
pub(crate) enum Picture {
/// Asked for, not here yet.
Loading,
Ready(Arc<[u8]>),
/// Why it couldn't be had (offline, not published…).
Failed(String),
}
impl Picture {
pub(crate) fn from_result(result: Result<Vec<u8>, String>) -> Self {
match result {
Ok(bytes) => Self::Ready(bytes.into()),
Err(why) => Self::Failed(why),
}
}
}
impl LauncherUi {
/// Asks the backend for the pictures of `app`'s page that we don't have. Pictures that failed
/// earlier are asked for again: opening the page is the "try again".
fn request_pictures(&mut self, app: &str) {
let Some(details) = AppDetails::by_id(app) else { return };
let mut missing = false;
for shot in details.shots {
let key = (details.id.to_string(), shot.key.to_string());
if !matches!(self.pictures.get(&key), Some(Picture::Ready(_) | Picture::Loading)) {
self.pictures.insert(key, Picture::Loading);
missing = true;
}
}
if missing {
self.send(Command::FetchScreenshots { app: details.id.to_string() });
}
}
/// Why the picture couldn't be had, if that is what happened to it.
fn failure(&self, app: &str, key: &str) -> Option<String> {
match self.pictures.get(&(app.to_string(), key.to_string())) {
Some(Picture::Failed(why)) => Some(why.clone()),
_ => None,
}
}
/// The picture to draw, once it has arrived.
fn picture_source(&self, app: &str, key: &str) -> Option<egui::ImageSource<'static>> {
match self.pictures.get(&(app.to_string(), key.to_string())) {
Some(Picture::Ready(bytes)) => Some(egui::ImageSource::Bytes {
uri: format!("bytes://screenshots/{app}/{key}.jpg").into(),
bytes: egui::load::Bytes::Shared(bytes.clone()),
}),
_ => None,
}
}
/// Draws the drawer for `self.detail`, sliding in and out. Call it every frame, after the
/// main panels, so it covers them.
pub(crate) fn details_drawer(&mut self, ctx: &egui::Context) {
if let Some(id) = &self.detail
&& self.detail_shown.as_ref() != Some(id)
{
self.detail_shown = Some(id.clone());
self.detail_shot = 0;
self.detail_zoom = false;
let app = id.clone();
self.request_pictures(&app);
}
// Asked every frame, even while closed, so the first opening starts from 0 and slides.
let open = self.detail.is_some();
let slide = if self.reduce_motion { 0.0 } else { SLIDE_SECS };
let k = ctx.animate_bool_with_time_and_easing(Id::new("details-slide"), open, slide, easing::cubic_out);
let Some(id) = self.detail_shown.clone() else { return };
let Some(details) = AppDetails::by_id(&id) else {
self.detail = None;
self.detail_shown = None;
return;
};
if k <= 0.0 && !open {
self.detail_shown = None;
return;
}
let Some(entry) = self.entry(&id).cloned() else { return };
if !open {
self.detail_zoom = false;
}
self.details_keys(ctx, details, open);
let t = Tokens::get(ctx);
let area = drawer_area(ctx);
let width = (area.width() * 0.5).clamp(440.0, 680.0).min(area.width());
let left = area.right() - width * k;
self.details_scrim(ctx, area, k, &t);
// Not constrained to the screen: the drawer is partly off it while it slides, and a constrained
// area would keep its contents in place while only the panel behind them moved.
egui::Area::new(Id::new("details-drawer")).order(Order::Foreground).fixed_pos(pos2(left, area.top())).constrain(false).show(ctx, |ui| {
let panel = Rect::from_min_size(pos2(left, area.top()), vec2(width, area.height()));
let painter = ui.painter();
painter.add(theme::shadow(&t).as_shape(panel, CornerRadius::ZERO));
painter.rect_filled(panel, CornerRadius::ZERO, t.bg);
painter.vline(panel.left() + 0.5, panel.y_range(), Stroke::new(1.0, t.line_strong));
ui.set_min_size(panel.size());
ui.set_max_size(panel.size());
// A bar that stays on screen says there is more below the fold. egui's default one is
// floating and invisible until the pointer is over it, so the drawer uses the solid style.
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 8.0;
self.details_hero(ui, &entry, details, &t);
egui::Frame::new().inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
ui.set_width(ui.available_width());
self.gallery(ui, details, &t, rgb(entry.app.color));
ui.add_space(8.0);
for p in details.paragraphs() {
ui.add(egui::Label::new(RichText::new(p).size(14.5).color(t.ink)).wrap());
}
ui.add_space(14.0);
features(ui, details, entry.app, &t);
ui.add_space(14.0);
self.release_section(ui, &entry, details, &t);
});
});
});
if self.detail_zoom && open {
self.lightbox(ctx, details, &t);
}
}
/// Esc closes the enlarged picture first, then the drawer; ← and → flip through the pictures.
/// Nothing happens under a dialog, and the arrows leave a text field alone.
fn details_keys(&mut self, ctx: &egui::Context, details: &AppDetails, open: bool) {
if !open || self.dialog.is_some() {
return;
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if self.detail_zoom {
self.detail_zoom = false;
} else {
self.detail = None;
}
}
let shots = details.shots.len();
if shots > 1 && !ctx.egui_wants_keyboard_input() {
if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
self.flip_shot(shots, true);
}
if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
self.flip_shot(shots, false);
}
}
}
/// Moves to the next (or previous) of `count` pictures, wrapping around.
fn flip_shot(&mut self, count: usize, forward: bool) {
if count < 2 {
return;
}
let at = self.detail_shot.min(count - 1);
self.detail_shot = if forward { (at + 1) % count } else { (at + count - 1) % count };
}
/// The page behind the drawer, dimmed (egui can't blur what is under a layer); a click on it closes the drawer.
fn details_scrim(&mut self, ctx: &egui::Context, area: Rect, k: f32, t: &Tokens) {
egui::Area::new(Id::new("details-scrim")).order(Order::Middle).fixed_pos(area.min).show(ctx, |ui| {
let (rect, resp) = ui.allocate_exact_size(area.size(), Sense::click());
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::scrim(t, k));
if resp.on_hover_cursor(egui::CursorIcon::Default).clicked() {
self.detail = None;
}
});
}
/// The drawer's top: a wash of the app's colour under a 2 px rule, the close button, the name,
/// version and main button, and the app's links.
fn details_hero(&mut self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens) {
let app = entry.app;
let app_color = rgb(app.color);
let status = entry.status(self.platform, self.caps.manage_apps);
let hero = egui::Frame::new().fill(tint(app_color, if t.dark { 0.09 } else { 0.07 })).inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
widgets::hud(ui, tl!("App details"), t.faint);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked() {
self.detail = None;
}
});
});
ui.add_space(10.0);
ui.horizontal(|ui| {
if let Some(icon) = crate::icons::app(app.id) {
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(ICON, ICON)));
}
ui.add_space(8.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
widgets::wordmark(ui, app, 32.0);
ui.add(egui::Label::new(RichText::new(details.headline).size(15.0).color(t.ink)).wrap());
// "Version 0.5.0 · Oct 8, 2026", or the installed-versus-latest line.
let version = crate::views::version_line(entry, &status);
if !version.trim().is_empty() {
ui.label(RichText::new(version).size(12.5).color(t.muted));
}
});
});
ui.add_space(14.0);
// The menu leaves out the links: they are the buttons right below.
let row_w = ui.available_width();
ui.horizontal(|ui| self.actions_in(ui, entry, &status, row_w, false));
link_buttons(ui, app, row_w);
});
let r = hero.response.rect;
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 2.0)), CornerRadius::ZERO, app_color);
ui.painter().hline(r.x_range(), r.bottom() - 0.5, t.hairline());
}
/// The facts of the latest release in cells, then its notes.
fn release_section(&self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens) {
widgets::eyebrow(ui, "02", tl!("Latest release"));
ui.add_space(4.0);
let Some(r) = entry.release() else {
ui.label(RichText::new(tl!("No release published yet")).color(t.muted));
return;
};
let size = self.platform.and_then(|p| {
let stems: Vec<&str> = entry.app.stems().collect();
pick_asset(r, &stems, p, p.default_kinds()).map(|(a, _)| widgets::human_bytes(a.size))
});
let facts: Vec<(&str, String)> = [
(tl!("Version"), Some(r.version.as_ref().map_or_else(|| r.tag.clone(), |v| v.to_string()))),
(tl!("Released"), r.published_at.map(date_text)),
(tl!("Download"), size),
(tl!("Platforms"), Some(details.platforms.to_string())),
]
.into_iter()
.filter_map(|(label, value)| value.map(|v| (label, v)))
.collect();
for pair in facts.chunks(2) {
ui.columns(2, |cols| {
for (col, (label, value)) in cols.iter_mut().zip(pair) {
egui::Frame::new().stroke(t.hairline()).inner_margin(Margin::same(12)).show(col, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 2.0;
widgets::hud(ui, label, t.faint);
ui.add(egui::Label::new(RichText::new(value).size(14.0).color(t.ink_strong)).wrap());
});
}
});
}
ui.add_space(8.0);
crate::notes::show(ui, &r.notes);
}
/// The current screenshot, large, over the whole window; ‹ › flip through them, a click or Esc closes it.
fn lightbox(&mut self, ctx: &egui::Context, details: &AppDetails, t: &Tokens) {
let count = details.shots.len();
let at = self.detail_shot.min(count.saturating_sub(1));
let Some(shot) = details.shots.get(at) else {
self.detail_zoom = false;
return;
};
let src = self.picture_source(details.id, shot.key);
let failed = self.failure(details.id, shot.key);
let full = ctx.content_rect();
egui::Area::new(Id::new("details-lightbox")).order(Order::Tooltip).fixed_pos(full.min).show(ctx, |ui| {
let (rect, backdrop) = ui.allocate_exact_size(full.size(), Sense::click());
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::backdrop());
// As large as the window allows, but no larger than 1400 points: the files are 1200 px wide.
let room = rect.shrink2(vec2(112.0, 96.0));
let w = room.width().min(room.height() / SHOT_ASPECT).clamp(1.0, 1400.0);
let pic = Rect::from_center_size(rect.center(), vec2(w, w * SHOT_ASPECT));
match src {
Some(src) => egui::Image::new(src).paint_at(ui, pic),
// Flipped to a picture that hasn't arrived: the same frame, saying so.
None => placeholder(ui, pic, Id::new("lightbox-placeholder"), failed.as_deref(), t, true),
}
ui.painter().rect_stroke(pic, CornerRadius::ZERO, Stroke::new(1.0, theme::backdrop_ink(40)), egui::StrokeKind::Outside);
let caption = if count > 1 { format!("{} · {}/{count}", shot.caption, at + 1) } else { shot.caption.to_string() };
let ink = theme::backdrop_ink(190);
let galley = widgets::hud_galley(ui, &caption, ink);
let caption_at = pos2(rect.center().x - galley.size().x / 2.0, pic.bottom() + 14.0);
ui.painter().galley(caption_at, galley, ink);
if count > 1 {
let y = pic.center().y - ARROW / 2.0;
let prev = Rect::from_min_size(pos2(pic.left() - 16.0 - ARROW, y), vec2(ARROW, ARROW));
let next = Rect::from_min_size(pos2(pic.right() + 16.0, y), vec2(ARROW, ARROW));
if arrow_button(ui, prev, Id::new("zoom-prev"), true, tl!("Previous picture (←)"), t) {
self.flip_shot(count, false);
}
if arrow_button(ui, next, Id::new("zoom-next"), false, tl!("Next picture (→)"), t) {
self.flip_shot(count, true);
}
}
let close =
Rect::from_min_size(pos2(rect.right() - 16.0 - theme::CONTROL_H, rect.top() + 16.0), vec2(theme::CONTROL_H, theme::CONTROL_H));
let mut done = backdrop.clicked();
ui.scope_builder(egui::UiBuilder::new().max_rect(close), |ui| {
done |= widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked();
});
if done {
self.detail_zoom = false;
}
});
}
/// The lead screenshot, its caption, and a row of thumbnails to switch between them. A picture
/// that hasn't arrived is a frame of the same size saying so, so nothing moves when it does.
fn gallery(&mut self, ui: &mut Ui, details: &AppDetails, t: &Tokens, app_color: Color32) {
let Some(&current) = details.shots.get(self.detail_shot).or(details.shots.first()) else { return };
let w = ui.available_width();
let pic = match self.picture_source(details.id, current.key) {
Some(lead) => {
let image = ui.add(egui::Image::new(lead).fit_to_exact_size(vec2(w, w * SHOT_ASPECT)).sense(Sense::click()));
image.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Enlarge picture")));
let rect = image.rect;
// The arrows below are added after the picture, so they keep their own clicks.
if image.on_hover_cursor(egui::CursorIcon::ZoomIn).on_hover_text(tl!("Click to enlarge")).clicked() {
self.detail_zoom = true;
}
rect
}
None => {
let (rect, _) = ui.allocate_exact_size(vec2(w, w * SHOT_ASPECT), Sense::hover());
let failed = self.failure(details.id, current.key);
placeholder(ui, rect, Id::new("lead-placeholder"), failed.as_deref(), t, false);
rect
}
};
ui.painter().rect_stroke(pic, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
let count = details.shots.len();
if count < 2 {
widgets::hud(ui, current.caption, t.faint);
return;
}
// Previous and next, over the sides of the picture.
let y = pic.center().y - ARROW / 2.0;
let prev = Rect::from_min_size(pos2(pic.left() + 10.0, y), vec2(ARROW, ARROW));
let next = Rect::from_min_size(pos2(pic.right() - 10.0 - ARROW, y), vec2(ARROW, ARROW));
if arrow_button(ui, prev, Id::new("shot-prev"), true, tl!("Previous picture (←)"), t) {
self.flip_shot(count, false);
}
if arrow_button(ui, next, Id::new("shot-next"), false, tl!("Next picture (→)"), t) {
self.flip_shot(count, true);
}
widgets::hud(ui, &format!("{} · {}/{count}", current.caption, self.detail_shot.min(count - 1) + 1), t.faint);
let gap = 8.0;
let thumb_w = ((w - gap * (count as f32 - 1.0)) / count as f32).floor().max(1.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = gap;
for (i, shot) in details.shots.iter().enumerate() {
let selected = i == self.detail_shot;
let size = vec2(thumb_w, thumb_w * SHOT_ASPECT);
let resp = match self.picture_source(details.id, shot.key) {
Some(src) => ui.add(egui::Image::new(src).fit_to_exact_size(size).sense(Sense::click())),
// Too small for words: a blank frame, still there to pick.
None => {
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
ui.painter().rect_filled(rect, CornerRadius::ZERO, t.sunken);
resp
}
};
// The other pictures sit under a veil of the page's own background (a little lighter on hover):
// they fade toward the page, not toward grey, in both themes.
if !selected {
let veil = if resp.hovered() { 0.25 } else { 0.55 };
ui.painter().rect_filled(resp.rect, CornerRadius::ZERO, t.bg.gamma_multiply(veil));
}
let stroke = if selected { Stroke::new(2.0, app_color) } else { t.hairline() };
ui.painter().rect_stroke(resp.rect, CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(shot.caption).clicked() {
self.detail_shot = i;
}
}
});
}
}
/// What stands in for a picture that isn't here: a frame of the picture's size saying why. `failed`
/// is the reason the picture couldn't be had; without one it is still on its way.
fn placeholder(ui: &mut Ui, rect: Rect, id: Id, failed: Option<&str>, t: &Tokens, over_backdrop: bool) {
let text = if failed.is_some() { tl!("Pictures couldn't be loaded. Reopen this page to try again.") } else { tl!("Loading pictures…") };
ui.painter().rect_filled(rect, CornerRadius::ZERO, if over_backdrop { theme::backdrop_panel() } else { t.sunken });
let ink = if over_backdrop { theme::backdrop_ink(190) } else { t.faint };
let galley = ui.painter().layout(text.to_string(), egui::FontId::proportional(13.5), ink, (rect.width() - 32.0).max(1.0));
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, ink);
let resp = ui.interact(rect, id, Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
if let Some(why) = failed {
resp.on_hover_text(why);
}
}
/// Between the header and the footer, which stay usable. In a window shorter than the two bars
/// together there is no room left: an empty area, never a negative one.
fn drawer_area(ctx: &egui::Context) -> Rect {
let full = ctx.content_rect();
let top = (full.top() + HEADER_H).min(full.bottom());
let bottom = (full.bottom() - FOOTER_H).max(top);
Rect::from_min_max(pos2(full.left(), top), pos2(full.right(), bottom))
}
/// The app's links as a row of buttons sharing `row_w`; the ↗ says each opens the browser, like the links on the cards.
fn link_buttons(ui: &mut Ui, app: &CraftApp, row_w: f32) {
let links = [(tl!("Website"), app.website()), (tl!("GitHub"), app.repo_url()), (tl!("Releases"), app.releases_url())];
let gap = ui.spacing().item_spacing.x;
let link_w = ((row_w - gap * (links.len() as f32 - 1.0)) / links.len() as f32).floor();
ui.horizontal(|ui| {
for (label, url) in links {
if widgets::button(ui, &format!("{label} ↗"), Kind::Secondary, link_w).on_hover_text(url.as_str()).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
});
}
/// The numbered feature cells.
fn features(ui: &mut Ui, details: &AppDetails, app: &CraftApp, t: &Tokens) {
widgets::eyebrow(ui, "01", tl!("Features"));
ui.add_space(4.0);
for (i, f) in details.features.iter().enumerate() {
egui::Frame::new().stroke(t.hairline()).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 3.0;
ui.horizontal(|ui| {
widgets::hud(ui, &format!("{:02}", i + 1), t.app_ink(app));
ui.label(RichText::new(f.title).size(15.0).family(theme::semibold()).color(t.ink_strong));
});
ui.add(egui::Label::new(RichText::new(f.text).size(13.5).color(t.muted)).wrap());
});
}
}
/// A square button with a chevron, drawn over a picture: opaque enough to read on any image,
/// inverted on hover like the site's outlined buttons. True when clicked.
fn arrow_button(ui: &mut Ui, rect: Rect, id: Id, left: bool, tip: &str, t: &Tokens) -> bool {
let resp = ui.interact(rect, id, Sense::click());
let (fill, ink) = if resp.hovered() { (t.invert_bg, t.invert_fg) } else { (t.bg.gamma_multiply(0.92), t.ink) };
let painter = ui.painter();
painter.rect(rect, CornerRadius::ZERO, fill, Stroke::new(1.0, t.line_strong), egui::StrokeKind::Inside);
let (c, s) = (rect.center(), if left { -1.0 } else { 1.0 });
let points = vec![pos2(c.x - s * 3.5, c.y - 6.0), pos2(c.x + s * 3.5, c.y), pos2(c.x - s * 3.5, c.y + 6.0)];
painter.add(egui::Shape::line(points, Stroke::new(1.75, ink)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked()
}

View File

@ -1,12 +1,11 @@
//! Modal dialogs: release notes, uninstall confirmation, settings.
use crate::i18n::fmt;
use artcraft_launcher_core::protocol::Command;
use artcraft_launcher_core::{Os, PackageKind};
use artcraft_launcher_core::{Os, PackageKind, time};
use egui::{RichText, vec2};
use crate::app::{Dialog, Host, LauncherUi};
use crate::settings::{CheckEvery, Settings};
use crate::settings::CheckEvery;
use crate::theme::{self, ThemeChoice, Tokens};
use crate::widgets::{self, Cell, Kind};
@ -17,16 +16,13 @@ impl LauncherUi {
return;
};
let t = Tokens::get(ctx);
// An area is laid out in the size it had last frame, so dialogs can't share one id: the
// uninstall prompt would open as tall as Settings and never shrink (#88).
let modal = egui::Modal::new(egui::Id::new("dialog").with(dialog.key()))
let modal = egui::Modal::new(egui::Id::new("dialog"))
.frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
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)),
Dialog::LauncherUpdate(r) => modal.show(ctx, |ui| close = self.launcher_update_dialog(ui, r)),
};
if close || response.should_close() {
self.dialog = None;
@ -37,7 +33,7 @@ impl LauncherUi {
fn alert_popup(&mut self, ctx: &egui::Context) {
let Some(alert) = self.alerts.iter().find(|a| !self.settings.dismissed_notices.contains(&a.id)).cloned() else { return };
let t = Tokens::get(ctx);
let modal = egui::Modal::new(egui::Id::new("feed_alert").with(&alert.id))
let modal = egui::Modal::new(egui::Id::new("feed_alert"))
.frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
let mut ok = false;
modal.show(ctx, |ui| {
@ -48,16 +44,13 @@ impl LauncherUi {
ui.add(egui::Label::new(RichText::new(&alert.body).color(t.muted)).wrap());
});
ui.add_space(14.0);
// A row: a right-to-left layout straight in the column would fill the height left.
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ok = widgets::button(ui, tl!("OK"), Kind::Primary, 90.0).clicked();
if let Some(link) = &alert.link
&& widgets::button(ui, tl!("Open link"), Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked()
{
ui.ctx().open_url(egui::OpenUrl::new_tab(link.clone()));
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ok = widgets::button(ui, "OK", Kind::Primary, 90.0).clicked();
if let Some(link) = &alert.link
&& widgets::button(ui, "Open link", Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked()
{
ui.ctx().open_url(egui::OpenUrl::new_tab(link.clone()));
}
});
});
// Only OK acknowledges it: Escape or a click outside leaves it for later.
@ -88,11 +81,11 @@ impl LauncherUi {
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| fmt(tl!("Released {date}"), &[("date", &crate::views::date_text(d))])).unwrap_or_default();
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(tl!("These notes weren't fetched (GitHub's request limit was reached). They're on the release page."));
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);
}
@ -100,43 +93,9 @@ impl LauncherUi {
ui.add_space(12.0);
let mut close = false;
ui.horizontal(|ui| {
widgets::hud_link(ui, tl!("Open on GitHub"), &release.html_url);
widgets::hud_link(ui, "Open on GitHub", &release.html_url);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Close"), Kind::Secondary, 90.0).clicked();
});
});
close
}
fn launcher_update_dialog(&mut self, ui: &mut egui::Ui, r: &artcraft_launcher_core::Release) -> bool {
let t = Tokens::get(ui.ctx());
ui.set_width(480.0f32.min(ui.ctx().content_rect().width() - 80.0));
let title = fmt(tl!("ArtCraft Launcher {version} is available."), &[("version", &r.version_label())]);
Self::title(ui, Some(crate::icons::artcraft_mark()), &title, "");
ui.label(RichText::new(tl!("A new version is available. Would you like to relaunch with the updated version?")).color(t.muted));
ui.add_space(14.0);
let mut close = false;
ui.horizontal(|ui| {
widgets::hud_link(ui, tl!("What's new"), &r.html_url);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if self.caps.self_update {
if widgets::button(ui, tl!("Update and restart"), Kind::Primary, 0.0)
.on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))
.clicked()
{
self.send(Command::UpdateLauncher);
close = true;
}
} else {
let url = self.platform.and_then(|p| crate::views::launcher_download(r, p)).unwrap_or_else(|| r.html_url.clone());
if widgets::button(ui, tl!("Download"), Kind::Primary, 0.0).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
close = true;
}
}
if widgets::button(ui, tl!("Later"), Kind::Secondary, 80.0).clicked() {
close = true;
}
close = widgets::button(ui, "Close", Kind::Secondary, 90.0).clicked();
});
});
close
@ -146,126 +105,54 @@ impl LauncherUi {
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), &fmt(tl!("Uninstall {name}?"), &[("name", entry.app.name)]), "");
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) => tl!("The app will be moved to the Trash. Your documents and settings stay."),
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) => {
tl!("Windows will remove it like any installed program. Your documents and settings stay.")
"Windows will remove it like any installed program. Your documents and settings stay."
}
Some(
artcraft_launcher_core::InstallSource::Deb
| artcraft_launcher_core::InstallSource::Rpm
| artcraft_launcher_core::InstallSource::Pacman,
) => tl!("Your system's package manager will remove it, and may ask for your password. Your documents and settings stay."),
_ => tl!("The app will be removed. Your documents and settings stay."),
) => "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.muted));
ui.add_space(20.0);
let mut close = false;
// A row: a right-to-left layout straight in the column would fill the height left.
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, tl!("Uninstall"), Kind::Danger, 110.0).clicked() {
self.send(Command::Uninstall { app: id.to_string() });
close = true;
}
if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() {
close = true;
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, "Uninstall", Kind::Danger, 110.0).clicked() {
self.send(Command::Uninstall { app: id.to_string() });
close = true;
}
if widgets::button(ui, "Cancel", Kind::Secondary, 90.0).clicked() {
close = true;
}
});
close
}
fn settings_dialog(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) -> bool {
// Leave room for the frame (48 px) and a 16 px gutter on each side.
let viewport = ui.ctx().content_rect();
ui.set_width(520.0_f32.min((viewport.width() - 80.0).max(0.0)));
// The area remembers its previous size. Reset the available height so the scroll area
// measures against the viewport immediately, including on the first frame after a resize.
ui.set_max_height((viewport.height() - 80.0).max(0.0));
ui.spacing_mut().scroll = egui::style::ScrollStyle { foreground_color: true, ..egui::style::ScrollStyle::solid() };
let title_top = ui.cursor().top();
Self::title(ui, Some(crate::icons::artcraft_mark()), tl!("Settings"), &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
let title_height = ui.cursor().top() - title_top;
let quit_width = widgets::button_width(ui, tl!("Quit ArtCraft Launcher"), 0.0);
let done_width = widgets::button_width(ui, tl!("Done"), 90.0);
let stack_actions = self.caps.manage_apps && quit_width + done_width + ui.spacing().item_spacing.x > ui.available_width();
let footer_height = if stack_actions { 2.0 * theme::CONTROL_H + ui.spacing().item_spacing.y } else { theme::CONTROL_H };
let body_height = (viewport.height() - 80.0 - title_height - footer_height - 24.0 - ui.spacing().item_spacing.y).max(0.0);
egui::ScrollArea::vertical().id_salt("settings-body").max_height(body_height).auto_shrink([false, true]).show(ui, |ui| {
self.settings_contents(ui, host);
});
ui.add_space(24.0);
let mut close = false;
let quit = |ui: &mut egui::Ui, host: &mut dyn Host| {
if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() {
host.quit();
}
};
if stack_actions {
quit(ui, host);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
} else {
ui.horizontal(|ui| {
quit(ui, host);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
});
}
close
}
fn settings_contents(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) {
let t = Tokens::get(ui.ctx());
// Wide enough for the community buttons and every social icon on one row.
ui.set_width(520.0);
Self::title(ui, Some(crate::icons::artcraft_mark()), "Settings", &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
let section = |ui: &mut egui::Ui, s: &str| {
ui.add_space(14.0);
widgets::hud(ui, s, t.faint);
ui.add_space(6.0);
};
let row = |ui: &mut egui::Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut egui::Ui)| {
let font = egui::TextStyle::Body.resolve(ui.style());
let label_width = ui.painter().layout_no_wrap(label.to_string(), font, t.ink).size().x;
if label_width + control_width + ui.spacing().item_spacing.x > ui.available_width() {
let row = |ui: &mut egui::Ui, label: &str, add: &mut dyn FnMut(&mut egui::Ui)| {
ui.horizontal(|ui| {
ui.label(label);
add(ui);
} else {
ui.horizontal(|ui| {
ui.label(label);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
});
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
});
};
let mut lang_code = self.settings.language.clone();
let installed = self.cjk_font;
row(ui, tl!("Language"), 180.0, &mut |ui| {
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code, installed)).width(180.0).show_ui(ui, |ui| {
ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto"));
for lang in crate::i18n::Lang::all() {
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code(), installed));
}
});
});
if lang_code != self.settings.language {
self.settings.language = lang_code;
crate::i18n::set_current(crate::app::effective_lang(&self.settings.language));
}
let pref = crate::i18n::Lang::from_pref(&self.settings.language);
if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {
// In English on purpose: the language asked for can't be drawn.
let note = "No font for this language was found on this system, so the interface stays in English. \
Installing one (for example Noto Sans CJK) and restarting fixes it.";
ui.add(egui::Label::new(RichText::new(note).size(12.0).color(t.warn)).wrap());
}
section(ui, tl!("Updates"));
section(ui, "Updates");
let mut every = self.settings.check_every;
row(ui, tl!("Check for updates"), 150.0, &mut |ui| {
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());
@ -273,97 +160,80 @@ impl LauncherUi {
});
});
self.settings.check_every = every;
if self.caps.manage_apps
&& ui.checkbox(&mut self.settings.auto_update, tl!("Automatically download and install updates")).changed()
&& self.settings.auto_update
{
self.trigger_auto_updates();
}
ui.checkbox(&mut self.settings.notify, tl!("Notify me when an installed app has an update"));
ui.checkbox(&mut self.settings.prereleases, tl!("Include pre-releases (betas and release candidates)"));
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, tl!("Installing on Linux"));
let is_flatpak = crate::settings::is_flatpak_sandbox();
let is_appimage = crate::settings::is_appimage();
let default_kind = if is_flatpak {
Some(PackageKind::Flatpak)
} else if is_appimage {
Some(PackageKind::AppImage)
} else {
None
};
let mut choice = self.settings.preferred_kinds.first().copied().or(default_kind);
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
let default_label = match default_kind {
Some(k) => crate::i18n::t(k.label()),
None => tl!("System package (recommended)"),
};
let label = choice.map_or(default_label, |k| crate::i18n::t(k.label()));
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| {
if !is_flatpak && !is_appimage {
ui.selectable_value(&mut choice, None, tl!("System package (recommended)"));
}
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), crate::i18n::t(k.label()));
}
// The tarball is unpacked and run inside the launcher's own sandbox, so it
// is only ever on offer there (as the fallback when the host has no Flatpak).
if is_flatpak {
ui.selectable_value(&mut choice, Some(PackageKind::TarGz), crate::i18n::t(PackageKind::TarGz.label()));
ui.selectable_value(&mut choice, Some(k), k.label());
}
});
});
self.settings.preferred_kinds = choice.into_iter().collect();
ui.label(RichText::new(tl!("Updates always use the format an app was installed with.")).size(12.0).color(t.muted));
ui.label(RichText::new("Updates always use the format an app was installed with.").size(12.0).color(t.muted));
}
if self.caps.manage_apps {
section(ui, tl!("Running in the background"));
section(ui, "Running in the background");
if host.has_tray() {
ui.checkbox(&mut self.settings.close_to_tray, tl!("Keep running in the tray when the window is closed"));
ui.checkbox(&mut self.settings.close_to_tray, "Keep running in the tray when the window is closed");
}
if host.has_desktops() {
ui.checkbox(&mut self.settings.show_on_current_desktop, tl!("Show the window on the current desktop"))
.on_hover_text(tl!("When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on."));
ui.checkbox(&mut self.settings.show_on_current_desktop, "Show the window on the current desktop")
.on_hover_text("When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on.");
}
if let Some(mut on) = host.login_item()
&& ui.checkbox(&mut on, tl!("Start ArtCraft Launcher when I log in")).changed()
&& 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, fmt(tl!("Couldn't change the login item: {e}"), &[("e", &e)]), true);
self.toast(&ctx, format!("Couldn't change the login item: {e}"), true);
}
}
section(ui, tl!("Community"));
ui.label(RichText::new(tl!("Questions, ideas and news from the ArtCraft team")).color(t.muted));
section(ui, "Community");
ui.label(RichText::new("Questions, ideas and news from the ArtCraft team").color(t.muted));
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
ui.horizontal(|ui| {
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
if widgets::button(ui, tl!("Join the Discord"), Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() {
if widgets::button(ui, "Join the Discord", Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(DISCORD_URL));
}
if widgets::button(ui, tl!("Website"), Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() {
if widgets::button(ui, "Website", Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(WEBSITE_URL));
}
if widgets::button(ui, tl!("GitHub"), Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
if widgets::button(ui, "GitHub", Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
}
// Wrap the social links as a group so their icons stay together.
let count = artcraft_launcher_core::catalog::SOCIAL.len() as f32;
let width = count * (16.0 + 8.0) + (count - 1.0).max(0.0) * 4.0;
ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
widgets::social_icons(ui, 16.0);
});
});
section(ui, tl!("Appearance"));
section(ui, "Appearance");
let mut theme_choice = self.settings.theme;
ui.horizontal(|ui| {
widgets::cells(ui, &mut theme_choice, &ThemeChoice::ALL.map(|c| (c, Cell { label: c.label(), tip: c.label(), count: None })));
});
self.settings.theme = theme_choice;
ui.add_space(24.0);
let mut close = false;
ui.horizontal(|ui| {
if self.caps.manage_apps && widgets::button(ui, "Quit ArtCraft Launcher", Kind::Secondary, 0.0).clicked() {
host.quit();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, "Done", Kind::Primary, 90.0).clicked();
});
});
close
}
}

View File

@ -1,201 +0,0 @@
//! TSV catalog parsing for UI localisation.
//!
//! Format (one entry per line):
//! context<TAB>source<TAB>translation
//! - Empty context = plain UI string (`tl!`, `t`).
//! - `@id` context = keyed by command id (`tr_id`).
//! - `@plural` context = plural forms: the English `one|other` key, and the language's forms
//! separated by `|` (one form for a language whose nouns don't inflect for number, up to
//! three: `one|few|many` for Russian, Ukrainian and Polish).
//! - `@msg` context = a status/error message, or a template whose `{placeholders}` match any text
//! (`message`, `msg`).
//! - Any other context = disambiguation for `tr_ctx`.
//!
//! Escapes in source/translation: `\t` `\n` `\\` (parsed once at load time).
//! Placeholders `{name}` must appear in both source and translation.
//! A trailing `…` (U+2026) in source must be preserved in translation.
use std::collections::HashMap;
/// A parsed translation catalog for one language.
#[derive(Debug, Default)]
pub struct Catalog {
/// Plain strings: English -> translation.
plain: HashMap<String, String>,
/// Contextual strings: context -> (English -> translation).
contextual: HashMap<String, HashMap<String, String>>,
/// Command-id keyed strings: id -> translation.
by_id: HashMap<String, String>,
/// Plural forms: English "one|other" -> the language's forms.
plural: HashMap<String, Vec<String>>,
/// Messages: English message or template -> translation.
messages: HashMap<String, String>,
/// The `@msg` rows with placeholders, matched against messages that have no exact row.
templates: Vec<Template>,
}
/// An `@msg` template, split into its literal text and placeholders.
#[derive(Debug)]
struct Template {
/// Literal text around the placeholders: always one more than `names`.
literals: Vec<String>,
names: Vec<String>,
translation: String,
}
impl Template {
fn new(source: &str, translation: String) -> Option<Self> {
let mut literals = Vec::new();
let mut names = Vec::new();
let mut rest = source;
while let Some(open) = rest.find('{') {
let close = open + rest.get(open..)?.find('}')?;
literals.push(rest.get(..open)?.to_string());
names.push(rest.get(open + 1..close)?.to_string());
rest = rest.get(close + 1..)?;
}
literals.push(rest.to_string());
// Two placeholders in a row can't be told apart.
if names.is_empty() || literals.iter().skip(1).rev().skip(1).any(String::is_empty) {
return None;
}
Some(Self { literals, names, translation })
}
/// The length of the template's literal text: the more of a message a template spells out,
/// the better it fits.
fn specificity(&self) -> usize {
self.literals.iter().map(String::len).sum()
}
/// The translation with this message's placeholder values filled in, if `s` fits the template.
/// Each placeholder matches at least one character; between two literals it takes the
/// shortest text that fits, the last one takes everything up to the final literal.
fn apply(&self, s: &str) -> Option<String> {
let (first, between) = self.literals.split_first()?;
let mut rest = s.strip_prefix(first.as_str())?;
let mut values = Vec::with_capacity(self.names.len());
for (i, literal) in between.iter().enumerate() {
let value = if i + 1 == between.len() {
let value = rest.strip_suffix(literal.as_str())?;
rest = "";
value
} else {
// Skip one character, so the value isn't empty.
let skip = rest.chars().next()?.len_utf8();
let at = skip + rest.get(skip..)?.find(literal.as_str())?;
let value = rest.get(..at)?;
rest = rest.get(at + literal.len()..)?;
value
};
if value.is_empty() {
return None;
}
values.push(value);
}
let args: Vec<(&str, &str)> = self.names.iter().map(String::as_str).zip(values).collect();
Some(super::translate::fmt(&self.translation, &args))
}
}
impl Catalog {
/// Parse a catalog from TSV content. Malformed rows are logged and skipped.
pub fn parse(source: &str) -> Self {
let mut cat = Catalog::default();
for (line_no, line) in source.lines().enumerate() {
let line_no = line_no + 1;
// Only the end: a plain row starts with its (empty) context and a TAB.
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
let [context, source_text, translation] = parts.as_slice() else {
log::warn!("i18n catalog: line {line_no}: expected 3 tab-separated fields, got {}", parts.len());
continue;
};
let (context, source_text, translation) = (unescape(context), unescape(source_text), unescape(translation));
let duplicate = match context.as_str() {
"" => cat.plain.insert(source_text.clone(), translation).is_some(),
"@id" => cat.by_id.insert(source_text.clone(), translation).is_some(),
"@plural" => {
let forms: Vec<String> = translation.split('|').map(str::to_string).collect();
if forms.len() > 3 {
log::warn!("i18n catalog: line {line_no}: @plural expects 1 to 3 forms separated by '|', got {}", forms.len());
continue;
}
cat.plural.insert(source_text.clone(), forms).is_some()
}
"@msg" => {
if source_text.contains('{') {
match Template::new(&source_text, translation.clone()) {
Some(t) => cat.templates.push(t),
None => log::warn!("i18n catalog: line {line_no}: @msg template can't be matched: {source_text:?}"),
}
}
cat.messages.insert(source_text.clone(), translation).is_some()
}
_ => cat.contextual.entry(context.clone()).or_default().insert(source_text.clone(), translation).is_some(),
};
if duplicate {
log::warn!("i18n catalog: line {line_no}: duplicate key {context:?} {source_text:?}");
}
}
// The most specific template first.
cat.templates.sort_by_key(|t| std::cmp::Reverse(t.specificity()));
cat
}
/// Look up a plain string.
pub fn plain(&self, s: &str) -> Option<&str> {
self.plain.get(s).map(String::as_str)
}
/// Look up a contextual string.
pub fn contextual(&self, context: &str, s: &str) -> Option<&str> {
self.contextual.get(context)?.get(s).map(String::as_str)
}
/// Look up a command-id keyed string.
pub fn id(&self, id: &str) -> Option<&str> {
self.by_id.get(id).map(String::as_str)
}
/// Look up plural forms for an English "one|other" key; the form at `index` (0 = one).
/// A row with fewer forms than the language's rule uses its last form for the rest.
pub fn plural(&self, key: &str, index: usize) -> Option<&str> {
let forms = self.plural.get(key)?;
forms.get(index).or_else(|| forms.last()).map(String::as_str)
}
/// Translate a message: an exact `@msg` row, else the most specific template it fits.
pub fn message(&self, s: &str) -> Option<String> {
if let Some(t) = self.messages.get(s) {
return Some(t.clone());
}
self.templates.iter().find_map(|t| t.apply(s))
}
}
/// Unescape `\t`, `\n`, `\\` in a TSV field.
pub(super) fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('t') => out.push('\t'),
Some('n') => out.push('\n'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}

View File

@ -1,314 +0,0 @@
# Czech (cs) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t). @id = command-id keyed. @plural = one|few|other.
# @msg = status/error message template. Other context = disambiguation for tr_ctx.
# AI-assisted Czech translation, translated from the meaning of the English source strings.
# Product and app names, shortcuts, units, file formats, placeholders and trailing ellipses are kept.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Open-source nástroje pro kreativce, tvůrce a vývojáře
Settings Nastavení
Check for updates Zkontrolovat aktualizace
Grid Mřížka
Table Tabulka
Show apps as cards Zobrazit aplikace jako karty
Show apps as a table Zobrazit aplikace v tabulce
All Vše
Updates Aktualizace
Not installed Nenainstalované
Search apps Hledat aplikace
Sort Seřadit
Suite order Pořadí v sadě
Name Název
Inspired by Inspirace
Status Stav
Installed version Nainstalovaná verze
Latest version Nejnovější verze
Newest release Nejnovější vydání
Discord Discord
GitHub GitHub
Website Web
Classic apps Klasické aplikace
Classic Klasické
New apps Nové aplikace
NEW NOVÉ
Install Nainstalovat
Update to {v} Aktualizovat na {v}
Open Otevřít
Download for {os} Stáhnout pro {os}
Download Stáhnout
Coming soon Již brzy
Checking… Kontrola…
Not available Není k dispozici
Offline Bez připojení
Downloading {done} of {total} Stahování {done} z {total}
Downloading… Stahování…
Reconnecting… Obnovování připojení…
Verifying… Ověřování…
Installing, then restarting… Instalace a restart…
Installing… Instalace…
Removing… Odebírání…
Pause download Pozastavit stahování
Cancel download Zrušit stahování
Resume download Pokračovat ve stahování
Discard download Zahodit stahování
Release notes Poznámky k vydání
Show in Finder Zobrazit ve Finderu
Show in Explorer Zobrazit v Průzkumníku
Show in Files Zobrazit v Souborech
Reinstall Znovu nainstalovat
Uninstall… Odinstalovat…
All releases Všechna vydání
ArtCraft Launcher {version} is available. Je k dispozici ArtCraft Launcher {version}.
Dismiss Skrýt
Update and restart Aktualizovat a restartovat
A new version is available. Would you like to relaunch with the updated version? Je k dispozici nová verze. Chcete aplikaci znovu spustit s aktualizovanou verzí?
Later Později
What's new Co je nového
No notes were published with this release. K tomuto vydání nebyly zveřejněny žádné poznámky.
Released {date} Vydáno {date}
Open on GitHub Otevřít na GitHubu
Close Zavřít
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Pouze na vyžádání
Automatically download and install updates Automaticky stahovat a instalovat aktualizace
Notify me when an installed app has an update Upozornit, když má nainstalovaná aplikace aktualizaci
Include pre-releases (betas and release candidates) Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
Installing on Linux Instalace v Linuxu
Install new apps as Instalovat nové aplikace jako
System package (recommended) Systémový balíček (doporučeno)
Updates always use the format an app was installed with. Aktualizace vždy použijí formát, ve kterém byla aplikace nainstalována.
Running in the background Běh na pozadí
Keep running in the tray when the window is closed Po zavření okna ponechat spuštěné v oznamovací oblasti
Show the window on the current desktop Zobrazit okno na aktuální ploše
Start ArtCraft Launcher when I log in Spouštět ArtCraft Launcher po přihlášení
Community Komunita
Questions, ideas and news from the ArtCraft team Dotazy, nápady a novinky od týmu ArtCraft
Appearance Vzhled
Quit ArtCraft Launcher Ukončit ArtCraft Launcher
Done Hotovo
Uninstall {name}? Odinstalovat {name}?
The app will be moved to the Trash. Your documents and settings stay. Aplikace bude přesunuta do Koše. Vaše dokumenty a nastavení zůstanou zachovány.
Windows will remove it like any installed program. Your documents and settings stay. Windows ji odebere jako každý nainstalovaný program. Vaše dokumenty a nastavení zůstanou zachovány.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Odebere ji správce balíčků systému a může požádat o heslo. Vaše dokumenty a nastavení zůstanou zachovány.
The app will be removed. Your documents and settings stay. Aplikace bude odebrána. Vaše dokumenty a nastavení zůstanou zachovány.
Uninstall Odinstalovat
Cancel Zrušit
Checked {ago} Zkontrolováno {ago}
just now právě teď
yesterday včera
{month} {day}, {year} {day}. {month} {year}
Automatically Automaticky
Every Craft app Všechny aplikace Craft
Apps on this computer Aplikace v tomto počítači
Installed apps with a newer version Nainstalované aplikace s novější verzí
Apps you can install Aplikace, které lze nainstalovat
Hide App Skrýt aplikaci
Unhide App Zobrazit aplikaci
Hidden Apps Skryté aplikace
Apps you hid Aplikace, které jste skryli
Checked {ago} · using {sources} Zkontrolováno {ago} · zdroje: {sources}
{a} and {b} {a} a {b}
Join the Discord Připojit se na Discord
Learn more Další informace
Minimize Minimalizovat
Maximize Maximalizovat
Restore Obnovit
New Nové
OK OK
Open link Otevřít odkaz
Update Aktualizovat
{name} icon Ikona aplikace {name}
Loading ArtCraft Launcher… Načítání aplikace ArtCraft Launcher…
Cancel update Zrušit aktualizaci
Stop waiting; the update can be started again later Přestat čekat; aktualizaci lze spustit znovu později
Download again Stáhnout znovu
Install cancelled Instalace zrušena
Install cancelled. The download is kept for the next try. Instalace zrušena. Stažený soubor zůstane připravený pro další pokus.
Update cancelled Aktualizace zrušena
Uninstall cancelled Odinstalace zrušena
Retry install Zkusit instalaci znovu
Retry update to {version} Zkusit aktualizaci na {version} znovu
The download is still here. It's checked again, then installed. Stažený soubor je stále k dispozici. Znovu se ověří a poté nainstaluje.
Waiting for another app… Čekání na jinou aplikaci…
Waiting for other installs… Čekání na dokončení ostatních instalací…
Installer (.msi) Instalační program (.msi)
Portable (.zip) Přenosná verze (.zip)
Disk image (.dmg) Obraz disku (.dmg)
Debian package (.deb) Balíček Debian (.deb)
RPM package (.rpm) Balíček RPM (.rpm)
Flatpak bundle Balíček Flatpak
Tarball (.tar.gz) Archiv tar (.tar.gz)
photocraft Image editing Úpravy obrázků
vectorcraft Vector illustration Vektorová ilustrace
filmcraft Video editing, color and sound Střih videa, barvy a zvuk
lightcraft Photo library and raw development Katalog fotografií a vyvolávání RAW
pdfcraft Reading, organizing and protecting PDFs Čtení, organizace a ochrana souborů PDF
effectcraft Motion graphics and visual effects Pohyblivá grafika a vizuální efekty
designcraft Page layout and publishing Sazba stránek a publikování
wordcraft Writing and document design Psaní a návrh dokumentů
deckcraft Presentations and slide shows Prezentace a promítání snímků
gridcraft Spreadsheets and data Tabulky a data
cadcraft Computer-aided design and drafting Počítačem podporované navrhování a kreslení
soundcraft Recording, editing and mixing audio Nahrávání, úpravy a mixování zvuku
figcraft Interface design and prototyping Návrh rozhraní a prototypování
month Jan 1.
month Feb 2.
month Mar 3.
month Apr 4.
month May 5.
month Jun 6.
month Jul 7.
month Aug 8.
month Sep 9.
month Oct 10.
month Nov 11.
month Dec 12.
Not checked yet Zatím nezkontrolováno
GitHub's hourly limit was reached; full details return in {min} min Byl dosažen hodinový limit GitHubu; úplné informace budou znovu dostupné za {min} min
GitHub limit hit; back in {min} min Dosažen limit GitHubu; obnovení za {min} min
Installing for {platform} Instalace pro {platform}
Downloads for {platform} Stažení pro {platform}
No downloads for this system Pro tento systém nejsou k dispozici žádné soubory ke stažení
Join the ArtCraft community on Discord Připojit se ke komunitě ArtCraft na Discordu
ArtCraft on {name} ArtCraft na {name}
Couldn't open {name}: {e} {name} nelze otevřít: {e}
Download cancelled Stahování zrušeno
Available to install K dispozici k instalaci
Available K dispozici
Installed · up to date Nainstalováno · aktuální
Update available Je k dispozici aktualizace
Installed Nainstalováno
installed nainstalováno
Not available here Zde není k dispozici
Updating… Aktualizuje se…
Update paused Aktualizace pozastavena
Download paused Stahování pozastaveno
{version} installed → {latest} released Nainstalována verze {version} → vydána {latest}
Version {version} Verze {version}
{installed} installed · latest {latest} Nainstalována verze {installed} · nejnovější {latest}
Installed · latest {latest} Nainstalováno · nejnovější {latest}
Version {version} installed Nainstalována verze {version}
Version {latest} Verze {latest}
No release published yet Zatím nebylo zveřejněno žádné vydání
ArtCraft Launcher ArtCraft Launcher
App Aplikace
Latest Nejnovější
Released Vydáno
Auto Automaticky
Language Jazyk
Ascending Vzestupně
Descending Sestupně
Click to reverse the order Kliknutím obrátíte pořadí
Click to sort by this column Kliknutím seřadíte podle tohoto sloupce
Like {name} Obdoba {name}
{done} of {total} {done} z {total}
Downloading Stahuje se
Verifying checksum… Ověřování kontrolního součtu…
Resume Pokračovat
{done} of {total} downloaded Staženo {done} z {total}
{done} downloaded Staženo {done}
Checking for updates… Kontrola aktualizací…
Downloads the new version, replaces this one and opens it again Stáhne novou verzi, nahradí tuto verzi a znovu ji otevře
Couldn't check for updates: {f} Aktualizace nelze zkontrolovat: {f}
Website (coming soon) Web (již brzy)
GitHub repository Repozitář na GitHubu
ArtCraft ArtCraft
Update 1 app Aktualizovat 1 aplikaci
Update all ({count}) Aktualizovat vše ({count})
⊞ Grid ⊞ Mřížka
☰ Table ☰ Tabulka
🔍 Search apps 🔍 Hledat aplikace
{name} {version} {name} {version}
Match system Podle systému
Dark Tmavý
Light Světlý
Full changelog Úplný přehled změn
link odkaz
No apps match Žádné aplikace neodpovídají hledání
Open ArtCraft Launcher to update. Aktualizujte otevřením aplikace ArtCraft Launcher.
{name} {v} is available Je k dispozici {name} {v}
{count} updates are available Počet dostupných aktualizací: {count}
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Tyto poznámky se nepodařilo načíst (byl dosažen limit požadavků GitHubu). Najdete je na stránce vydání.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Je-li tato volba zapnutá, otevření launcheru z řádku nabídek zobrazí jeho okno na aktuální ploše. Je-li vypnutá, macOS přepne na plochu, na které se okno nacházelo.
Couldn't change the login item: {e} Položku po přihlášení nelze změnit: {e}
column App Aplikace
column Installed Nainstalováno
column Latest Nejnovější
column Released Vydáno
filter Installed Nainstalováno
App details Podrobnosti o aplikaci
Close (Esc) Zavřít (Esc)
Features Funkce
Latest release Nejnovější verze
Version Verze
Platforms Platformy
Releases Vydání
Enlarge picture Zvětšit obrázek
Click to enlarge Kliknutím zvětšíte
Previous picture (←) Předchozí obrázek (←)
Next picture (→) Další obrázek (→)
Loading pictures… Načítání obrázků…
Pictures couldn't be loaded. Reopen this page to try again. Obrázky se nepodařilo načíst. Po opětovném otevření stránky se načtení zkusí znovu.
{name} details Podrobnosti: {name}
@plural {n} app|{n} apps {n} aplikace|{n} aplikace|{n} aplikací
@plural {n} min ago|{n} min ago před {n} min|před {n} min|před {n} min
@plural {n} h ago|{n} h ago před {n} h|před {n} h|před {n} h
@plural {n} day ago|{n} days ago před {n} dnem|před {n} dny|před {n} dny
@msg Couldn't open {name}: {e} {name} nelze otevřít: {e}
@msg Download cancelled Stahování zrušeno
@msg {name} updated to {version} Aplikace {name} byla aktualizována na verzi {version}
@msg {name} installed: {version} Aplikace {name} byla nainstalována ve verzi {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla aktualizována na verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla nainstalována ve verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
@msg {name} was removed Aplikace {name} byla odebrána
@msg {name} is running. Quit it, then try again. Aplikace {name} je spuštěná. Ukončete ji a zkuste to znovu.
@id app.install Nainstalovat
@id app.update Aktualizovat na {v}
@id app.open Otevřít
@id app.download Stáhnout pro {os}
@id app.reveal Zobrazit v umístění
@id app.reinstall Znovu nainstalovat
@id app.uninstall Odinstalovat…
@id app.notes Poznámky k vydání
@id app.website Web
@id app.github Repozitář na GitHubu
@id app.releases Všechna vydání
@id launcher.update Aktualizovat a restartovat
@id launcher.download Stáhnout
@id launcher.notes Co je nového
@id launcher.dismiss Zavřít
@id settings.open Nastavení
@id settings.quit Ukončit ArtCraft Launcher
@id view.grid Mřížka
@id view.table Tabulka
@id filter.all Vše
@id filter.installed Nainstalované
@id filter.updates Aktualizace
@id filter.notinstalled Nenainstalované
@id sort.suite Pořadí v sadě
@id sort.name Název
@id sort.inspired Inspirace
@id sort.status Stav
@id sort.installed Nainstalovaná verze
@id sort.latest Nejnovější verze
@id sort.released Nejnovější vydání
@id help.discord Discord
@id help.github GitHub
@id help.website Web
@id update.check Zkontrolovat aktualizace
@id update.check_auto Automaticky kontrolovat aktualizace
@id update.notify Upozornit, když má nainstalovaná aplikace aktualizaci
@id update.prereleases Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
@id linux.preferred_kind Instalovat nové aplikace jako
@id tray.close_to_tray Po zavření okna ponechat spuštěné v oznamovací oblasti
@id tray.show_on_current_desktop Zobrazit okno na aktuální ploše
@id tray.login_item Spouštět ArtCraft Launcher po přihlášení
@id appearance.theme Vzhled
Show ArtCraft Launcher Zobrazit ArtCraft Launcher
Check for Updates Zkontrolovat aktualizace
Visit getartcraft.com Navštívit getartcraft.com
ArtCraft on GitHub ArtCraft na GitHubu
Join the ArtCraft Discord Připojit se na ArtCraft Discord
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostupná aktualizace|ArtCraft Launcher · {n} dostupné aktualizace|ArtCraft Launcher · {n} dostupných aktualizací
Can't render this file because it has a wrong number of fields in line 8.

View File

@ -1,346 +0,0 @@
# German (de) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (English and German).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral German,
# using the terms German-speaking users of creative software know. The user is addressed with
# the informal "du", as in most current creative and community software.
#
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite",
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Open-Source-Werkzeuge für Kreative, Maker und Entwickler
Settings Einstellungen
Check for updates Nach Updates suchen
Grid Raster
Table Tabelle
Show apps as cards Apps als Karten anzeigen
Show apps as a table Apps als Tabelle anzeigen
All Alle
Updates Updates
Not installed Nicht installiert
Search apps Apps durchsuchen
Sort Sortieren
Suite order Suite-Reihenfolge
Name Name
Inspired by Inspiriert von
Status Status
Installed version Installierte Version
Latest version Neueste Version
Newest release Neueste Veröffentlichung
Discord Discord
GitHub GitHub
Website Website
Classic apps Klassische Apps
New apps Neue Apps
NEW NEU
Install Installieren
Update to {v} Auf {v} aktualisieren
Open Öffnen
Download for {os} Für {os} herunterladen
Download Herunterladen
Coming soon Demnächst
Checking… Wird geprüft…
Not available Nicht verfügbar
Offline Offline
Downloading {done} of {total} Herunterladen: {done} von {total}
Downloading… Wird heruntergeladen…
Reconnecting… Neue Verbindung…
Verifying… Integrität wird geprüft…
Installing, then restarting… Installation, dann Neustart…
Installing… Wird installiert…
Removing… Wird entfernt…
Pause download Download pausieren
Cancel download Download abbrechen
Resume download Download fortsetzen
Discard download Download verwerfen
Release notes Versionshinweise
Show in Finder Im Finder zeigen
Show in Explorer Im Explorer zeigen
Show in Files Im Dateimanager zeigen
Reinstall Neu installieren
Uninstall… Deinstallieren…
All releases Alle Versionen
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} ist verfügbar.
Dismiss Ausblenden
Update and restart Aktualisieren und neu starten
A new version is available. Would you like to relaunch with the updated version? Eine neue Version ist verfügbar. Mit der neuen Version neu starten?
Later Später
What's new Neuigkeiten
No notes were published with this release. Zu dieser Version wurden keine Hinweise veröffentlicht.
Released {date} Veröffentlicht am {date}
Open on GitHub Auf GitHub öffnen
Close Schließen
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Nur manuell
Automatically download and install updates Updates automatisch herunterladen und installieren
Notify me when an installed app has an update Benachrichtigen, wenn es für eine installierte App ein Update gibt
Include pre-releases (betas and release candidates) Vorabversionen einbeziehen (Betas und Release Candidates)
Installing on Linux Installation unter Linux
Install new apps as Neue Apps installieren als
System package (recommended) Systempaket (empfohlen)
Updates always use the format an app was installed with. Updates verwenden immer das Format, mit dem eine App installiert wurde.
Running in the background Im Hintergrund ausführen
Keep running in the tray when the window is closed Beim Schließen des Fensters im Infobereich weiterlaufen
Show the window on the current desktop Fenster auf dem aktuellen Schreibtisch zeigen
Start ArtCraft Launcher when I log in ArtCraft Launcher bei der Anmeldung starten
Community Community
Questions, ideas and news from the ArtCraft team Fragen, Ideen und Neuigkeiten vom ArtCraft-Team
Appearance Erscheinungsbild
Quit ArtCraft Launcher ArtCraft Launcher beenden
Done Fertig
Uninstall {name}? {name} deinstallieren?
The app will be moved to the Trash. Your documents and settings stay. Die App wird in den Papierkorb verschoben. Deine Dokumente und Einstellungen bleiben erhalten.
Windows will remove it like any installed program. Your documents and settings stay. Windows entfernt sie wie jedes installierte Programm. Deine Dokumente und Einstellungen bleiben erhalten.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Die Paketverwaltung deines Systems entfernt sie und fragt eventuell nach deinem Passwort. Deine Dokumente und Einstellungen bleiben erhalten.
The app will be removed. Your documents and settings stay. Die App wird entfernt. Deine Dokumente und Einstellungen bleiben erhalten.
Uninstall Deinstallieren
Cancel Abbrechen
Checked {ago} Zuletzt geprüft: {ago}
just now gerade eben
yesterday gestern
{month} {day}, {year} {day}. {month} {year}
Automatically Automatisch
Every Craft app Alle Craft-Apps
Apps on this computer Apps auf diesem Computer
Installed apps with a newer version Installierte Apps mit neuerer Version
Apps you can install Apps, die du installieren kannst
Hide App App ausblenden
Unhide App App einblenden
Hidden Apps Ausgeblendete Apps
Apps you hid Apps, die du ausgeblendet hast
Checked {ago} · using {sources} Zuletzt geprüft: {ago} · über {sources}
{a} and {b} {a} und {b}
Join the Discord Dem Discord beitreten
Learn more Mehr erfahren
Minimize Minimieren
Maximize Maximieren
Restore Wiederherstellen
New Neu
OK OK
Open link Link öffnen
Update Aktualisieren
{name} icon {name}-Symbol
Loading ArtCraft Launcher… ArtCraft Launcher wird geladen…
Cancel update Update abbrechen
Stop waiting; the update can be started again later Nicht mehr warten; das Update kann später erneut gestartet werden
Download again Erneut herunterladen
Install cancelled Installation abgebrochen
Install cancelled. The download is kept for the next try. Installation abgebrochen. Der Download bleibt für den nächsten Versuch erhalten.
Update cancelled Update abgebrochen
Uninstall cancelled Deinstallation abgebrochen
Retry install Installation wiederholen
Retry update to {version} Update auf {version} wiederholen
The download is still here. It's checked again, then installed. Der Download ist noch da. Er wird erneut geprüft und dann installiert.
Waiting for another app… Warten auf eine andere App…
Waiting for other installs… Warten auf andere Installationen…
# Package formats offered for download (PackageKind::label)
Installer (.msi) Installationsprogramm (.msi)
Portable (.zip) Portable Version (.zip)
Disk image (.dmg) Disk-Image (.dmg)
Debian package (.deb) Debian-Paket (.deb)
RPM package (.rpm) RPM-Paket (.rpm)
Flatpak bundle Flatpak-Bundle
Tarball (.tar.gz) Tar-Archiv (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing Bildbearbeitung
vectorcraft Vector illustration Vektorillustration
filmcraft Video editing, color and sound Videoschnitt, Farbe und Ton
lightcraft Photo library and raw development Fotobibliothek und RAW-Entwicklung
pdfcraft Reading, organizing and protecting PDFs PDFs lesen, ordnen und schützen
effectcraft Motion graphics and visual effects Motion Graphics und visuelle Effekte
designcraft Page layout and publishing Seitenlayout und Publishing
wordcraft Writing and document design Schreiben und Dokumentgestaltung
deckcraft Presentations and slide shows Präsentationen und Diashows
gridcraft Spreadsheets and data Tabellenkalkulation und Daten
cadcraft Computer-aided design and drafting Computergestütztes Konstruieren und Zeichnen
soundcraft Recording, editing and mixing audio Audio aufnehmen, bearbeiten und mischen
figcraft Interface design and prototyping Interface-Design und Prototyping
# Contextual: month abbreviations for date rendering
month Jan Jan.
month Feb Feb.
month Mar März
month Apr Apr.
month May Mai
month Jun Juni
month Jul Juli
month Aug Aug.
month Sep Sept.
month Oct Okt.
month Nov Nov.
month Dec Dez.
Not checked yet Noch nicht geprüft
GitHub's hourly limit was reached; full details return in {min} min Das Stundenlimit von GitHub ist erreicht; alle Details sind in {min} Min. wieder da
GitHub limit hit; back in {min} min GitHub-Limit erreicht; zurück in {min} Min.
Installing for {platform} Installation für {platform}
Downloads for {platform} Downloads für {platform}
No downloads for this system Keine Downloads für dieses System
Join the ArtCraft community on Discord Tritt der ArtCraft-Community auf Discord bei
ArtCraft on {name} ArtCraft auf {name}
Couldn't open {name}: {e} {name} konnte nicht geöffnet werden: {e}
Download cancelled Download abgebrochen
Available to install Installierbar
Available Verfügbar
Installed · up to date Installiert · aktuell
Update available Update verfügbar
Installed Installiert
installed installiert
Not available here Hier nicht verfügbar
Updating… Wird aktualisiert…
Update paused Update pausiert
Download paused Download pausiert
{version} installed → {latest} released {version} installiert → {latest} veröffentlicht
Version {version} Version {version}
{installed} installed · latest {latest} {installed} installiert · neueste {latest}
Installed · latest {latest} Installiert · neueste {latest}
Version {version} installed Version {version} installiert
Version {latest} Version {latest}
No release published yet Noch keine Version veröffentlicht
ArtCraft Launcher ArtCraft Launcher
App App
Latest Neueste
Released Veröffentlicht
Auto Automatisch
Language Sprache
Ascending Aufsteigend
Descending Absteigend
Click to reverse the order Klicken, um die Reihenfolge umzukehren
Click to sort by this column Klicken, um nach dieser Spalte zu sortieren
Like {name} Wie {name}
{done} of {total} {done} von {total}
Downloading Download läuft
Verifying checksum… Prüfsumme wird geprüft…
Resume Fortsetzen
{done} of {total} downloaded {done} von {total} heruntergeladen
{done} downloaded {done} heruntergeladen
Checking for updates… Suche nach Updates…
Downloads the new version, replaces this one and opens it again Lädt die neue Version herunter, ersetzt diese und öffnet sie erneut
Couldn't check for updates: {f} Updates konnten nicht geprüft werden: {f}
GitHub repository GitHub-Repository
ArtCraft ArtCraft
Update 1 app 1 App aktualisieren
Update all ({count}) Alle aktualisieren ({count})
⊞ Grid ⊞ Raster
☰ Table ☰ Tabelle
🔍 Search apps 🔍 Apps durchsuchen
{name} {version} {name} {version}
Match system Wie das System
Dark Dunkel
Light Hell
Full changelog Vollständiges Änderungsprotokoll
link Link
No apps match Keine passenden Apps
Open ArtCraft Launcher to update. Öffne ArtCraft Launcher, um zu aktualisieren.
{name} {v} is available {name} {v} ist verfügbar
{count} updates are available {count} Updates sind verfügbar
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Diese Hinweise wurden nicht geladen (das Anfragelimit von GitHub ist erreicht). Sie stehen auf der Release-Seite.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Wenn aktiv, holt das Öffnen des Launchers aus der Menüleiste sein Fenster auf den Schreibtisch, auf dem du gerade bist. Wenn nicht, wechselt macOS zu dem Schreibtisch, auf dem das Fenster war.
Couldn't change the login item: {e} Das Anmeldeobjekt konnte nicht geändert werden: {e}
# Contextual: table column headers
column App App
column Installed Lokal
column Latest Neueste
column Released Datum
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Installiert
{name} details Details zu {name}
# Plural forms for German (one|other)
@plural {n} app|{n} apps {n} App|{n} Apps
@plural {n} min ago|{n} min ago vor {n} Min.|vor {n} Min.
@plural {n} h ago|{n} h ago vor {n} Std.|vor {n} Std.
@plural {n} day ago|{n} days ago vor {n} Tag|vor {n} Tagen
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} {name} konnte nicht geöffnet werden: {e}
@msg Download cancelled Download abgebrochen
@msg {name} updated to {version} {name} auf {version} aktualisiert
@msg {name} installed: {version} {name} installiert: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} auf {version} aktualisiert (diese Version veröffentlicht keine Prüfsumme, daher wurde nur die Größe geprüft)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} installiert: {version} (diese Version veröffentlicht keine Prüfsumme, daher wurde nur die Größe geprüft)
@msg {name} was removed {name} wurde entfernt
@msg {name} is running. Quit it, then try again. {name} läuft. Beende die App und versuche es erneut.
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install Installieren
@id app.update Auf {v} aktualisieren
@id app.open Öffnen
@id app.download Für {os} herunterladen
@id app.reveal Im Finder zeigen
@id app.reinstall Neu installieren
@id app.uninstall Deinstallieren…
@id app.notes Versionshinweise
@id app.website Website
@id app.github GitHub-Repository
@id app.releases Alle Versionen
@id launcher.update Aktualisieren und neu starten
@id launcher.download Herunterladen
@id launcher.notes Neuigkeiten
@id launcher.dismiss Ausblenden
@id settings.open Einstellungen
@id settings.quit ArtCraft Launcher beenden
@id view.grid Raster
@id view.table Tabelle
@id filter.all Alle
@id filter.installed Installiert
@id filter.updates Updates
@id filter.notinstalled Nicht installiert
@id sort.suite Suite-Reihenfolge
@id sort.name Name
@id sort.inspired Inspiriert von
@id sort.status Status
@id sort.installed Installierte Version
@id sort.latest Neueste Version
@id sort.released Neueste Veröffentlichung
@id help.discord Discord
@id help.github GitHub
@id help.website Website
@id update.check Nach Updates suchen
@id update.check_auto Automatisch nach Updates suchen
@id update.notify Benachrichtigen, wenn es für eine installierte App ein Update gibt
@id update.prereleases Vorabversionen einbeziehen (Betas und Release Candidates)
@id linux.preferred_kind Neue Apps installieren als
@id tray.close_to_tray Beim Schließen des Fensters im Infobereich weiterlaufen
@id tray.show_on_current_desktop Fenster auf dem aktuellen Schreibtisch zeigen
@id tray.login_item ArtCraft Launcher bei der Anmeldung starten
@id appearance.theme Erscheinungsbild
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher ArtCraft Launcher anzeigen
Check for Updates Nach Updates suchen
Visit getartcraft.com getartcraft.com besuchen
ArtCraft on GitHub ArtCraft auf GitHub
Join the ArtCraft Discord Dem ArtCraft-Discord beitreten
App details App-Details
Close (Esc) Schließen (Esc)
Features Funktionen
Latest release Neueste Version
Version Version
Platforms Plattformen
Releases Versionen
Enlarge picture Bild vergrößern
Click to enlarge Zum Vergrößern klicken
Previous picture (←) Vorheriges Bild (←)
Next picture (→) Nächstes Bild (→)
Loading pictures… Bilder werden geladen…
Pictures couldn't be loaded. Reopen this page to try again. Die Bilder konnten nicht geladen werden. Beim erneuten Öffnen der Seite wird es noch einmal versucht.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} Update verfügbar|ArtCraft Launcher · {n} Updates verfügbar
Can't render this file because it contains an unexpected character in line 11 and column 16.

View File

@ -1,70 +0,0 @@
//! Working out the system language: the OS's preferred UI languages on the desktop, the
//! browser's on the web.
use std::sync::OnceLock;
use super::lang::{Lang, lang_from_tag};
/// The system language, worked out once.
static SYSTEM: OnceLock<Lang> = OnceLock::new();
/// Work out the system language on a background thread, so the first frame doesn't wait for the
/// locale probe. Call it at startup; a frame that needs the language before the probe finishes
/// waits for it. A no-op on wasm.
pub fn detect_system_lang_in_background() {
#[cfg(not(target_arch = "wasm32"))]
{
let _ = std::thread::Builder::new().name("locale-probe".into()).spawn(|| {
system_lang();
});
}
}
/// The system language (cached). English when it can't be determined.
pub fn system_lang() -> Lang {
// Tests drive the UI by its English labels whatever the developer's locale is.
if cfg!(test) {
return Lang::EN;
}
*SYSTEM.get_or_init(detect_system_lang)
}
/// Fix the system language before anything asks for it (integration tests, which are compiled
/// without `cfg(test)`, use it to stay independent of the developer's locale).
pub fn set_test_lang(lang: Lang) {
let _ = SYSTEM.set(lang);
}
/// Tell the UI the system's preferred languages, most preferred first, where it can't find them
/// itself: the web shell passes the browser's (`navigator.languages`). The first supported one is
/// the system language (English if none is); call it before the first frame. Has no effect once
/// the system language is known.
pub fn set_system_locales<S: AsRef<str>>(tags: &[S]) {
let lang = first_supported(tags.iter().map(AsRef::as_ref)).unwrap_or(Lang::EN);
// Already set means already detected (or reported): the first answer stands.
let _ = SYSTEM.set(lang);
}
/// The first supported language in a preference list, most preferred first.
pub(super) fn first_supported<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<Lang> {
tags.into_iter().find_map(lang_from_tag)
}
/// `ARTCRAFT_LAUNCHER_LOCALE` overrides everything; then the OS's preferred UI languages
/// (`sys-locale`: `LANGUAGE`/`LC_*`/`LANG` on Linux and the BSDs, the preferred-languages list on
/// macOS, which apps opened from the Finder need since they inherit no `LANG`, and the user's UI
/// languages on Windows).
#[cfg(not(target_arch = "wasm32"))]
fn detect_system_lang() -> Lang {
if let Some(l) = std::env::var("ARTCRAFT_LAUNCHER_LOCALE").ok().and_then(|v| first_supported(v.split(':'))) {
return l;
}
let locales: Vec<String> = sys_locale::get_locales().collect();
first_supported(locales.iter().map(String::as_str)).unwrap_or(Lang::EN)
}
/// The browser's languages arrive through [`set_system_locales`]; without them, English.
#[cfg(target_arch = "wasm32")]
fn detect_system_lang() -> Lang {
Lang::EN
}

View File

@ -1,327 +0,0 @@
# Spanish (es) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (only 1 is "one" in Spanish).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels, in neutral
# international Spanish for Spain and Latin America (the interface addresses the user as "tú";
# no regional words such as "ordenador").
#
# Coverage: the whole interface: every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft,
# DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
# SoundCraft, FigCraft), "GitHub", "Discord", "Finder", "makers".
# Trailing "…" (U+2026) must be preserved. Placeholders must appear in the translation:
# {ago}, {a}, {b}, {count}, {date}, {day}, {done}, {e}, {f}, {installed}, {latest}, {min},
# {month}, {name}, {n}, {os}, {platform}, {sources}, {total}, {version}, {v}, {year}.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Herramientas de código abierto para creativos, makers y constructores
Settings Ajustes
Check for updates Buscar actualizaciones
Grid Cuadrícula
Table Tabla
Show apps as cards Mostrar las aplicaciones como tarjetas
Show apps as a table Mostrar las aplicaciones como tabla
All Todas
Updates Actualizaciones
Not installed No instaladas
Search apps Buscar aplicaciones
Sort Ordenar
Suite order Orden de la suite
Name Nombre
Inspired by Inspirada en
Status Estado
Installed version Versión instalada
Latest version Última versión
Newest release Publicación más reciente
Discord Discord
GitHub GitHub
Website Sitio web
Classic apps Aplicaciones clásicas
New apps Aplicaciones nuevas
NEW NUEVA
Install Instalar
Update to {v} Actualizar a {v}
Open Abrir
Download for {os} Descargar para {os}
Download Descargar
Coming soon Próximamente
Checking… Comprobando…
Not available No disponible
Offline Sin conexión
Downloading {done} of {total} Descargando {done} de {total}
Downloading… Descargando…
Reconnecting… Reconectando…
Verifying… Verificando…
Installing, then restarting… Instalando y reiniciando…
Installing… Instalando…
Removing… Quitando…
Pause download Pausar la descarga
Cancel download Cancelar la descarga
Resume download Reanudar la descarga
Discard download Descartar la descarga
Release notes Notas de la versión
Show in Finder Mostrar en el Finder
Show in Explorer Mostrar en el Explorador
Show in Files Mostrar en Archivos
Reinstall Reinstalar
Uninstall… Desinstalar…
All releases Todas las versiones
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} está disponible.
Dismiss Descartar
Update and restart Actualizar y reiniciar
A new version is available. Would you like to relaunch with the updated version? Hay una versión nueva. ¿Quieres reiniciar con la versión actualizada?
Later Más tarde
What's new Novedades
No notes were published with this release. Esta versión no se publicó con notas.
Released {date} Publicada el {date}
Open on GitHub Abrir en GitHub
Close Cerrar
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Solo cuando lo pida
Automatically download and install updates Descargar e instalar actualizaciones automáticamente
Notify me when an installed app has an update Avisarme cuando una aplicación instalada tenga una actualización
Include pre-releases (betas and release candidates) Incluir versiones preliminares (betas y versiones candidatas)
Installing on Linux Instalación en Linux
Install new apps as Instalar las aplicaciones nuevas como
System package (recommended) Paquete del sistema (recomendado)
Updates always use the format an app was installed with. Las actualizaciones siempre usan el formato con el que se instaló cada aplicación.
Running in the background En segundo plano
Keep running in the tray when the window is closed Seguir abierto en la bandeja del sistema al cerrar la ventana
Show the window on the current desktop Mostrar la ventana en el escritorio actual
Start ArtCraft Launcher when I log in Abrir ArtCraft Launcher al iniciar sesión
Community Comunidad
Questions, ideas and news from the ArtCraft team Preguntas, ideas y noticias del equipo de ArtCraft
Appearance Apariencia
Quit ArtCraft Launcher Salir de ArtCraft Launcher
Done Listo
Uninstall {name}? ¿Desinstalar {name}?
The app will be moved to the Trash. Your documents and settings stay. La aplicación se moverá a la Papelera. Tus documentos y ajustes se conservan.
Windows will remove it like any installed program. Your documents and settings stay. Windows la quitará como cualquier programa instalado. Tus documentos y ajustes se conservan.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. El gestor de paquetes del sistema la quitará y puede pedirte la contraseña. Tus documentos y ajustes se conservan.
The app will be removed. Your documents and settings stay. La aplicación se quitará. Tus documentos y ajustes se conservan.
Uninstall Desinstalar
Cancel Cancelar
Checked {ago} Comprobado {ago}
just now ahora mismo
yesterday ayer
{month} {day}, {year} {day} {month} {year}
Automatically Automáticamente
Every Craft app Todas las aplicaciones Craft
Apps on this computer Aplicaciones en este equipo
Installed apps with a newer version Aplicaciones instaladas con una versión más reciente
Apps you can install Aplicaciones que puedes instalar
Hide App Ocultar aplicación
Unhide App Mostrar aplicación
Hidden Apps Aplicaciones ocultas
Apps you hid Aplicaciones que ocultaste
Checked {ago} · using {sources} Comprobado {ago} · con {sources}
{a} and {b} {a} y {b}
Join the Discord Únete al Discord
Learn more Más información
Minimize Minimizar
Maximize Maximizar
Restore Restaurar
New Nueva
OK Aceptar
Open link Abrir enlace
Update Actualizar
{name} icon Icono de {name}
Loading ArtCraft Launcher… Cargando ArtCraft Launcher…
Cancel update Cancelar la actualización
Stop waiting; the update can be started again later Dejar de esperar; la actualización se puede volver a iniciar más tarde
Download again Volver a descargar
Install cancelled Instalación cancelada
Install cancelled. The download is kept for the next try. Instalación cancelada. La descarga se guarda para el próximo intento.
Update cancelled Actualización cancelada
Uninstall cancelled Desinstalación cancelada
Retry install Reintentar la instalación
Retry update to {version} Reintentar la actualización a {version}
The download is still here. It's checked again, then installed. La descarga sigue aquí. Se vuelve a comprobar y luego se instala.
Waiting for another app… Esperando a otra aplicación…
Waiting for other installs… Esperando a otras instalaciones…
Installer (.msi) Instalador (.msi)
Portable (.zip) Portátil (.zip)
Disk image (.dmg) Imagen de disco (.dmg)
Debian package (.deb) Paquete Debian (.deb)
RPM package (.rpm) Paquete RPM (.rpm)
Flatpak bundle Paquete Flatpak
Tarball (.tar.gz) Archivo tar (.tar.gz)
photocraft Image editing Edición de imágenes
vectorcraft Vector illustration Ilustración vectorial
filmcraft Video editing, color and sound Edición de video, color y sonido
lightcraft Photo library and raw development Fototeca y revelado RAW
pdfcraft Reading, organizing and protecting PDFs Leer, organizar y proteger PDF
effectcraft Motion graphics and visual effects Gráficos en movimiento y efectos visuales
designcraft Page layout and publishing Maquetación y publicación
wordcraft Writing and document design Escritura y diseño de documentos
deckcraft Presentations and slide shows Presentaciones y diapositivas
gridcraft Spreadsheets and data Hojas de cálculo y datos
cadcraft Computer-aided design and drafting CAD: diseño y dibujo técnico
soundcraft Recording, editing and mixing audio Grabación, edición y mezcla de audio
figcraft Interface design and prototyping Diseño de interfaces y prototipos
month Jan ene.
month Feb feb.
month Mar mar.
month Apr abr.
month May may.
month Jun jun.
month Jul jul.
month Aug ago.
month Sep sept.
month Oct oct.
month Nov nov.
month Dec dic.
Not checked yet Aún sin comprobar
GitHub's hourly limit was reached; full details return in {min} min Se alcanzó el límite por hora de GitHub; todos los detalles estarán disponibles de nuevo en {min} min
GitHub limit hit; back in {min} min Límite de GitHub alcanzado; disponible de nuevo en {min} min
Installing for {platform} Instalación para {platform}
Downloads for {platform} Descargas para {platform}
No downloads for this system No hay descargas para este sistema
Join the ArtCraft community on Discord Únete a la comunidad de ArtCraft en Discord
ArtCraft on {name} ArtCraft en {name}
Couldn't open {name}: {e} No se pudo abrir {name}: {e}
Download cancelled Descarga cancelada
Available to install Disponible para instalar
Available Disponible
Installed · up to date Instalada · al día
Update available Actualización disponible
Installed Instalada
installed instalada
Not available here No disponible aquí
Updating… Actualizando…
Update paused Actualización en pausa
Download paused Descarga en pausa
{version} installed → {latest} released {version} instalada → {latest} publicada
Version {version} Versión {version}
{installed} installed · latest {latest} {installed} instalada · última {latest}
Installed · latest {latest} Instalada · última {latest}
Version {version} installed Versión {version} instalada
Version {latest} Versión {latest}
No release published yet Aún no hay ninguna versión publicada
ArtCraft Launcher ArtCraft Launcher
App Aplicación
Latest Última
Released Publicada
Auto Automático
Language Idioma
Ascending Ascendente
Descending Descendente
Click to reverse the order Haz clic para invertir el orden
Click to sort by this column Haz clic para ordenar por esta columna
Like {name} Al estilo de {name}
{done} of {total} {done} de {total}
Downloading Descargando
Verifying checksum… Verificando la suma de comprobación…
Resume Reanudar
{done} of {total} downloaded {done} de {total} descargados
{done} downloaded {done} descargados
Checking for updates… Buscando actualizaciones…
Downloads the new version, replaces this one and opens it again Descarga la versión nueva, reemplaza esta y vuelve a abrirla
Couldn't check for updates: {f} No se pudieron buscar actualizaciones: {f}
Website (coming soon) Sitio web (próximamente)
GitHub repository Repositorio en GitHub
ArtCraft ArtCraft
Update 1 app Actualizar 1 aplicación
Update all ({count}) Actualizar todas ({count})
⊞ Grid ⊞ Cuadrícula
☰ Table ☰ Tabla
🔍 Search apps 🔍 Buscar aplicaciones
{name} {version} {name} {version}
Match system Igual que el sistema
Dark Oscuro
Light Claro
Full changelog Registro de cambios completo
link enlace
No apps match Ninguna aplicación coincide
Open ArtCraft Launcher to update. Abre ArtCraft Launcher para actualizar.
{name} {v} is available {name} {v} está disponible
{count} updates are available Hay {count} actualizaciones disponibles
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Estas notas no se descargaron (se alcanzó el límite de solicitudes de GitHub). Están en la página de la versión.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Si está activado, abrir ArtCraft Launcher desde la barra de menús lleva su ventana al escritorio en el que estás. Si está desactivado, macOS te cambia al escritorio en el que estaba la ventana.
Couldn't change the login item: {e} No se pudo cambiar el elemento de inicio de sesión: {e}
column App Aplicación
column Installed Instalada
column Latest Última
column Released Publicada
filter Installed Instaladas
App details Detalles de la app
Close (Esc) Cerrar (Esc)
Features Funciones
Latest release Última versión
Version Versión
Platforms Plataformas
Releases Versiones
Enlarge picture Ampliar imagen
Click to enlarge Haz clic para ampliar
Previous picture (←) Imagen anterior (←)
Next picture (→) Imagen siguiente (→)
Loading pictures… Cargando imágenes…
Pictures couldn't be loaded. Reopen this page to try again. No se pudieron cargar las imágenes. Vuelve a abrir esta página para intentarlo de nuevo.
{name} details Detalles de {name}
@plural {n} app|{n} apps {n} aplicación|{n} aplicaciones
@plural {n} min ago|{n} min ago hace {n} min|hace {n} min
@plural {n} h ago|{n} h ago hace {n} h|hace {n} h
@plural {n} day ago|{n} days ago hace {n} día|hace {n} días
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
@msg Download cancelled Descarga cancelada
@msg {name} updated to {version} {name} actualizada a {version}
@msg {name} installed: {version} {name} instalada: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} actualizada a {version} (esta versión no publica la suma de comprobación, así que solo se comprobó su tamaño)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} instalada: {version} (esta versión no publica la suma de comprobación, así que solo se comprobó su tamaño)
@msg {name} was removed Se quitó {name}
@msg {name} is running. Quit it, then try again. {name} está abierta. Ciérrala y vuelve a intentarlo.
@id app.install Instalar
@id app.update Actualizar a {v}
@id app.open Abrir
@id app.download Descargar para {os}
@id app.reveal Mostrar en el Finder
@id app.reinstall Reinstalar
@id app.uninstall Desinstalar…
@id app.notes Notas de la versión
@id app.website Sitio web
@id app.github Repositorio en GitHub
@id app.releases Todas las versiones
@id launcher.update Actualizar y reiniciar
@id launcher.download Descargar
@id launcher.notes Novedades
@id launcher.dismiss Descartar
@id settings.open Ajustes
@id settings.quit Salir de ArtCraft Launcher
@id view.grid Cuadrícula
@id view.table Tabla
@id filter.all Todas
@id filter.installed Instaladas
@id filter.updates Actualizaciones
@id filter.notinstalled No instaladas
@id sort.suite Orden de la suite
@id sort.name Nombre
@id sort.inspired Inspirada en
@id sort.status Estado
@id sort.installed Versión instalada
@id sort.latest Última versión
@id sort.released Publicación más reciente
@id help.discord Discord
@id help.github GitHub
@id help.website Sitio web
@id update.check Buscar actualizaciones
@id update.check_auto Buscar actualizaciones automáticamente
@id update.notify Avisarme cuando una aplicación instalada tenga una actualización
@id update.prereleases Incluir versiones preliminares (betas y versiones candidatas)
@id linux.preferred_kind Instalar las aplicaciones nuevas como
@id tray.close_to_tray Seguir abierto en la bandeja del sistema al cerrar la ventana
@id tray.show_on_current_desktop Mostrar la ventana en el escritorio actual
@id tray.login_item Abrir ArtCraft Launcher al iniciar sesión
@id appearance.theme Apariencia
Show ArtCraft Launcher Mostrar ArtCraft Launcher
Check for Updates Buscar actualizaciones
Visit getartcraft.com Visitar getartcraft.com
ArtCraft on GitHub ArtCraft en GitHub
Join the ArtCraft Discord Únete al Discord de ArtCraft
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} actualización disponible|ArtCraft Launcher · {n} actualizaciones disponibles
Can't render this file because it contains an unexpected character in line 5 and column 48.

View File

@ -1,344 +0,0 @@
# French (fr) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (0 and 1 are "one" in French).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels, in neutral
# international French (the interface addresses the user as "vous").
#
# Coverage: the whole interface: every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft,
# PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub",
# "Discord".
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Des outils open source pour les créatifs, les makers et les bâtisseurs
Settings Réglages
Check for updates Rechercher des mises à jour
Grid Grille
Table Tableau
Show apps as cards Afficher les applications en cartes
Show apps as a table Afficher les applications en tableau
All Toutes
Updates Mises à jour
Not installed Non installées
Search apps Rechercher des applications
Sort Tri
Suite order Ordre de la suite
Name Nom
Inspired by Inspirée de
Status État
Installed version Version installée
Latest version Dernière version
Newest release Version la plus récente
Discord Discord
GitHub GitHub
Website Site web
Classic apps Applications classiques
New apps Nouvelles applications
NEW NOUVEAU
Install Installer
Update to {v} Mettre à jour vers {v}
Open Ouvrir
Download for {os} Télécharger pour {os}
Download Télécharger
Coming soon Bientôt disponible
Checking… Vérification…
Not available Indisponible
Offline Hors ligne
Downloading {done} of {total} Téléchargement de {done} sur {total}
Downloading… Téléchargement…
Reconnecting… Reconnexion…
Verifying… Vérification…
Installing, then restarting… Installation, puis redémarrage…
Installing… Installation…
Removing… Suppression…
Pause download Mettre le téléchargement en pause
Cancel download Annuler le téléchargement
Resume download Reprendre le téléchargement
Discard download Supprimer le téléchargement
Release notes Notes de version
Show in Finder Afficher dans le Finder
Show in Explorer Afficher dans l'Explorateur
Show in Files Afficher dans Fichiers
Reinstall Réinstaller
Uninstall… Désinstaller…
All releases Toutes les versions
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} est disponible.
Dismiss Ignorer
Update and restart Mettre à jour et redémarrer
A new version is available. Would you like to relaunch with the updated version? Une nouvelle version est disponible. Redémarrer avec la nouvelle version ?
Later Plus tard
What's new Nouveautés
No notes were published with this release. Aucune note n'a été publiée avec cette version.
Released {date} Publiée le {date}
Open on GitHub Ouvrir sur GitHub
Close Fermer
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Seulement à ma demande
Automatically download and install updates Télécharger et installer les mises à jour automatiquement
Notify me when an installed app has an update Me prévenir quand une application installée a une mise à jour
Include pre-releases (betas and release candidates) Inclure les préversions (bêtas et versions candidates)
Installing on Linux Installation sous Linux
Install new apps as Installer les nouvelles applications en tant que
System package (recommended) Paquet système (recommandé)
Updates always use the format an app was installed with. Les mises à jour utilisent toujours le format avec lequel l'application a été installée.
Running in the background Exécution en arrière-plan
Keep running in the tray when the window is closed Rester actif dans la zone de notification à la fermeture de la fenêtre
Show the window on the current desktop Afficher la fenêtre sur le bureau actuel
Start ArtCraft Launcher when I log in Lancer ArtCraft Launcher à l'ouverture de session
Community Communauté
Questions, ideas and news from the ArtCraft team Questions, idées et actualités de l'équipe ArtCraft
Appearance Apparence
Quit ArtCraft Launcher Quitter ArtCraft Launcher
Done Terminé
Uninstall {name}? Désinstaller {name} ?
The app will be moved to the Trash. Your documents and settings stay. L'application sera placée dans la Corbeille. Vos documents et réglages sont conservés.
Windows will remove it like any installed program. Your documents and settings stay. Windows la supprimera comme n'importe quel programme installé. Vos documents et réglages sont conservés.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Le gestionnaire de paquets de votre système la supprimera et pourra vous demander votre mot de passe. Vos documents et réglages sont conservés.
The app will be removed. Your documents and settings stay. L'application sera supprimée. Vos documents et réglages sont conservés.
Uninstall Désinstaller
Cancel Annuler
Checked {ago} Vérifié {ago}
just now à l'instant
yesterday hier
{month} {day}, {year} {day} {month} {year}
Automatically Automatiquement
Every Craft app Toutes les applications Craft
Apps on this computer Applications sur cet ordinateur
Installed apps with a newer version Applications installées avec une version plus récente
Apps you can install Applications que vous pouvez installer
Hide App Masquer l'application
Unhide App Afficher l'application
Hidden Apps Applications masquées
Apps you hid Applications que vous avez masquées
Checked {ago} · using {sources} Vérifié {ago} · via {sources}
{a} and {b} {a} et {b}
Join the Discord Rejoindre le Discord
Learn more En savoir plus
Minimize Réduire
Maximize Agrandir
Restore Restaurer
New Nouveau
OK OK
Open link Ouvrir le lien
Update Mettre à jour
{name} icon Icône de {name}
Loading ArtCraft Launcher… Chargement d'ArtCraft Launcher…
Cancel update Annuler la mise à jour
Stop waiting; the update can be started again later Ne plus attendre ; la mise à jour pourra être relancée plus tard
Download again Télécharger à nouveau
Install cancelled Installation annulée
Install cancelled. The download is kept for the next try. Installation annulée. Le téléchargement est conservé pour la prochaine tentative.
Update cancelled Mise à jour annulée
Uninstall cancelled Désinstallation annulée
Retry install Réessayer l'installation
Retry update to {version} Réessayer la mise à jour vers {version}
The download is still here. It's checked again, then installed. Le téléchargement est toujours là. Il sera vérifié à nouveau, puis installé.
Waiting for another app… En attente d'une autre application…
Waiting for other installs… En attente d'autres installations…
# Package formats offered for download (PackageKind::label)
Installer (.msi) Programme d'installation (.msi)
Portable (.zip) Version portable (.zip)
Disk image (.dmg) Image disque (.dmg)
Debian package (.deb) Paquet Debian (.deb)
RPM package (.rpm) Paquet RPM (.rpm)
Flatpak bundle Paquet Flatpak
Tarball (.tar.gz) Archive (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing Retouche d'images
vectorcraft Vector illustration Illustration vectorielle
filmcraft Video editing, color and sound Montage vidéo, étalonnage et son
lightcraft Photo library and raw development Photothèque et développement RAW
pdfcraft Reading, organizing and protecting PDFs Lecture, organisation et protection de PDF
effectcraft Motion graphics and visual effects Motion design et effets visuels
designcraft Page layout and publishing Mise en page et édition
wordcraft Writing and document design Rédaction et mise en forme de documents
deckcraft Presentations and slide shows Présentations et diaporamas
gridcraft Spreadsheets and data Tableurs et données
cadcraft Computer-aided design and drafting Conception et dessin assistés par ordinateur
soundcraft Recording, editing and mixing audio Enregistrement, édition et mixage audio
figcraft Interface design and prototyping Design d'interfaces et prototypage
# Contextual: month abbreviations for date rendering
month Jan janv.
month Feb févr.
month Mar mars
month Apr avr.
month May mai
month Jun juin
month Jul juil.
month Aug août
month Sep sept.
month Oct oct.
month Nov nov.
month Dec déc.
Not checked yet Pas encore vérifié
GitHub's hourly limit was reached; full details return in {min} min La limite horaire de GitHub a été atteinte ; les détails complets reviennent dans {min} min
GitHub limit hit; back in {min} min Limite GitHub atteinte ; retour dans {min} min
Installing for {platform} Installation pour {platform}
Downloads for {platform} Téléchargements pour {platform}
No downloads for this system Aucun téléchargement pour ce système
Join the ArtCraft community on Discord Rejoindre la communauté ArtCraft sur Discord
ArtCraft on {name} ArtCraft sur {name}
Couldn't open {name}: {e} Impossible d'ouvrir {name} : {e}
Download cancelled Téléchargement annulé
Available to install Disponible à l'installation
Available Disponible
Installed · up to date Installé · à jour
Update available Mise à jour disponible
Installed Installé
installed installé
Not available here Indisponible ici
Updating… Mise à jour…
Update paused Mise à jour en pause
Download paused Téléchargement en pause
{version} installed → {latest} released {version} installée → {latest} publiée
Version {version} Version {version}
{installed} installed · latest {latest} {installed} installée · dernière {latest}
Installed · latest {latest} Installée · dernière {latest}
Version {version} installed Version {version} installée
Version {latest} Version {latest}
No release published yet Aucune version publiée pour l'instant
ArtCraft Launcher ArtCraft Launcher
App Application
Latest Dernière
Released Publiée
Auto Auto
Language Langue
Ascending Croissant
Descending Décroissant
Click to reverse the order Cliquez pour inverser l'ordre
Click to sort by this column Cliquez pour trier selon cette colonne
Like {name} Comme {name}
{done} of {total} {done} sur {total}
Downloading Téléchargement
Verifying checksum… Vérification de la somme de contrôle…
Resume Reprendre
{done} of {total} downloaded {done} sur {total} téléchargés
{done} downloaded {done} téléchargés
Checking for updates… Recherche de mises à jour…
Downloads the new version, replaces this one and opens it again Télécharge la nouvelle version, remplace celle-ci et la rouvre
Couldn't check for updates: {f} Impossible de rechercher les mises à jour : {f}
GitHub repository Dépôt GitHub
ArtCraft ArtCraft
Update 1 app Mettre à jour 1 application
Update all ({count}) Tout mettre à jour ({count})
⊞ Grid ⊞ Grille
☰ Table ☰ Tableau
🔍 Search apps 🔍 Rechercher des applications
{name} {version} {name} {version}
Match system Comme le système
Dark Sombre
Light Clair
Full changelog Journal des modifications complet
link lien
No apps match Aucune application ne correspond
Open ArtCraft Launcher to update. Ouvrez ArtCraft Launcher pour mettre à jour.
{name} {v} is available {name} {v} est disponible
{count} updates are available {count} mises à jour sont disponibles
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Ces notes n'ont pas pu être récupérées (la limite de requêtes de GitHub a été atteinte). Elles sont sur la page de la version.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Si cette option est activée, ouvrir le launcher depuis la barre des menus amène sa fenêtre sur le bureau où vous êtes. Sinon, macOS vous renvoie sur le bureau où se trouvait la fenêtre.
Couldn't change the login item: {e} Impossible de modifier l'élément d'ouverture de session : {e}
# Contextual: table column headers
column App Application
column Installed Installée
column Latest Dernière
column Released Date de publication
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Installées
{name} details Détails de {name}
# Plural forms for French (one|other)
@plural {n} app|{n} apps {n} application|{n} applications
@plural {n} min ago|{n} min ago il y a {n} min|il y a {n} min
@plural {n} h ago|{n} h ago il y a {n} h|il y a {n} h
@plural {n} day ago|{n} days ago il y a {n} jour|il y a {n} jours
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} Impossible d'ouvrir {name} : {e}
@msg Download cancelled Téléchargement annulé
@msg {name} updated to {version} {name} mis à jour vers {version}
@msg {name} installed: {version} {name} installé : {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} mis à jour vers {version} (cette version ne publie pas de somme de contrôle : seule sa taille a été vérifiée)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} installé : {version} (cette version ne publie pas de somme de contrôle : seule sa taille a été vérifiée)
@msg {name} was removed {name} a été supprimé
@msg {name} is running. Quit it, then try again. {name} est en cours d'exécution. Quittez-le, puis réessayez.
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install Installer
@id app.update Mettre à jour vers {v}
@id app.open Ouvrir
@id app.download Télécharger pour {os}
@id app.reveal Afficher dans le Finder
@id app.reinstall Réinstaller
@id app.uninstall Désinstaller…
@id app.notes Notes de version
@id app.website Site web
@id app.github Dépôt GitHub
@id app.releases Toutes les versions
@id launcher.update Mettre à jour et redémarrer
@id launcher.download Télécharger
@id launcher.notes Nouveautés
@id launcher.dismiss Ignorer
@id settings.open Réglages
@id settings.quit Quitter ArtCraft Launcher
@id view.grid Grille
@id view.table Tableau
@id filter.all Toutes
@id filter.installed Installées
@id filter.updates Mises à jour
@id filter.notinstalled Non installées
@id sort.suite Ordre de la suite
@id sort.name Nom
@id sort.inspired Inspirée de
@id sort.status État
@id sort.installed Version installée
@id sort.latest Dernière version
@id sort.released Version la plus récente
@id help.discord Discord
@id help.github GitHub
@id help.website Site web
@id update.check Rechercher des mises à jour
@id update.check_auto Rechercher automatiquement les mises à jour
@id update.notify Me prévenir quand une application installée a une mise à jour
@id update.prereleases Inclure les préversions (bêtas et versions candidates)
@id linux.preferred_kind Installer les nouvelles applications en tant que
@id tray.close_to_tray Rester actif dans la zone de notification à la fermeture de la fenêtre
@id tray.show_on_current_desktop Afficher la fenêtre sur le bureau actuel
@id tray.login_item Lancer ArtCraft Launcher à l'ouverture de session
@id appearance.theme Apparence
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher Afficher ArtCraft Launcher
Check for Updates Rechercher des mises à jour
Visit getartcraft.com Visiter getartcraft.com
ArtCraft on GitHub ArtCraft sur GitHub
Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft
App details Détails de l'application
Close (Esc) Fermer (Esc)
Features Fonctionnalités
Latest release Dernière version
Version Version
Platforms Plateformes
Releases Versions
Enlarge picture Agrandir l'image
Click to enlarge Cliquer pour agrandir
Previous picture (←) Image précédente (←)
Next picture (→) Image suivante (→)
Loading pictures… Chargement des images…
Pictures couldn't be loaded. Reopen this page to try again. Impossible de charger les images. Rouvrir la page permet de réessayer.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} mise à jour disponible|ArtCraft Launcher · {n} mises à jour disponibles
Can't render this file because it contains an unexpected character in line 5 and column 50.

View File

@ -1,347 +0,0 @@
# Hungarian (hu) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (English and Hungarian).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Hungarian,
# using the terms Hungarian-speaking users of creative software know. The user is addressed with
# the informal "te", as in most current creative and community software.
#
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite",
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Nyílt forráskódú eszközök alkotóknak, barkácsolóknak és fejlesztőknek
Settings Beállítások
Check for updates Frissítések keresése
Grid Rács
Table Táblázat
Show apps as cards Alkalmazások megjelenítése kártyákként
Show apps as a table Alkalmazások megjelenítése táblázatként
All Összes
Updates Frissítések
Not installed Nincs telepítve
Search apps Alkalmazások keresése
Sort Rendezés
Suite order Alapértelmezett sorrend
Name Név
Inspired by Mintául szolgált
Status Állapot
Installed version Telepített verzió
Latest version Legújabb verzió
Newest release Legújabb kiadás
Discord Discord
GitHub GitHub
Website Webhely
Classic apps Eredeti alkalmazások
New apps Új alkalmazások
NEW ÚJ
Install Telepítés
Update to {v} Frissítés: {v}
Open Megnyitás
Download for {os} Letöltés {os} rendszerre
Download Letöltés
Coming soon Hamarosan
Checking… Ellenőrzés…
Not available Nem elérhető
Offline Nincs kapcsolat
Downloading {done} of {total} Letöltés: {done} / {total}
Downloading… Letöltés…
Reconnecting… Újracsatlakozás…
Verifying… Fájl ellenőrzése…
Installing, then restarting… Telepítés, majd újraindítás…
Installing… Telepítés…
Removing… Eltávolítás…
Pause download Letöltés szüneteltetése
Cancel download Letöltés megszakítása
Resume download Letöltés folytatása
Discard download Letöltött fájl törlése
Release notes Kiadási információk
Show in Finder Megjelenítés a Finderben
Show in Explorer Megjelenítés a Fájlkezelőben
Show in Files Megjelenítés a fájlkezelőben
Reinstall Újratelepítés
Uninstall… Eltávolítás…
All releases Minden kiadás
ArtCraft Launcher {version} is available. Elérhető az ArtCraft Launcher új verziója: {version}.
Dismiss Elrejtés
Update and restart Frissítés és újraindítás
A new version is available. Would you like to relaunch with the updated version? Új verzió érhető el. Újraindítod az alkalmazást a frissítéshez?
Later Később
What's new Újdonságok
No notes were published with this release. Ehhez a kiadáshoz nem tettek közzé kiadási információkat.
Released {date} Megjelent: {date}
Open on GitHub Megnyitás a GitHubon
Close Bezárás
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Csak kézi kereséskor
Automatically download and install updates Frissítések automatikus letöltése és telepítése
Notify me when an installed app has an update Értesítés a telepített alkalmazásokhoz elérhető frissítésekről
Include pre-releases (betas and release candidates) Előzetes kiadások engedélyezése (béta- és kiadásra jelölt verziók)
Installing on Linux Telepítés Linuxon
Install new apps as Új alkalmazások csomagformátuma
System package (recommended) Rendszercsomag (ajánlott)
Updates always use the format an app was installed with. A frissítések mindig az eredeti telepítés csomagformátumát használják.
Running in the background Futtatás a háttérben
Keep running in the tray when the window is closed Az ablak bezárásakor kerüljön a tálcára
Show the window on the current desktop Ablak megjelenítése az aktuális asztalon
Start ArtCraft Launcher when I log in ArtCraft Launcher indítása bejelentkezéskor
Community Közösség
Questions, ideas and news from the ArtCraft team Kérdések, ötletek és hírek az ArtCraft csapatától
Appearance Megjelenés
Quit ArtCraft Launcher Kilépés az ArtCraft Launcherből
Done Kész
Uninstall {name}? {name} eltávolítása?
The app will be moved to the Trash. Your documents and settings stay. Az alkalmazás a Kukába kerül. A dokumentumaid és beállításaid megmaradnak.
Windows will remove it like any installed program. Your documents and settings stay. A Windows úgy távolítja el az alkalmazást, mint bármely más telepített programot. A dokumentumaid és beállításaid megmaradnak.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Az alkalmazást a rendszer csomagkezelője távolítja el. Ehhez szükség lehet a jelszavadra. A dokumentumaid és beállításaid megmaradnak.
The app will be removed. Your documents and settings stay. Eltávolítjuk az alkalmazást. A dokumentumaid és beállításaid megmaradnak.
Uninstall Eltávolítás
Cancel Mégse
Checked {ago} Ellenőrizve: {ago}
just now épp most
yesterday tegnap
{month} {day}, {year} {year}. {month} {day}.
Automatically Automatikusan
Every Craft app Minden Craft-alkalmazás
Apps on this computer Alkalmazások ezen a számítógépen
Installed apps with a newer version Telepített alkalmazások, amelyekhez frissítés érhető el
Apps you can install Telepíthető alkalmazások
Hide App Alkalmazás elrejtése
Unhide App Alkalmazás megjelenítése
Hidden Apps Rejtett alkalmazások
Apps you hid Az általad elrejtett alkalmazások
Checked {ago} · using {sources} Ellenőrizve: {ago} · {sources}
{a} and {b} {a} és {b}
Join the Discord Csatlakozás a Discordhoz
Learn more További információ
Minimize Kis méret
Maximize Teljes méret
Restore Előző méret
New Új
OK OK
Open link Hivatkozás megnyitása
Update Frissítés
{name} icon {name} ikonja
Loading ArtCraft Launcher… ArtCraft Launcher betöltése…
Cancel update Frissítés megszakítása
Stop waiting; the update can be started again later Megszakítja a várakozást. A frissítést később újra elindíthatod.
Download again Újbóli letöltés
Install cancelled Telepítés megszakítva
Install cancelled. The download is kept for the next try. Telepítés megszakítva. A letöltött fájl megmarad a következő próbálkozáshoz.
Update cancelled Frissítés megszakítva
Uninstall cancelled Eltávolítás megszakítva
Retry install Telepítés újrapróbálása
Retry update to {version} Frissítés újrapróbálása: {version}
The download is still here. It's checked again, then installed. A letöltött fájl megmaradt. Újra ellenőrizzük, majd telepítjük.
Waiting for another app… Várakozás egy másik alkalmazásra…
Waiting for other installs… Várakozás a többi telepítésre…
# Package formats offered for download (PackageKind::label)
Installer (.msi) Telepítő (.msi)
Portable (.zip) Hordozható (.zip)
Disk image (.dmg) Lemezkép (.dmg)
Debian package (.deb) Debian-csomag (.deb)
RPM package (.rpm) RPM-csomag (.rpm)
Flatpak bundle Flatpak-csomag
Tarball (.tar.gz) Tömörített archívum (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing Képszerkesztés
vectorcraft Vector illustration Vektoros illusztráció
filmcraft Video editing, color and sound Videószerkesztés, színkorrekció és hang
lightcraft Photo library and raw development Fotók rendszerezése és RAW-előhívás
pdfcraft Reading, organizing and protecting PDFs PDF-ek olvasása, rendezése és védelme
effectcraft Motion graphics and visual effects Mozgógrafika és vizuális effektek
designcraft Page layout and publishing Oldaltervezés és kiadványszerkesztés
wordcraft Writing and document design Írás és dokumentumtervezés
deckcraft Presentations and slide shows Prezentációk és diavetítések
gridcraft Spreadsheets and data Táblázatok és adatok
cadcraft Computer-aided design and drafting Számítógépes tervezés és műszaki rajz
soundcraft Recording, editing and mixing audio Hangrögzítés, hangszerkesztés és keverés
figcraft Interface design and prototyping Felülettervezés és prototípus-készítés
# Contextual: month abbreviations for date rendering
month Jan jan.
month Feb febr.
month Mar márc.
month Apr ápr.
month May máj.
month Jun jún.
month Jul júl.
month Aug aug.
month Sep szept.
month Oct okt.
month Nov nov.
month Dec dec.
Not checked yet Még nem történt ellenőrzés
GitHub's hourly limit was reached; full details return in {min} min Elértük a GitHub óránkénti lekérdezési korlátját; a részletes adatok {min} perc múlva lesznek újra elérhetők
GitHub limit hit; back in {min} min GitHub-korlát elérve; újrapróbálás {min} perc múlva
Installing for {platform} Telepítés {platform} rendszerre
Downloads for {platform} Letöltések {platform} rendszerre
No downloads for this system Nincsenek letöltések ehhez a rendszerhez
Join the ArtCraft community on Discord Csatlakozz az ArtCraft közösségéhez a Discordon
ArtCraft on {name} ArtCraft – {name}
Couldn't open {name}: {e} {name} nem nyitható meg: {e}
Download cancelled Letöltés megszakítva
Available to install Telepíthető
Available Elérhető
Installed · up to date Telepítve · naprakész
Update available Frissítés elérhető
Installed Telepítve
installed telepítve
Not available here Ezen a rendszeren nem érhető el
Updating… Frissítés…
Update paused Frissítés szüneteltetve
Download paused Letöltés szüneteltetve
{version} installed → {latest} released Telepítve: {version} → megjelent: {latest}
Version {version} Verzió: {version}
{installed} installed · latest {latest} Telepítve: {installed} · legújabb: {latest}
Installed · latest {latest} Telepítve · legújabb: {latest}
Version {version} installed Telepített verzió: {version}
Version {latest} Verzió: {latest}
No release published yet Még nem jelent meg kiadás
ArtCraft Launcher ArtCraft Launcher
App Alkalmazás
Latest Legújabb
Released Megjelent
Auto Automatikus
Language Nyelv
Ascending Növekvő
Descending Csökkenő
Click to reverse the order Kattints a sorrend megfordításához
Click to sort by this column Kattints az oszlop szerinti rendezéshez
Like {name} Mintául szolgált: {name}
{done} of {total} {done} / {total}
Downloading Letöltés folyamatban
Verifying checksum… Ellenőrzőösszeg ellenőrzése…
Resume Folytatás
{done} of {total} downloaded {done} / {total} letöltve
{done} downloaded {done} letöltve
Checking for updates… Frissítések keresése…
Downloads the new version, replaces this one and opens it again Letölti és telepíti az új verziót, majd újraindítja az alkalmazást
Couldn't check for updates: {f} Nem sikerült frissítéseket keresni: {f}
Website (coming soon) Webhely (hamarosan)
GitHub repository GitHub-tároló
ArtCraft ArtCraft
Update 1 app 1 alkalmazás frissítése
Update all ({count}) Összes frissítése ({count})
⊞ Grid ⊞ Rács
☰ Table ☰ Táblázat
🔍 Search apps 🔍 Alkalmazások keresése
{name} {version} {name} {version}
Match system Rendszerbeállítás szerint
Dark Sötét
Light Világos
Full changelog Teljes változásnapló
link hivatkozás
No apps match Nincs találat
Open ArtCraft Launcher to update. Nyisd meg az ArtCraft Launchert a frissítéshez.
{name} {v} is available Elérhető: {name} {v}
{count} updates are available {count} frissítés elérhető
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. A kiadási információkat nem sikerült betölteni, mert elértük a GitHub lekérdezési korlátját. A kiadás oldalán megtalálod őket.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Ha be van kapcsolva, a menüsorból megnyitott ArtCraft Launcher ablaka az aktuális asztalon jelenik meg. Ha ki van kapcsolva, a macOS arra az asztalra vált, ahol az ablak található.
Couldn't change the login item: {e} Az automatikus indítás beállítását nem sikerült módosítani: {e}
# Contextual: table column headers
column App Alkalmazás
column Installed Telepített
column Latest Legújabb
column Released Dátum
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Telepítve
App details Alkalmazás részletei
Close (Esc) Bezárás (Esc)
Features Funkciók
Latest release Legújabb kiadás
Version Verzió
Platforms Platformok
Releases Kiadások
Enlarge picture Kép nagyítása
Click to enlarge Kattints a nagyításhoz
Previous picture (←) Előző kép (←)
Next picture (→) Következő kép (→)
Loading pictures… Képek betöltése…
Pictures couldn't be loaded. Reopen this page to try again. A képeket nem sikerült betölteni. Nyisd meg újra az oldalt, és újra megpróbáljuk.
{name} details {name} – részletek
# Plural forms for Hungarian (one|other). After a number the noun stays singular.
@plural {n} app|{n} apps {n} alkalmazás|{n} alkalmazás
@plural {n} min ago|{n} min ago {n} perce|{n} perce
@plural {n} h ago|{n} h ago {n} órája|{n} órája
@plural {n} day ago|{n} days ago {n} napja|{n} napja
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} {name} nem nyitható meg: {e}
@msg Download cancelled Letöltés megszakítva
@msg {name} updated to {version} {name} frissítve. Verzió: {version}
@msg {name} installed: {version} {name} telepítve: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} frissítve. Verzió: {version} (ehhez a kiadáshoz nem adtak meg ellenőrzőösszeget, ezért csak a fájl méretét ellenőriztük)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} telepítve: {version} (ehhez a kiadáshoz nem adtak meg ellenőrzőösszeget, ezért csak a fájl méretét ellenőriztük)
@msg {name} was removed {name} eltávolítva
@msg {name} is running. Quit it, then try again. {name} fut. Zárd be, majd próbáld újra.
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install Telepítés
@id app.update Frissítés: {v}
@id app.open Megnyitás
@id app.download Letöltés {os} rendszerre
@id app.reveal Megjelenítés a Finderben
@id app.reinstall Újratelepítés
@id app.uninstall Eltávolítás…
@id app.notes Kiadási információk
@id app.website Webhely
@id app.github GitHub-tároló
@id app.releases Minden kiadás
@id launcher.update Frissítés és újraindítás
@id launcher.download Letöltés
@id launcher.notes Újdonságok
@id launcher.dismiss Elrejtés
@id settings.open Beállítások
@id settings.quit Kilépés az ArtCraft Launcherből
@id view.grid Rács
@id view.table Táblázat
@id filter.all Összes
@id filter.installed Telepítve
@id filter.updates Frissítések
@id filter.notinstalled Nincs telepítve
@id sort.suite Alapértelmezett sorrend
@id sort.name Név
@id sort.inspired Mintául szolgált
@id sort.status Állapot
@id sort.installed Telepített verzió
@id sort.latest Legújabb verzió
@id sort.released Legújabb kiadás
@id help.discord Discord
@id help.github GitHub
@id help.website Webhely
@id update.check Frissítések keresése
@id update.check_auto Frissítések automatikus keresése
@id update.notify Értesítés a telepített alkalmazásokhoz elérhető frissítésekről
@id update.prereleases Előzetes kiadások engedélyezése (béta- és kiadásra jelölt verziók)
@id linux.preferred_kind Új alkalmazások csomagformátuma
@id tray.close_to_tray Az ablak bezárásakor kerüljön a tálcára
@id tray.show_on_current_desktop Ablak megjelenítése az aktuális asztalon
@id tray.login_item ArtCraft Launcher indítása bejelentkezéskor
@id appearance.theme Megjelenés
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher ArtCraft Launcher megjelenítése
Check for Updates Frissítések keresése
Visit getartcraft.com getartcraft.com megnyitása
ArtCraft on GitHub ArtCraft a GitHubon
Join the ArtCraft Discord Csatlakozás az ArtCraft Discord-közösségéhez
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} frissítés elérhető|ArtCraft Launcher · {n} frissítés elérhető
Can't render this file because it contains an unexpected character in line 11 and column 16.

View File

@ -1,349 +0,0 @@
# Japanese (ja) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other in English, one form for Japanese.
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Japanese,
# using the terms Japanese-speaking users of creative software know. The user is addressed with
# the neutral polite form (です・ます), as in most current Japanese software.
#
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite",
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
# Japanese has one plural form. Shown with the system's Japanese font (see cjk_font.rs); where the
# system has none, the interface stays in English.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders クリエイター、メイカー、ビルダーのためのオープンソースツール
Settings 設定
Check for updates 更新を確認
Grid グリッド
Table テーブル
Show apps as cards アプリをカードで表示
Show apps as a table アプリをテーブルで表示
All すべて
Updates 更新
Not installed 未インストール
Search apps アプリを検索
Sort 並べ替え
Suite order スイート順
Name 名前
Inspired by 参考アプリ
Status ステータス
Installed version インストール済みバージョン
Latest version 最新バージョン
Newest release 最新リリース
Discord Discord
GitHub GitHub
Website ウェブサイト
Classic apps クラシックアプリ
New apps 新着アプリ
NEW 新着
Install インストール
Update to {v} {v} に更新
Open 開く
Download for {os} {os} 版をダウンロード
Download ダウンロード
Coming soon 近日公開
Checking… 確認中…
Not available 利用不可
Offline オフライン
Downloading {done} of {total} ダウンロード中 {done} / {total}
Downloading… ダウンロード中…
Reconnecting… 再接続中…
Verifying… 検証中…
Installing, then restarting… インストール完了後再起動します…
Installing… インストール中…
Removing… アンインストール中…
Pause download ダウンロードを一時停止
Cancel download ダウンロードをキャンセル
Resume download ダウンロードを再開
Discard download ダウンロードを破棄
Release notes リリースノート
Show in Finder Finder で表示
Show in Explorer エクスプローラーで表示
Show in Files ファイルマネージャーで表示
Reinstall 再インストール
Uninstall… アンインストール…
All releases すべてのリリース
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} が利用可能です。
Dismiss 閉じる
Update and restart 更新して再起動
A new version is available. Would you like to relaunch with the updated version? 新しいバージョンがあります。更新後のバージョンで再起動しますか?
Later 後で
What's new 新着情報
No notes were published with this release. このリリースにはリリースノートが公開されていません。
Released {date} {date} 公開
Open on GitHub GitHub で開く
Close 閉じる
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask 手動で確認したときのみ
Automatically download and install updates 更新を自動でダウンロードしてインストール
Notify me when an installed app has an update インストール済みアプリに更新があるときに通知する
Include pre-releases (betas and release candidates) プレリリース(ベータ版とリリース候補)を含める
Installing on Linux Linux へのインストール
Install new apps as 新しいアプリのインストール形式
System package (recommended) システムパッケージ(推奨)
Updates always use the format an app was installed with. 更新は常に、アプリをインストールしたときの形式で行われます。
Running in the background バックグラウンドで実行
Keep running in the tray when the window is closed ウィンドウを閉じてもトレイで実行し続ける
Show the window on the current desktop 現在のデスクトップにウィンドウを表示
Start ArtCraft Launcher when I log in ログイン時に ArtCraft Launcher を起動
Community コミュニティ
Questions, ideas and news from the ArtCraft team ArtCraft チームからの質問、アイデア、ニュース
Appearance 外観
Quit ArtCraft Launcher ArtCraft Launcher を終了
Done 完了
Uninstall {name}? {name} をアンインストールしますか?
The app will be moved to the Trash. Your documents and settings stay. アプリはゴミ箱に移動されます。ドキュメントと設定はそのまま残ります。
Windows will remove it like any installed program. Your documents and settings stay. Windows は他のインストール済みプログラムと同様にアンインストールします。ドキュメントと設定はそのまま残ります。
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. アプリはアンインストールされます。ドキュメントと設定はそのまま残ります。
Uninstall アンインストール
Cancel キャンセル
Checked {ago} 最終確認: {ago}
just now たった今
yesterday 昨日
{month} {day}, {year} {year}年{month}{day}日
Automatically 自動
Every Craft app すべての Craft アプリ
Apps on this computer このコンピューターのアプリ
Installed apps with a newer version 新しいバージョンがあるインストール済みアプリ
Apps you can install インストールできるアプリ
Hide App アプリを非表示
Unhide App アプリを再表示
Hidden Apps 非表示のアプリ
Apps you hid 非表示にしたアプリ
Checked {ago} · using {sources} 最終確認: {ago} · 取得元: {sources}
{a} and {b} {a} と {b}
Join the Discord Discord に参加
Learn more 詳細を見る
Minimize 最小化
Maximize 最大化
Restore 元に戻す
New 新着
OK OK
Open link リンクを開く
Update 更新
{name} icon {name} のアイコン
Loading ArtCraft Launcher… ArtCraft Launcher を読み込み中…
Cancel update 更新をキャンセル
Stop waiting; the update can be started again later 待機を中止します。更新は後で再開できます
Download again 再ダウンロード
Install cancelled インストールをキャンセルしました
Install cancelled. The download is kept for the next try. インストールをキャンセルしました。ダウンロードしたファイルは次回のために保持されます。
Update cancelled 更新をキャンセルしました
Uninstall cancelled アンインストールをキャンセルしました
Retry install インストールを再試行
Retry update to {version} {version} への更新を再試行
The download is still here. It's checked again, then installed. ダウンロードしたファイルはそのまま残っています。もう一度検証してからインストールします。
Waiting for another app… 他のアプリを待機中…
Waiting for other installs… 他のインストールを待機中…
# Package formats offered for download (PackageKind::label)
Installer (.msi) インストーラー (.msi)
Portable (.zip) ポータブル版 (.zip)
Disk image (.dmg) ディスクイメージ (.dmg)
Debian package (.deb) Debian パッケージ (.deb)
RPM package (.rpm) RPM パッケージ (.rpm)
Flatpak bundle Flatpak バンドル
Tarball (.tar.gz) tarball (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing 画像編集
vectorcraft Vector illustration ベクターイラスト
filmcraft Video editing, color and sound 動画編集、カラー、サウンド
lightcraft Photo library and raw development フォトライブラリと RAW 現像
pdfcraft Reading, organizing and protecting PDFs PDF の閲覧、整理、保護
effectcraft Motion graphics and visual effects モーショングラフィックスと視覚効果
designcraft Page layout and publishing ページレイアウトと出版
wordcraft Writing and document design 文書作成とデザイン
deckcraft Presentations and slide shows プレゼンテーションとスライドショー
gridcraft Spreadsheets and data 表計算とデータ
cadcraft Computer-aided design and drafting コンピューター支援設計と製図
soundcraft Recording, editing and mixing audio オーディオの録音、編集、ミックス
figcraft Interface design and prototyping インターフェースデザインとプロトタイピング
# Contextual: month abbreviations for date rendering
month Jan 1月
month Feb 2月
month Mar 3月
month Apr 4月
month May 5月
month Jun 6月
month Jul 7月
month Aug 8月
month Sep 9月
month Oct 10月
month Nov 11月
month Dec 12月
Not checked yet まだ確認していません
GitHub's hourly limit was reached; full details return in {min} min GitHub の時間あたりの上限に達しました。{min} 分後に詳細が表示されます
GitHub limit hit; back in {min} min GitHub の上限に達しました。{min} 分後に復旧します
Installing for {platform} {platform} にインストール
Downloads for {platform} {platform} 用のダウンロード
No downloads for this system このシステム用のダウンロードはありません
Join the ArtCraft community on Discord Discord で ArtCraft コミュニティに参加
ArtCraft on {name} {name} の ArtCraft
Couldn't open {name}: {e} {name} を開けませんでした: {e}
Download cancelled ダウンロードをキャンセルしました
Available to install インストール可能
Available 利用可能
Installed · up to date インストール済み · 最新
Update available 更新あり
Installed インストール済み
installed インストール済み
Not available here このプラットフォームでは利用不可
Updating… 更新中…
Update paused 更新を一時停止
Download paused ダウンロードを一時停止
{version} installed → {latest} released {version} インストール済み → {latest} 公開
Version {version} バージョン {version}
{installed} installed · latest {latest} {installed} インストール済み · 最新 {latest}
Installed · latest {latest} インストール済み · 最新 {latest}
Version {version} installed バージョン {version} インストール済み
Version {latest} バージョン {latest}
No release published yet まだリリースがありません
ArtCraft Launcher ArtCraft Launcher
App アプリ
Latest 最新
Released 公開日
Auto 自動
Language 言語
Ascending 昇順
Descending 降順
Click to reverse the order クリックして順序を反転
Click to sort by this column クリックしてこの列で並べ替え
Like {name} {name} と同様
{done} of {total} {done} / {total}
Downloading ダウンロード中
Verifying checksum… チェックサムを検証中…
Resume 再開
{done} of {total} downloaded {done} / {total} ダウンロード済み
{done} downloaded {done} ダウンロード済み
Checking for updates… 更新を確認中…
Downloads the new version, replaces this one and opens it again 新しいバージョンをダウンロードし、現在のバージョンと置き換えて再び開きます
Couldn't check for updates: {f} 更新を確認できませんでした: {f}
Website (coming soon) ウェブサイト(近日公開)
GitHub repository GitHub リポジトリ
ArtCraft ArtCraft
Update 1 app 1 個のアプリを更新
Update all ({count}) すべて更新({count})
⊞ Grid ⊞ グリッド
☰ Table ☰ テーブル
🔍 Search apps 🔍 アプリを検索
{name} {version} {name} {version}
Match system システムに合わせる
Dark ダーク
Light ライト
Full changelog 完全な変更履歴
link リンク
No apps match 一致するアプリがありません
Open ArtCraft Launcher to update. 更新するには ArtCraft Launcher を開いてください。
{name} {v} is available {name} {v} が利用可能です
{count} updates are available {count} 件の更新があります
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. このリリースノートは取得できませんでした(GitHub のリクエスト上限に達しました)。リリースページにあります。
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. オンにすると、メニューバーからランチャーを開いたときにウィンドウが現在のデスクトップに表示されます。オフにすると、macOS はウィンドウがあったデスクトップに切り替えます。
Couldn't change the login item: {e} ログイン項目を変更できませんでした: {e}
# Contextual: table column headers
column App アプリ
column Installed インストール済みバージョン
column Latest 最新バージョン
column Released 公開日
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed インストール済み
App details アプリの詳細
Close (Esc) 閉じる (Esc)
Features 機能
Latest release 最新リリース
Version バージョン
Platforms 対応プラットフォーム
Releases リリース
Enlarge picture 画像を拡大
Click to enlarge クリックして拡大
Previous picture (←) 前の画像 (←)
Next picture (→) 次の画像 (→)
Loading pictures… 画像を読み込み中…
Pictures couldn't be loaded. Reopen this page to try again. 画像を読み込めませんでした。このページを開き直すと再試行します。
{name} details {name} の詳細
# Plural forms for Japanese (one form: Japanese nouns don't inflect for number)
@plural {n} app|{n} apps {n} 個のアプリ
@plural {n} min ago|{n} min ago {n} 分前
@plural {n} h ago|{n} h ago {n} 時間前
@plural {n} day ago|{n} days ago {n} 日前
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} {name} を開けませんでした: {e}
@msg Download cancelled ダウンロードをキャンセルしました
@msg {name} updated to {version} {name} を {version} に更新しました
@msg {name} installed: {version} {name} をインストールしました: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} を {version} に更新しました(このリリースはチェックサムを公開していないため、サイズのみ確認しました)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} をインストールしました: {version}(このリリースはチェックサムを公開していないため、サイズのみ確認しました)
@msg {name} was removed {name} をアンインストールしました
@msg {name} is running. Quit it, then try again. {name} が実行中です。終了してから再試行してください。
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install インストール
@id app.update {v} に更新
@id app.open 開く
@id app.download {os} 版をダウンロード
@id app.reveal Finder で表示
@id app.reinstall 再インストール
@id app.uninstall アンインストール…
@id app.notes リリースノート
@id app.website ウェブサイト
@id app.github GitHub リポジトリ
@id app.releases すべてのリリース
@id launcher.update 更新して再起動
@id launcher.download ダウンロード
@id launcher.notes 新着情報
@id launcher.dismiss 閉じる
@id settings.open 設定
@id settings.quit ArtCraft Launcher を終了
@id view.grid グリッド
@id view.table テーブル
@id filter.all すべて
@id filter.installed インストール済み
@id filter.updates 更新
@id filter.notinstalled 未インストール
@id sort.suite スイート順
@id sort.name 名前
@id sort.inspired 参考アプリ
@id sort.status ステータス
@id sort.installed インストール済みバージョン
@id sort.latest 最新バージョン
@id sort.released 最新リリース
@id help.discord Discord
@id help.github GitHub
@id help.website ウェブサイト
@id update.check 更新を確認
@id update.check_auto 自動で更新を確認
@id update.notify インストール済みアプリに更新があるときに通知する
@id update.prereleases プレリリース(ベータ版とリリース候補)を含める
@id linux.preferred_kind 新しいアプリのインストール形式
@id tray.close_to_tray ウィンドウを閉じてもトレイで実行し続ける
@id tray.show_on_current_desktop 現在のデスクトップにウィンドウを表示
@id tray.login_item ログイン時に ArtCraft Launcher を起動
@id appearance.theme 外観
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher ArtCraft Launcher を表示
Check for Updates 更新を確認
Visit getartcraft.com getartcraft.com を開く
ArtCraft on GitHub GitHub の ArtCraft
Join the ArtCraft Discord ArtCraft Discord に参加
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} 件の更新があります
Can't render this file because it contains an unexpected character in line 14 and column 44.

View File

@ -1,344 +0,0 @@
//! The language registry and matching locale tags to it.
use std::sync::OnceLock;
use super::catalog::Catalog;
use super::detect::system_lang;
/// The writing system a language is drawn in, and so the system font it needs. Languages that
/// share one can share a face; Simplified and Traditional Chinese do not (they want different
/// faces), so each script is its own. The bundled faces have no CJK glyphs, so a language with a
/// script borrows a system font for it (`crate::theme::cjk_font`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Script {
Chinese,
Japanese,
}
impl Script {
/// Every script a language can be written in.
pub const ALL: [Script; 2] = [Script::Chinese, Script::Japanese];
}
/// One supported UI language.
pub struct LangInfo {
/// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`).
/// Also the `interfaceLanguage` value.
pub code: &'static str,
/// The language's name in itself, shown in the Preferences dropdown.
pub name: &'static str,
/// The language's name in English, shown instead where its own script can't be drawn.
pub english_name: &'static str,
/// The writing system this language is drawn in, or `None` if it needs no system CJK font.
pub script: Option<Script>,
/// Catalog file contents (empty for the built-in English).
pub source: &'static str,
/// Plural form index for a count (English, German, Spanish and Hungarian: 0 = one, 1 = other;
/// Russian: 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only
/// for 1, 1 = few, 2 = many; French: 0 = one for 0 and 1; a language whose nouns don't inflect
/// for number: always 0).
pub plural: fn(u64) -> usize,
/// Number of plural forms the catalog's `@plural` rows carry (checked by tests).
pub plural_forms: usize,
/// Must the catalog cover every `tl!` literal? (checked by tests)
pub complete_menus: bool,
catalog: OnceLock<Catalog>,
}
fn plural_one_other(n: u64) -> usize {
usize::from(n != 1)
}
/// French: 0 and 1 are singular ("0 mise à jour"), everything else plural.
fn plural_zero_one(n: u64) -> usize {
usize::from(n > 1)
}
/// A language whose nouns don't inflect for number: one form for every count.
fn plural_none(_: u64) -> usize {
0
}
/// Russian and Ukrainian integer counts: 1 (but not 11) is `one`, 2–4 (but not 12–14)
/// `few`, everything else `many`.
pub(super) fn plural_east_slavic(n: u64) -> usize {
match n % 100 {
11..=14 => 2,
_ => match n % 10 {
1 => 0,
2..=4 => 1,
_ => 2,
},
}
}
/// Czech integer counts: 1 is `one`, 2–4 are `few`, everything else is `other`.
pub(super) fn plural_czech(n: u64) -> usize {
match n {
1 => 0,
2..=4 => 1,
_ => 2,
}
}
/// Polish integer counts: only 1 itself is `one` (21 is "21 aplikacji"), 2–4 in the last digit
/// (but not 12–14) `few`, everything else `many`.
pub(super) fn plural_polish(n: u64) -> usize {
if n == 1 {
0
} else if matches!(n % 10, 2..=4) && !matches!(n % 100, 12..=14) {
1
} else {
2
}
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 11] = [
LangInfo {
code: "en",
name: "English",
english_name: "English",
script: None,
source: "",
plural: plural_one_other,
plural_forms: 2,
complete_menus: false,
catalog: OnceLock::new(),
},
LangInfo {
code: "ru",
name: "Русский",
english_name: "Russian",
script: None,
source: include_str!("ru.tsv"),
plural: plural_east_slavic,
plural_forms: 3,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "zh-CN",
name: "简体中文",
english_name: "Simplified Chinese",
script: Some(Script::Chinese),
source: include_str!("zh-CN.tsv"),
plural: plural_none,
plural_forms: 1,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "de",
name: "Deutsch",
english_name: "German",
script: None,
source: include_str!("de.tsv"),
plural: plural_one_other,
plural_forms: 2,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "fr",
name: "Français",
english_name: "French",
script: None,
source: include_str!("fr.tsv"),
plural: plural_zero_one,
plural_forms: 2,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "pt-BR",
name: "Português (Brasil)",
english_name: "Portuguese (Brazil)",
script: None,
source: include_str!("pt-BR.tsv"),
plural: plural_one_other,
plural_forms: 2,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "es",
name: "Español",
english_name: "Spanish",
script: None,
source: include_str!("es.tsv"),
plural: plural_one_other,
plural_forms: 2,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "cs",
name: "Čeština",
english_name: "Czech",
script: None,
source: include_str!("cs.tsv"),
plural: plural_czech,
plural_forms: 3,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "pl",
name: "Polski",
english_name: "Polish",
script: None,
source: include_str!("pl.tsv"),
plural: plural_polish,
plural_forms: 3,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "hu",
name: "Magyar",
english_name: "Hungarian",
script: None,
source: include_str!("hu.tsv"),
plural: plural_one_other,
plural_forms: 2,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "ja",
name: "日本語",
english_name: "Japanese",
script: Some(Script::Japanese),
source: include_str!("ja.tsv"),
plural: plural_none,
plural_forms: 1,
complete_menus: true,
catalog: OnceLock::new(),
},
];
impl LangInfo {
pub(super) fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| Catalog::parse(self.source))
}
}
/// A language the UI can be shown in (a handle into [`LANGUAGES`]).
#[derive(Clone, Copy)]
pub struct Lang(pub(super) &'static LangInfo);
impl std::fmt::Debug for Lang {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Lang({})", self.0.code)
}
}
impl PartialEq for Lang {
fn eq(&self, other: &Self) -> bool {
self.0.code == other.0.code
}
}
impl Eq for Lang {}
impl Lang {
pub const EN: Lang = Lang(&LANGUAGES[0]);
pub const RU: Lang = Lang(&LANGUAGES[1]);
pub const ZH_CN: Lang = Lang(&LANGUAGES[2]);
pub const DE: Lang = Lang(&LANGUAGES[3]);
pub const FR: Lang = Lang(&LANGUAGES[4]);
pub const PT_BR: Lang = Lang(&LANGUAGES[5]);
pub const ES: Lang = Lang(&LANGUAGES[6]);
pub const CS: Lang = Lang(&LANGUAGES[7]);
pub const PL: Lang = Lang(&LANGUAGES[8]);
pub const HU: Lang = Lang(&LANGUAGES[9]);
pub const JA: Lang = Lang(&LANGUAGES[10]);
pub fn code(self) -> &'static str {
self.0.code
}
/// A language by its exact code (case-insensitive).
pub fn from_code(code: &str) -> Option<Lang> {
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
}
/// Resolve the `interfaceLanguage` preference: a language code, or `auto` (and anything
/// unknown) to follow the system locale.
pub fn from_pref(pref: &str) -> Lang {
Lang::from_code(pref).unwrap_or_else(system_lang)
}
/// Every registered language.
pub fn all() -> impl Iterator<Item = Lang> {
LANGUAGES.iter().map(Lang)
}
pub fn name(self) -> &'static str {
self.0.name
}
/// The language's name in English.
pub fn english_name(self) -> &'static str {
self.0.english_name
}
/// Does drawing this language need a CJK font from the system?
pub fn needs_cjk_font(self) -> bool {
self.0.script.is_some()
}
/// The writing system this language is drawn in, if it needs a system CJK font.
pub fn script(self) -> Option<Script> {
self.0.script
}
/// Does this language's catalog claim to cover every `tl!` literal?
pub fn complete_menus(self) -> bool {
self.0.complete_menus
}
/// Index into [`LANGUAGES`].
pub(super) fn index(self) -> usize {
LANGUAGES.iter().position(|l| l.code == self.0.code).unwrap_or(0)
}
pub(super) fn catalog(self) -> &'static Catalog {
self.0.catalog()
}
}
/// Candidate language codes for a locale tag, most specific first: `ru_RU.UTF-8` →
/// `ru-ru`, `ru`.
pub(super) fn candidates(tag: &str) -> Vec<String> {
let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase();
let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();
(1..=parts.len()).rev().filter_map(|n| parts.get(..n)).map(|p| p.join("-")).collect()
}
/// The registered language for a locale tag such as `ru_RU.UTF-8`, `ru-RU`, `zh-Hans-CN`;
/// `None` if the language isn't supported. `C`/`POSIX` mean English.
pub fn lang_from_tag(tag: &str) -> Option<Lang> {
let cands = candidates(tag);
match cands.last().map(String::as_str) {
Some("c" | "posix") => Some(Lang::EN),
Some("zh") => chinese(cands.first().map_or("zh", String::as_str)),
Some("pt") => Some(Lang::PT_BR),
_ => cands.iter().find_map(|c| Lang::from_code(c)),
}
}
/// Chinese by script: Simplified for `zh-Hans`, and for a bare `zh` or the regions that write
/// Simplified (mainland China, Singapore) when no script is given. An explicit script wins over
/// the region (`zh-Hant-CN` is Traditional); Traditional Chinese isn't offered.
fn chinese(tag: &str) -> Option<Lang> {
let parts: Vec<&str> = tag.split('-').collect();
if parts.contains(&"hant") {
None
} else if parts.contains(&"hans") || parts.len() == 1 || parts.iter().any(|p| matches!(*p, "cn" | "sg")) {
Some(Lang::ZH_CN)
} else {
None
}
}

View File

@ -1,39 +0,0 @@
//! UI localisation for ArtCraft Launcher. Strings in code stay English and are the default
//! lookup keys; a per-language catalog (`*.tsv`) maps them to display text at render time.
//!
//! # Adding a language
//! 1. Add `xx.tsv` next to `ru.tsv` (copy its header; translate from the *meaning* of the
//! English text, clean-room).
//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule). For a complete
//! catalog, cover every UI literal and plural key; the tests enforce that claim. A translation
//! that isn't finished yet starts with `complete_menus: false`: it still shows in
//! Preferences, strings without a row fall back to English, and the completeness tests
//! skip it until every row is done. A CJK language also sets `script` and takes its system
//! font from `crate::cjk_font`.
//!
//! That is all: the Preferences dropdown, the system-locale match and the catalog tests
//! (parse, placeholders, plural forms, coverage of every `tl!` literal) pick it up from the
//! registry.
//!
//! # Looking strings up
//! - [`t`] / the `tl!` macro: a plain string in the language the UI is drawn in.
//! [`tr`] takes the language explicitly; [`tr_ctx`] when one English word needs different
//! translations.
//! - [`tr_id`]: a command-id keyed string with the English label as fallback (menu items).
//! - [`trn`]: plural-aware (`{n}` is filled in). [`fmt`]: fill `{name}` placeholders after [`tr`].
//! - [`message`] / [`msg`]: a status or error message, translated only where it is shown.
//! The message itself stays English (the control channel, tests read it); `@msg` catalog
//! rows hold whole messages or templates such as `Couldn't open {name}: {e}`, whose
//! placeholders match any text and are carried over into the translation.
mod catalog;
mod detect;
mod lang;
mod translate;
#[cfg(test)]
mod tests;
pub use detect::{detect_system_lang_in_background, set_system_locales, set_test_lang, system_lang};
pub use lang::{LANGUAGES, Lang, LangInfo, Script, lang_from_tag};
pub use translate::{current, fmt, has, message, msg, set_current, t, t_id, tn, tr, tr_ctx, tr_id, trn};

View File

@ -1,348 +0,0 @@
# Polish (pl) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (English) or one|few|many (Polish).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Polish,
# using the terms Polish users of creative and system software know. The user is addressed
# informally ("twoje", "cię"), as in Polish system interfaces.
#
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft,
# PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub",
# "Discord".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Narzędzia open source dla artystów, twórców i konstruktorów
Settings Ustawienia
Check for updates Sprawdź dostępność aktualizacji
Grid Siatka
Table Tabela
Show apps as cards Wyświetlaj listę jako karty
Show apps as a table Wyświetlaj listę jako tabelę
All Wszystkie
Updates Aktualizacje
Not installed Niezainstalowane
Search apps Wyszukaj aplikacje
Sort Sortowanie
Suite order Domyślna kolejność
Name Nazwa
Inspired by Wzorowane na
Status Status
Installed version Zainstalowana wersja
Latest version Najnowsza wersja
Newest release Najnowsze wydanie
Discord Discord
GitHub GitHub
Website Strona
Classic apps Klasyczne aplikacje
New apps Nowe aplikacje
Classic Klasyk
NEW NOWE
Install Instaluj
Update to {v} Zaktualizuj do {v}
Open Otwórz
Download for {os} Pobierz dla {os}
Download Pobierz
Coming soon Wkrótce
Checking… Sprawdzanie…
Not available Niedostępna
Offline Offline
Downloading {done} of {total} Pobrano {done} z {total}
Downloading… Pobieranie…
Reconnecting… Wznawianie połączenia…
Verifying… Weryfikowanie…
Installing, then restarting… Instalowanie a następnie ponowne uruchomienie…
Installing… Instalowanie…
Removing… Usuwanie…
Pause download Wstrzymaj pobieranie
Cancel download Anuluj pobieranie
Resume download Wznów pobieranie
Discard download Usuń pobrane
Release notes Informacje o wydaniu
Show in Finder Pokaż w Finderze
Show in Explorer Pokaż w Eksploratorze plików
Show in Files Pokaż w menedżerze plików
Reinstall Przeinstaluj
Uninstall… Odinstaluj…
All releases Wszystkie wydania
ArtCraft Launcher {version} is available. Dostępna jest nowa wersja ArtCraft Launcher ({version}).
Dismiss Zamknij
Update and restart Zaktualizuj i zrestartuj
A new version is available. Would you like to relaunch with the updated version? Nowa wersja jest dostępna. Czy chcesz uruchomić ponownie ze zaktualizowaną wersją
Later Później
What's new Co nowego
No notes were published with this release. Dla tego wydania nie opublikowano żadnych notatek.
Released {date} Wydano {date}
Open on GitHub Otwórz na GitHubie
Close Zamknij
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Tylko kiedy zapytam
Automatically download and install updates Automatycznie pobieraj i instaluj aktualizacje
Notify me when an installed app has an update Powiadom mnie o aktualizacjach dla zainstalowanych aplikacji
Include pre-releases (betas and release candidates) Uwzględnij wersje przedpremierowe (beta i wersje kandydujące)
Installing on Linux Instalowanie w systemie Linux
Install new apps as Instaluj nowe aplikacje jako
System package (recommended) Pakiet systemowy (zalecany)
Updates always use the format an app was installed with. Aktualizacje zawsze używają formatu, w jakim pierwotnie zainstalowano aplikację.
Running in the background Działanie w tle
Keep running in the tray when the window is closed Po zamknięciu okna działaj w zasobniku systemowym
Show the window on the current desktop Pokaż okno na bieżącym pulpicie
Start ArtCraft Launcher when I log in Uruchom ArtCraft Launcher po zalogowaniu
Community Społeczność
Questions, ideas and news from the ArtCraft team Pytania, pomysły i nowości od zespołu ArtCraft
Appearance Motyw
Quit ArtCraft Launcher Wyjdź z ArtCraft Launcher
Done Gotowe
Uninstall {name}? Odinstalować {name}?
The app will be moved to the Trash. Your documents and settings stay. Aplikacja zostanie przeniesiona do kosza. Twoje dokumenty i ustawienia pozostaną.
Windows will remove it like any installed program. Your documents and settings stay. Windows usunie to jak normalny zainstalowany program. Twoje dokumenty i ustawienia pozostaną.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Twój systemowy menedżer pakietów usunie aplikację, może też zapytać cię o hasło. Twoje dokumenty i ustawienia pozostaną.
The app will be removed. Your documents and settings stay. Ta aplikacja zostanie usunięta. Twoje dokumenty i ustawienia pozostaną.
Uninstall Odinstaluj
Cancel Anuluj
Checked {ago} Ostatnio sprawdzono {ago}
just now przed chwilą
yesterday wczoraj
{month} {day}, {year} {day} {month} {year}
Automatically Automatycznie
Every Craft app Wszystkie aplikacje Craft
Apps on this computer Aplikacje na tym komputerze
Installed apps with a newer version Zainstalowane aplikacje z nowszą wersją
Apps you can install Aplikacje, które możesz zainstalować
Hide App Ukryj aplikację
Unhide App Pokaż aplikację
Hidden Apps Ukryte aplikacje
Apps you hid Aplikacje ukryte przez ciebie
Checked {ago} · using {sources} Ostatnio sprawdzono {ago} · przy użyciu {sources}
{a} and {b} {a} i {b}
Join the Discord Dołącz na Discorda
Learn more Więcej
Minimize Minimalizuj
Maximize Maksymalizuj
Restore Przywróć
New Nowe
OK OK
Open link Otwórz link
Update Aktualizacja
{name} icon Ikona {name}
Loading ArtCraft Launcher… Ładowanie ArtCraft Launcher…
Cancel update Anuluj aktualizację
Stop waiting; the update can be started again later Przerwij oczekiwanie; aktualizację można uruchomić ponownie później
Download again Pobierz ponownie
Install cancelled Anulowano instalację
Install cancelled. The download is kept for the next try. Anulowano instalację. Pobrany plik zostanie zachowany na następną próbę.
Update cancelled Anulowano aktualizację
Uninstall cancelled Anulowano odinstalowanie
Retry install Ponów instalację
Retry update to {version} Ponów aktualizację do {version}
The download is still here. It's checked again, then installed. Pobrany plik wciąż tu jest. Zostanie sprawdzony ponownie, a następnie zainstalowany.
Waiting for another app… Oczekiwanie na inną aplikację…
Waiting for other installs… Oczekiwanie na inne instalacje…
# Package formats offered for download (PackageKind::label)
Installer (.msi) Instalator (.msi)
Portable (.zip) Wersja przenośna (.zip)
Disk image (.dmg) Obraz dysku (.dmg)
Debian package (.deb) Pakiet Debiana (.deb)
RPM package (.rpm) Pakiet RPM (.rpm)
Flatpak bundle Pakiet Flatpak
Tarball (.tar.gz) Archiwum (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing Edycja zdjęć
vectorcraft Vector illustration Ilustracja wektorowa
filmcraft Video editing, color and sound Montaż wideo, kolor i dźwięk
lightcraft Photo library and raw development Biblioteka zdjęć i obróbka RAW
pdfcraft Reading, organizing and protecting PDFs Czytanie, porządkowanie i zabezpieczanie plików PDF
effectcraft Motion graphics and visual effects Grafika ruchoma i efekty wizualne
designcraft Page layout and publishing Skład stron i publikowanie
wordcraft Writing and document design Pisanie i projektowanie dokumentów
deckcraft Presentations and slide shows Prezentacje i pokazy slajdów
gridcraft Spreadsheets and data Arkusze kalkulacyjne i dane
cadcraft Computer-aided design and drafting Projektowanie i kreślenie wspomagane komputerowo
soundcraft Recording, editing and mixing audio Nagrywanie, edycja i miksowanie dźwięku
figcraft Interface design and prototyping Projektowanie interfejsów i prototypowanie
# Contextual: month abbreviations for date rendering
month Jan sty
month Feb lut
month Mar mar
month Apr kwi
month May maj
month Jun cze
month Jul lip
month Aug sie
month Sep wrz
month Oct paź
month Nov lis
month Dec gru
Not checked yet Jeszcze nie sprawdzono
GitHub's hourly limit was reached; full details return in {min} min Osiągnięto godzinowy limit GitHuba; pełne informacje wrócą za {min} min
GitHub limit hit; back in {min} min Limit GitHuba osiągnięty; powrót za {min} min
Installing for {platform} Instalowanie dla {platform}
Downloads for {platform} Do pobrania dla {platform}
No downloads for this system Brak pobrań dla tego systemu
Join the ArtCraft community on Discord Dołącz do społeczności ArtCraft na Discordzie
ArtCraft on {name} ArtCraft w serwisie {name}
Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e}
Download cancelled Anulowano pobieranie
Available to install Dostępna do instalacji
Available Dostępna
Installed · up to date Zainstalowana · aktualna
Update available Dostępna aktualizacja
Installed Zainstalowana
installed zainstalowana
Not available here Niedostępna w tym systemie
Updating… Aktualizowanie…
Update paused Aktualizacja wstrzymana
Download paused Pobieranie wstrzymane
{version} installed → {latest} released {version} zainstalowana → {latest} wydana
Version {version} Wersja {version}
{installed} installed · latest {latest} {installed} zainstalowana · najnowsza {latest}
Installed · latest {latest} Zainstalowana · najnowsza {latest}
Version {version} installed Wersja {version} zainstalowana
Version {latest} Wersja {latest}
No release published yet Nie opublikowano jeszcze wydania
ArtCraft Launcher ArtCraft Launcher
App Aplikacja
Latest Najnowsza
Released Wydano
Auto Automatycznie
Language Język
Ascending Rosnąco
Descending Malejąco
Click to reverse the order Kliknij, aby odwrócić kolejność
Click to sort by this column Kliknij, aby posortować według tej kolumny
Like {name} Wzorowane na {name}
{done} of {total} {done} z {total}
Downloading Pobieranie
Verifying checksum… Weryfikowanie sumy kontrolnej…
Resume Wznów
{done} of {total} downloaded Pobrano {done} z {total}
{done} downloaded Pobrano {done}
Checking for updates… Sprawdzanie aktualizacji…
Downloads the new version, replaces this one and opens it again Pobiera nową wersję, zastępuje obecną i uruchamia ją ponownie.
Couldn't check for updates: {f} Nie udało się sprawdzić aktualizacji: {f}
Website (coming soon) Strona (wkrótce)
GitHub repository Repozytorium na GitHubie
ArtCraft ArtCraft
Update 1 app Zaktualizuj 1 aplikację
Update all ({count}) Zaktualizuj wszystkie ({count})
⊞ Grid ⊞ Siatka
☰ Table ☰ Tabela
🔍 Search apps 🔍 Wyszukaj aplikacje
{name} {version} {name} {version}
Match system Zgodnie z systemem
Dark Ciemny
Light Jasny
Full changelog Pełna lista zmian
link link
No apps match Brak pasujących aplikacji
Open ArtCraft Launcher to update. Otwórz ArtCraft Launcher, aby zaktualizować aplikacje.
{name} {v} is available Dostępna jest nowa wersja {name} {v}
{count} updates are available Dostępne aktualizacje: {count}
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Nie udało się pobrać tych notatek (osiągnięto limit zapytań GitHuba). Znajdziesz je na stronie wydania.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Gdy włączone, otwarcie launchera z paska menu przenosi jego okno na bieżący pulpit. Gdy wyłączone, macOS przełącza cię na pulpit, na którym było okno.
Couldn't change the login item: {e} Nie udało się zmienić elementu logowania: {e}
# Contextual: table column headers
column App Aplikacja
column Installed Zainstalowana
column Latest Najnowsza
column Released Data wydania
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Zainstalowane
App details Szczegóły aplikacji
Close (Esc) Zamknij (Esc)
Features Funkcje
Latest release Najnowsze wydanie
Version Wersja
Platforms Platformy
Releases Wydania
Enlarge picture Powiększ obraz
Click to enlarge Kliknij, aby powiększyć
Previous picture (←) Poprzedni obraz (←)
Next picture (→) Następny obraz (→)
Loading pictures… Wczytywanie obrazów…
Pictures couldn't be loaded. Reopen this page to try again. Nie udało się wczytać obrazów. Otwórz tę stronę ponownie, aby spróbować jeszcze raz.
{name} details {name} – szczegóły
# Plural forms for Polish (one|few|many)
@plural {n} app|{n} apps {n} aplikacja|{n} aplikacje|{n} aplikacji
@plural {n} min ago|{n} min ago {n} min temu|{n} min temu|{n} min temu
@plural {n} h ago|{n} h ago {n} godz. temu|{n} godz. temu|{n} godz. temu
@plural {n} day ago|{n} days ago {n} dzień temu|{n} dni temu|{n} dni temu
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e}
@msg Download cancelled Anulowano pobieranie
@msg {name} updated to {version} Zaktualizowano {name} do wersji {version}
@msg {name} installed: {version} Zainstalowano {name} w wersji {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Zaktualizowano {name} do wersji {version} (to wydanie nie zawiera sumy kontrolnej, więc sprawdzono tylko rozmiar)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Zainstalowano {name} w wersji {version} (to wydanie nie zawiera sumy kontrolnej, więc sprawdzono tylko rozmiar)
@msg {name} was removed Usunięto {name}
@msg {name} is running. Quit it, then try again. {name} jest uruchomiona. Zamknij ją i spróbuj ponownie.
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install Instaluj
@id app.update Zaktualizuj do {v}
@id app.open Otwórz
@id app.download Pobierz dla {os}
@id app.reveal Pokaż w Finderze
@id app.reinstall Przeinstaluj
@id app.uninstall Odinstaluj…
@id app.notes Informacje o wydaniu
@id app.website Strona
@id app.github Repozytorium na GitHubie
@id app.releases Wszystkie wydania
@id launcher.update Zaktualizuj i zrestartuj
@id launcher.download Pobierz
@id launcher.notes Co nowego
@id launcher.dismiss Zamknij
@id settings.open Ustawienia
@id settings.quit Wyjdź z ArtCraft Launcher
@id view.grid Siatka
@id view.table Tabela
@id filter.all Wszystkie
@id filter.installed Zainstalowane
@id filter.updates Aktualizacje
@id filter.notinstalled Niezainstalowane
@id sort.suite Domyślna kolejność
@id sort.name Nazwa
@id sort.inspired Wzorowane na
@id sort.status Status
@id sort.installed Zainstalowana wersja
@id sort.latest Najnowsza wersja
@id sort.released Najnowsze wydanie
@id help.discord Discord
@id help.github GitHub
@id help.website Strona
@id update.check Sprawdź dostępność aktualizacji
@id update.check_auto Automatycznie sprawdzaj dostępność aktualizacji
@id update.notify Powiadom mnie o aktualizacjach dla zainstalowanych aplikacji
@id update.prereleases Uwzględnij wersje przedpremierowe (beta i wersje kandydujące)
@id linux.preferred_kind Instaluj nowe aplikacje jako
@id tray.close_to_tray Po zamknięciu okna działaj w zasobniku systemowym
@id tray.show_on_current_desktop Pokaż okno na bieżącym pulpicie
@id tray.login_item Uruchom ArtCraft Launcher po zalogowaniu
@id appearance.theme Wygląd
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher Pokaż ArtCraft Launcher
Check for Updates Sprawdź dostępność aktualizacji
Visit getartcraft.com Odwiedź getartcraft.com
ArtCraft on GitHub ArtCraft na GitHubie
Join the ArtCraft Discord Dołącz do Discorda ArtCraft
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostępna aktualizacja|ArtCraft Launcher · {n} dostępne aktualizacje|ArtCraft Launcher · {n} dostępnych aktualizacji
Can't render this file because it contains an unexpected character in line 11 and column 15.

View File

@ -1,334 +0,0 @@
# Brazilian Portuguese (pt-BR) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other.
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Ferramentas de código aberto para criadores, artistas e desenvolvedores
Settings Configurações
Check for updates Verificar atualizações
Grid Grade
Table Tabela
Show apps as cards Mostrar aplicativos como cartões
Show apps as a table Mostrar aplicativos como tabela
All Todos
Updates Atualizações
Not installed Não instalados
Search apps Buscar aplicativos
Sort Ordenar
Suite order Ordem da suíte
Name Nome
Inspired by Inspirado em
Status Status
Installed version Versão instalada
Latest version Última versão
Newest release Lançamento mais recente
Discord Discord
GitHub GitHub
Website Site
Classic apps Aplicativos clássicos
New apps Novos aplicativos
Classic Clássico
NEW NOVO
Install Instalar
Update to {v} Atualizar para {v}
Open Abrir
Download for {os} Baixar para {os}
Download Baixar
Coming soon Em breve
Checking… Verificando…
Not available Indisponível
Offline Offline
Downloading {done} of {total} Baixando {done} de {total}
Downloading… Baixando…
Reconnecting… Reconectando…
Verifying… Verificando…
Installing, then restarting… Instalando e reiniciando…
Installing… Instalando…
Removing… Removendo…
Pause download Pausar download
Cancel download Cancelar download
Resume download Retomar download
Discard download Descartar download
Release notes Notas de lançamento
Show in Finder Mostrar no Finder
Show in Explorer Mostrar no Explorer
Show in Files Mostrar no gerenciador de arquivos
Reinstall Reinstalar
Uninstall… Desinstalar…
All releases Todos os lançamentos
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} está disponível.
Dismiss Dispensar
Update and restart Atualizar e reiniciar
A new version is available. Would you like to relaunch with the updated version? Uma nova versão está disponível. Reiniciar com a nova versão?
Later Mais tarde
What's new Novidades
No notes were published with this release. Nenhuma nota foi publicada com este lançamento.
Released {date} Lançado em {date}
Open on GitHub Abrir no GitHub
Close Fechar
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Apenas quando eu pedir
Automatically download and install updates Baixar e instalar atualizações automaticamente
Notify me when an installed app has an update Notificar quando um aplicativo instalado tiver atualização
Include pre-releases (betas and release candidates) Incluir versões de pré-lançamento (betas e RC)
Installing on Linux Instalação no Linux
Install new apps as Instalar novos aplicativos como
System package (recommended) Pacote do sistema (recomendado)
Updates always use the format an app was installed with. As atualizações sempre usam o formato com o qual o aplicativo foi instalado.
Running in the background Execução em segundo plano
Keep running in the tray when the window is closed Continuar na bandeja do sistema ao fechar a janela
Show the window on the current desktop Mostrar a janela na área de trabalho atual
Start ArtCraft Launcher when I log in Iniciar o ArtCraft Launcher ao fazer login
Community Comunidade
Questions, ideas and news from the ArtCraft team Dúvidas, ideias e novidades da equipe ArtCraft
Appearance Aparência
Quit ArtCraft Launcher Sair do ArtCraft Launcher
Done Concluído
Uninstall {name}? Desinstalar {name}?
The app will be moved to the Trash. Your documents and settings stay. O aplicativo será movido para a Lixeira. Seus documentos e configurações serão mantidos.
Windows will remove it like any installed program. Your documents and settings stay. O Windows o removerá como qualquer programa instalado. Seus documentos e configurações serão mantidos.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. O gerenciador de pacotes do sistema o removerá e poderá pedir sua senha. Seus documentos e configurações serão mantidos.
The app will be removed. Your documents and settings stay. O aplicativo será removido. Seus documentos e configurações serão mantidos.
Uninstall Desinstalar
Cancel Cancelar
Checked {ago} Verificado {ago}
just now agora mesmo
yesterday ontem
{month} {day}, {year} {day} de {month} de {year}
Automatically Automaticamente
Every Craft app Todos os aplicativos Craft
Apps on this computer Aplicativos neste computador
Installed apps with a newer version Aplicativos instalados com nova versão
Apps you can install Aplicativos que você pode instalar
Hide App Ocultar aplicativo
Unhide App Mostrar aplicativo
Hidden Apps Aplicativos ocultos
Apps you hid Aplicativos que você ocultou
Checked {ago} · using {sources} Verificado {ago} · via {sources}
{a} and {b} {a} e {b}
Join the Discord Entrar no Discord
Learn more Saiba mais
Minimize Minimizar
Maximize Maximizar
Restore Restaurar
New Novo
OK OK
Open link Abrir link
Update Atualizar
{name} icon Ícone do {name}
Loading ArtCraft Launcher… Carregando ArtCraft Launcher…
Cancel update Cancelar atualização
Stop waiting; the update can be started again later Parar de aguardar; a atualização pode ser iniciada novamente depois
Download again Baixar novamente
Install cancelled Instalação cancelada
Install cancelled. The download is kept for the next try. Instalação cancelada. O download foi mantido para a próxima tentativa.
Update cancelled Atualização cancelada
Uninstall cancelled Desinstalação cancelada
Retry install Tentar instalar novamente
Retry update to {version} Tentar atualizar novamente para {version}
The download is still here. It's checked again, then installed. O arquivo baixado ainda está aqui. Ele será verificado novamente e instalado.
Waiting for another app… Aguardando outro aplicativo…
Waiting for other installs… Aguardando outras instalações…
# Package formats offered for download (PackageKind::label)
Installer (.msi) Instalador (.msi)
Portable (.zip) Portátil (.zip)
Disk image (.dmg) Imagem de disco (.dmg)
Debian package (.deb) Pacote Debian (.deb)
RPM package (.rpm) Pacote RPM (.rpm)
Flatpak bundle Pacote Flatpak
Tarball (.tar.gz) Arquivo (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing Edição de imagens
vectorcraft Vector illustration Ilustração vetorial
filmcraft Video editing, color and sound Edição de vídeo, cor e som
lightcraft Photo library and raw development Biblioteca de fotos e revelação RAW
pdfcraft Reading, organizing and protecting PDFs Leitura, organização e proteção de PDFs
effectcraft Motion graphics and visual effects Motion graphics e efeitos visuais
designcraft Page layout and publishing Diagramação de páginas e publicação
wordcraft Writing and document design Escrita e design de documentos
deckcraft Presentations and slide shows Apresentações e slides
gridcraft Spreadsheets and data Planilhas e dados
cadcraft Computer-aided design and drafting Desenho técnico e projeto assistido por computador (CAD)
soundcraft Recording, editing and mixing audio Gravação, edição e mixagem de áudio
figcraft Interface design and prototyping Design de interfaces e prototipagem
# Contextual: month abbreviations for date rendering
month Jan jan.
month Feb fev.
month Mar mar.
month Apr abr.
month May mai.
month Jun jun.
month Jul jul.
month Aug ago.
month Sep set.
month Oct out.
month Nov nov.
month Dec dez.
Not checked yet Ainda não verificado
GitHub's hourly limit was reached; full details return in {min} min O limite por hora do GitHub foi atingido; detalhes completos voltam em {min} min
GitHub limit hit; back in {min} min Limite do GitHub atingido; volta em {min} min
Installing for {platform} Instalando para {platform}
Downloads for {platform} Downloads para {platform}
No downloads for this system Nenhum download disponível para este sistema
Join the ArtCraft community on Discord Entre na comunidade ArtCraft no Discord
ArtCraft on {name} ArtCraft no {name}
Couldn't open {name}: {e} Não foi possível abrir {name}: {e}
Download cancelled Download cancelado
Available to install Disponível para instalar
Available Disponível
Installed · up to date Instalado · atualizado
Update available Atualização disponível
Installed Instalado
installed instalado
Not available here Indisponível neste sistema
Updating… Atualizando…
Update paused Atualização pausada
Download paused Download pausado
{version} installed → {latest} released {version} instalado → {latest} lançado
Version {version} Versão {version}
{installed} installed · latest {latest} {installed} instalado · última {latest}
Installed · latest {latest} Instalado · última {latest}
Version {version} installed Versão {version} instalada
Version {latest} Versão {latest}
No release published yet Nenhum lançamento publicado ainda
ArtCraft Launcher ArtCraft Launcher
App Aplicativo
Latest Última
Released Lançado
Auto Automático
Language Idioma
Ascending Crescente
Descending Decrescente
Click to reverse the order Clique para inverter a ordem
Click to sort by this column Clique para ordenar por esta coluna
Like {name} Similar ao {name}
{done} of {total} {done} de {total}
Downloading Baixando
Verifying checksum… Conferindo a soma de verificação…
Resume Retomar
{done} of {total} downloaded {done} de {total} baixado
{done} downloaded {done} baixado
Checking for updates… Verificando atualizações…
Downloads the new version, replaces this one and opens it again Baixa a nova versão, substitui a atual e abre novamente
Couldn't check for updates: {f} Não foi possível verificar atualizações: {f}
GitHub repository Repositório no GitHub
ArtCraft ArtCraft
Update 1 app Atualizar 1 aplicativo
Update all ({count}) Atualizar todos ({count})
⊞ Grid ⊞ Grade
☰ Table ☰ Tabela
🔍 Search apps 🔍 Buscar aplicativos
{name} {version} {name} {version}
Match system Igual ao sistema
Dark Escuro
Light Claro
Full changelog Histórico completo de alterações
link link
No apps match Nenhum aplicativo encontrado
Open ArtCraft Launcher to update. Abra o ArtCraft Launcher para atualizar.
{name} {v} is available {name} {v} está disponível
{count} updates are available {count} atualizações estão disponíveis
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Estas notas não foram carregadas (o limite de requisições do GitHub foi atingido). Elas estão na página de lançamentos.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Quando ativado, abrir o launcher pela barra de menus traz a janela para a área de trabalho atual. Quando desativado, o macOS alterna para a área de trabalho onde a janela estava.
Couldn't change the login item: {e} Não foi possível alterar o item de inicialização no login: {e}
# Contextual: table column headers
column App Aplicativo
column Installed Instalada
column Latest Última
column Released Lançamento
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Instalados
{name} details Detalhes de {name}
# Plural forms for Brazilian Portuguese (one|other)
@plural {n} app|{n} apps {n} aplicativo|{n} aplicativos
@plural {n} min ago|{n} min ago há {n} min|há {n} min
@plural {n} h ago|{n} h ago há {n} h|há {n} h
@plural {n} day ago|{n} days ago há {n} dia|há {n} dias
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} Não foi possível abrir {name}: {e}
@msg Download cancelled Download cancelado
@msg {name} updated to {version} {name} atualizado para {version}
@msg {name} installed: {version} {name} instalado: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} atualizado para {version} (este lançamento não publica soma de verificação, então apenas o tamanho foi verificado)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} instalado: {version} (este lançamento não publica soma de verificação, então apenas o tamanho foi verificado)
@msg {name} was removed {name} foi removido
@msg {name} is running. Quit it, then try again. {name} está em execução. Feche-o e tente novamente.
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install Instalar
@id app.update Atualizar para {v}
@id app.open Abrir
@id app.download Baixar para {os}
@id app.reveal Mostrar no Explorer
@id app.reinstall Reinstalar
@id app.uninstall Desinstalar…
@id app.notes Notas de lançamento
@id app.website Site
@id app.github Repositório no GitHub
@id app.releases Todos os lançamentos
@id launcher.update Atualizar e reiniciar
@id launcher.download Baixar
@id launcher.notes Novidades
@id launcher.dismiss Dispensar
@id settings.open Configurações
@id settings.quit Sair do ArtCraft Launcher
@id view.grid Grade
@id view.table Tabela
@id filter.all Todos
@id filter.installed Instalados
@id filter.updates Atualizações
@id filter.notinstalled Não instalados
@id sort.suite Ordem da suíte
@id sort.name Nome
@id sort.inspired Inspirado em
@id sort.status Status
@id sort.installed Versão instalada
@id sort.latest Última versão
@id sort.released Lançamento mais recente
@id help.discord Discord
@id help.github GitHub
@id help.website Site
@id update.check Verificar atualizações
@id update.check_auto Verificar atualizações automaticamente
@id update.notify Notificar quando um aplicativo instalado tiver atualização
@id update.prereleases Incluir versões de pré-lançamento (betas e RC)
@id linux.preferred_kind Instalar novos aplicativos como
@id tray.close_to_tray Continuar na bandeja do sistema ao fechar a janela
@id tray.show_on_current_desktop Mostrar a janela na área de trabalho atual
@id tray.login_item Iniciar o ArtCraft Launcher ao fazer login
@id appearance.theme Aparência
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher Mostrar ArtCraft Launcher
Check for Updates Verificar atualizações
Visit getartcraft.com Visitar getartcraft.com
ArtCraft on GitHub ArtCraft no GitHub
Join the ArtCraft Discord Entrar no Discord da ArtCraft
App details Detalhes do aplicativo
Close (Esc) Fechar (Esc)
Features Recursos
Latest release Último lançamento
Version Versão
Platforms Plataformas
Releases Lançamentos
Enlarge picture Ampliar imagem
Click to enlarge Clique para ampliar
Previous picture (←) Imagem anterior (←)
Next picture (→) Próxima imagem (→)
Loading pictures… Carregando imagens…
Pictures couldn't be loaded. Reopen this page to try again. Não foi possível carregar as imagens. Abra a página novamente para tentar de novo.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} atualização disponível|ArtCraft Launcher · {n} atualizações disponíveis
Can't render this file because it has a wrong number of fields in line 9.

View File

@ -1,342 +0,0 @@
# Russian (ru) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (English) or one|few|many (Russian).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral,
# international Russian, using terms Russian-speaking users of creative software know.
#
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite",
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord", "Website".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Инструменты с открытым кодом для творцов, мастеров и создателей
Settings Настройки
Check for updates Проверить обновления
Grid Сетка
Table Таблица
Show apps as cards Показывать приложения карточками
Show apps as a table Показывать приложения таблицей
All Все
Updates Обновления
Not installed Не установлены
Search apps Поиск приложений
Sort Сортировка
Suite order По классу приложений
Name Имя
Status Статус
Installed version Установленная версия
Latest version Последняя версия
Newest release Новый релиз
Discord Discord
GitHub GitHub
Website Веб-сайт
Classic apps Классические приложения
New apps Новые приложения
NEW НОВОЕ
Install Установить
Update to {v} Обновить до {v}
Open Открыть
Download for {os} Скачать для {os}
Download Скачать
Coming soon Скоро
Checking… Проверка…
Not available Недоступно
Offline Офлайн
Downloading {done} of {total} Скачивание {done} из {total}
Downloading… Скачивание…
Reconnecting… Переподключение…
Verifying… Проверка целостности…
Installing, then restarting… Установка, затем перезапуск…
Installing… Установка…
Removing… Удаление…
Pause download Приостановить скачивание
Cancel download Отменить скачивание
Resume download Продолжить скачивание
Discard download Удалить скачанное
Release notes Заметки к релизу
Show in Finder Показать в Finder
Show in Explorer Показать в Проводнике
Show in Files Показать в файловом менеджере
Reinstall Переустановить
Uninstall… Удалить…
All releases Все релизы
ArtCraft Launcher {version} is available. Доступен ArtCraft Launcher {version}.
Dismiss Закрыть
Update and restart Обновить и перезапустить
A new version is available. Would you like to relaunch with the updated version? Доступна новая версия. Перезапустить с обновленной версией?
Later Позже
What's new Что нового
No notes were published with this release. Заметки к этому релизу не публиковались.
Released {date} Релиз {date}
Open on GitHub Открыть на GitHub
Close Закрыть
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Только по запросу
Automatically download and install updates Автоматически скачивать и устанавливать обновления
Notify me when an installed app has an update Уведомлять, когда у установленного приложения есть обновление
Include pre-releases (betas and release candidates) Включать предварительные релизы (бета и RC)
Installing on Linux Установка в Linux
Install new apps as Устанавливать новые приложения как
System package (recommended) Системный пакет (рек.)
Updates always use the format an app was installed with. Обновления всегда используют формат, с которым приложение было установлено.
Running in the background Работа в фоне
Keep running in the tray when the window is closed Оставаться в трее при закрытии окна
Show the window on the current desktop Показывать окно на текущем рабочем столе
Start ArtCraft Launcher when I log in Запускать ArtCraft Launcher при входе в систему
Community Сообщество
Questions, ideas and news from the ArtCraft team Вопросы, идеи и новости от команды ArtCraft
Appearance Внешний вид
Quit ArtCraft Launcher Выйти из ArtCraft Launcher
Done Готово
Uninstall {name}? Удалить {name}?
The app will be moved to the Trash. Your documents and settings stay. Приложение будет перемещено в Корзину. Ваши документы и настройки сохранятся.
Windows will remove it like any installed program. Your documents and settings stay. Windows удалит его как любую установленную программу. Ваши документы и настройки сохранятся.
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. Приложение будет удалено. Ваши документы и настройки сохранятся.
Uninstall Удалить
Cancel Отмена
Checked {ago} Проверено {ago}
just now только что
yesterday вчера
{month} {day}, {year} {day} {month} {year}
Automatically Автоматически
Every Craft app Все приложения Craft
Apps on this computer Приложения на этом компьютере
Installed apps with a newer version Установленные приложения, у которых есть новая версия
Apps you can install Приложения, которые можно установить
Hide App Скрыть приложение
Unhide App Показать приложение
Hidden Apps Скрытые приложения
Apps you hid Приложения, которые вы скрыли
Checked {ago} · using {sources} Проверено {ago} · через {sources}
{a} and {b} {a} и {b}
Join the Discord Присоединиться к Discord
Learn more Подробнее
Minimize Свернуть
Maximize Развернуть
Restore Восстановить
New Новое
OK ОК
Open link Открыть ссылку
Update Обновить
{name} icon Значок {name}
Loading ArtCraft Launcher… Загрузка ArtCraft Launcher…
Cancel update Отменить обновление
Stop waiting; the update can be started again later Не ждать; обновление можно будет запустить позже
Download again Скачать заново
Install cancelled Установка отменена
Install cancelled. The download is kept for the next try. Установка отменена. Скачанный файл сохранён для следующей попытки.
Update cancelled Обновление отменено
Uninstall cancelled Удаление отменено
Retry install Повторить установку
Retry update to {version} Повторить обновление до {version}
The download is still here. It's checked again, then installed. Скачанный файл сохранён. Он будет проверен ещё раз, затем установлен.
Waiting for another app… Ожидание другого приложения…
Waiting for other installs… Ожидание других установок…
# Package formats offered for download (PackageKind::label)
Installer (.msi) Установщик (.msi)
Portable (.zip) Портативная версия (.zip)
Disk image (.dmg) Образ диска (.dmg)
Debian package (.deb) Пакет Debian (.deb)
RPM package (.rpm) Пакет RPM (.rpm)
Flatpak bundle Пакет Flatpak
Tarball (.tar.gz) Архив (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing Редактирование изображений
vectorcraft Vector illustration Векторная иллюстрация
filmcraft Video editing, color and sound Монтаж видео, цветокоррекция и звук
lightcraft Photo library and raw development Фотобиблиотека и проявка RAW
pdfcraft Reading, organizing and protecting PDFs Чтение, упорядочивание и защита PDF
effectcraft Motion graphics and visual effects Моушн-графика и визуальные эффекты
designcraft Page layout and publishing Вёрстка и издательское дело
wordcraft Writing and document design Тексты и оформление документов
deckcraft Presentations and slide shows Презентации и слайд-шоу
gridcraft Spreadsheets and data Электронные таблицы и данные
cadcraft Computer-aided design and drafting Автоматизированное проектирование и черчение
soundcraft Recording, editing and mixing audio Запись, редактирование и сведение звука
figcraft Interface design and prototyping Дизайн интерфейсов и прототипирование
# Contextual: month abbreviations for date rendering
month Jan янв.
month Feb февр.
month Mar март
month Apr апр.
month May май
month Jun июнь
month Jul июль
month Aug авг.
month Sep сент.
month Oct окт.
month Nov нояб.
month Dec дек.
Not checked yet Ещё не проверялось
GitHub's hourly limit was reached; full details return in {min} min Достигнут часовой лимит GitHub; полные детали вернутся через {min} мин
GitHub limit hit; back in {min} min Лимит GitHub исчерпан; вернётся через {min} мин
Installing for {platform} Установка для {platform}
Downloads for {platform} Скачивание для {platform}
No downloads for this system Нет загрузок для этой системы
Join the ArtCraft community on Discord Присоединиться к сообществу ArtCraft в Discord
ArtCraft on {name} ArtCraft в {name}
Couldn't open {name}: {e} Не удалось открыть {name}: {e}
Download cancelled Скачивание отменено
Available to install Доступно для установки
Available Доступно
Installed · up to date Установлено · актуально
Update available Доступно обновление
Installed Установлено
installed установлено
Not available here Недоступно здесь
Updating… Обновление…
Update paused Обновление приостановлено
Download paused Скачивание приостановлено
{version} installed → {latest} released {version} установлено → {latest} выпущено
Version {version} Версия {version}
{installed} installed · latest {latest} {installed} установлено · последняя {latest}
Installed · latest {latest} Установлено · последняя {latest}
Version {version} installed Версия {version} установлена
Version {latest} Версия {latest}
No release published yet Релиз ещё не опубликован
ArtCraft Launcher ArtCraft Launcher
App Приложение
Latest Последняя
Released Релиз
Auto Авто
Language Язык
Ascending По возрастанию
Descending По убыванию
Click to reverse the order Нажмите, чтобы изменить порядок
Click to sort by this column Нажмите, чтобы сортировать по этому столбцу
{done} of {total} {done} из {total}
Downloading Скачивание
Verifying checksum… Проверка контрольной суммы…
Resume Продолжить
{done} of {total} downloaded {done} из {total} скачано
{done} downloaded Скачано {done}
Checking for updates… Проверка обновлений…
Downloads the new version, replaces this one and opens it again Скачивает новую версию, заменяет текущую и открывает её заново
Couldn't check for updates: {f} Не удалось проверить обновления: {f}
GitHub repository Репозиторий GitHub
ArtCraft ArtCraft
Update 1 app Обновить 1 приложение
Update all ({count}) Обновить все ({count})
⊞ Grid ⊞ Сетка
☰ Table ☰ Таблица
🔍 Search apps 🔍 Поиск приложений
{name} {version} {name} {version}
Match system Как в системе
Dark Тёмная
Light Светлая
Full changelog Полный список изменений
link ссылка
No apps match Нет подходящих приложений
Open ArtCraft Launcher to update. Откройте ArtCraft Launcher, чтобы обновить.
{name} {v} is available Доступно обновление {name} {v}
{count} updates are available Доступны обновления ({count})
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Заметки не были загружены (достигнут лимит запросов GitHub). Они есть на странице релиза.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Когда включено, открытие лаунчера из строки меню переносит его окно на текущий рабочий стол. Когда выключено, macOS переключает вас на рабочий стол, где находилось окно.
Couldn't change the login item: {e} Не удалось изменить пункт автозагрузки: {e}
# Contextual: table column headers
column App Приложение
column Installed Текущая
column Latest Последняя
column Released Дата релиза
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Установлены
{name} details {name}: подробности
# Plural forms for Russian (one|few|many)
@plural {n} app|{n} apps {n} приложение|{n} приложения|{n} приложений
@plural {n} min ago|{n} min ago {n} минуту назад|{n} минуты назад|{n} минут назад
@plural {n} h ago|{n} h ago {n} час назад|{n} часа назад|{n} часов назад
@plural {n} day ago|{n} days ago {n} день назад|{n} дня назад|{n} дней назад
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} Не удалось открыть {name}: {e}
@msg Download cancelled Скачивание отменено
@msg {name} updated to {version} Приложение {name} обновлено до {version}
@msg {name} installed: {version} Приложение {name} установлено: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Приложение {name} обновлено до {version} (в этом релизе нет контрольных сумм, поэтому проверен только размер)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Приложение {name} установлено: {version} (в этом релизе нет контрольных сумм, поэтому проверен только размер)
@msg {name} was removed Приложение {name} удалено
@msg {name} is running. Quit it, then try again. Приложение {name} запущено. Закройте его и попробуйте снова.
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install Установить
@id app.update Обновить до {v}
@id app.open Открыть
@id app.download Скачать для {os}
@id app.reveal Показать в Finder
@id app.reinstall Переустановить
@id app.uninstall Удалить…
@id app.notes Заметки к релизу
@id app.website Веб-сайт
@id app.github Репозиторий на GitHub
@id app.releases Все релизы
@id launcher.update Обновить и перезапустить
@id launcher.download Скачать
@id launcher.notes Что нового
@id launcher.dismiss Закрыть
@id settings.open Настройки
@id settings.quit Выйти из ArtCraft Launcher
@id view.grid Сетка
@id view.table Таблица
@id filter.all Все
@id filter.installed Установленные
@id filter.updates Обновления
@id filter.notinstalled Не установленные
@id sort.suite По классу приложений
@id sort.name Имя
@id sort.status Статус
@id sort.installed Установленная версия
@id sort.latest Последняя версия
@id sort.released Новый релиз
@id help.discord Discord
@id help.github GitHub
@id help.website Веб-сайт
@id update.check Проверить обновления
@id update.check_auto Проверять обновления автоматически
@id update.notify Уведомлять, когда у установленного приложения есть обновление
@id update.prereleases Включать предварительные релизы (бета и RC)
@id linux.preferred_kind Устанавливать новые приложения как
@id tray.close_to_tray Оставаться в трее при закрытии окна
@id tray.show_on_current_desktop Показывать окно на текущем рабочем столе
@id tray.login_item Запускать ArtCraft Launcher при входе в систему
@id appearance.theme Внешний вид
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher Показать ArtCraft Launcher
Check for Updates Проверить обновления
Visit getartcraft.com Открыть getartcraft.com
ArtCraft on GitHub ArtCraft на GitHub
Join the ArtCraft Discord Присоединиться к Discord ArtCraft
App details О приложении
Close (Esc) Закрыть (Esc)
Features Возможности
Latest release Последний релиз
Version Версия
Platforms Платформы
Releases Релизы
Enlarge picture Увеличить изображение
Click to enlarge Нажмите, чтобы увеличить
Previous picture (←) Предыдущее изображение (←)
Next picture (→) Следующее изображение (→)
Loading pictures… Загрузка изображений…
Pictures couldn't be loaded. Reopen this page to try again. Не удалось загрузить изображения. Откройте страницу снова, чтобы повторить попытку.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} обновление|ArtCraft Launcher · {n} обновления|ArtCraft Launcher · {n} обновлений
Can't render this file because it contains an unexpected character in line 13 and column 44.

View File

@ -1,525 +0,0 @@
//! Tests for the i18n catalogs (format, duplicates, placeholders, plural forms, coverage) and the
//! lookups.
use std::collections::{BTreeSet, HashSet};
use super::catalog::{Catalog, unescape};
use super::lang::{plural_czech, plural_east_slavic, plural_polish};
use super::{LANGUAGES, Lang, LangInfo, lang_from_tag, message, tr, trn};
/// Each catalog's rows as (line number, context, source, translation), split exactly as the
/// parser does: `trim_end` only, since a plain row *starts* with a TAB.
fn rows(lang: &LangInfo) -> Vec<(usize, &str, &str, &str)> {
let mut out = Vec::new();
for (i, line) in lang.source.lines().enumerate() {
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
let [context, source, translation] = fields.as_slice() else {
panic!("{} line {}: expected 3 tab-separated fields: {line:?}", lang.code, i + 1);
};
out.push((i + 1, *context, *source, *translation));
}
out
}
fn catalogs() -> impl Iterator<Item = &'static LangInfo> {
LANGUAGES.iter().filter(|l| !l.source.is_empty())
}
fn placeholders(template: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
let Some(len) = rest[open..].find('}') else { break };
let name = &rest[open + 1..open + len];
if !name.is_empty() {
out.insert(name.to_string());
}
rest = &rest[open + len + 1..];
}
out
}
#[test]
fn catalogs_have_rows_and_no_empty_translations() {
for lang in catalogs() {
let rows = rows(lang);
assert!(rows.iter().filter(|r| r.1.is_empty()).count() > 50, "{}: plain rows were skipped", lang.code);
for (line, _, source, translation) in rows {
assert!(!source.is_empty() && !translation.is_empty(), "{} line {line}: empty field", lang.code);
}
}
}
#[test]
fn no_duplicate_keys() {
for lang in catalogs() {
let mut seen = HashSet::new();
for (line, context, source, _) in rows(lang) {
assert!(seen.insert((context, source)), "{} line {line}: duplicate key {context:?} {source:?}", lang.code);
}
}
}
#[test]
fn placeholders_match_in_translation() {
for lang in catalogs() {
for (line, context, source, translation) in rows(lang) {
if context == "@id" {
continue; // keyed by id: the English label isn't in the row
}
// Translations may reorder placeholders, but must keep each one.
let (src, tgt) = (placeholders(source), placeholders(translation));
assert_eq!(src, tgt, "{} line {line}: placeholders differ: {source:?} -> {translation:?}", lang.code);
}
}
}
#[test]
fn plural_forms_count_matches_language() {
for lang in catalogs() {
for (line, context, _, translation) in rows(lang) {
if context == "@plural" {
let forms = translation.split('|').count();
assert_eq!(forms, lang.plural_forms, "{} line {line}: {translation:?}", lang.code);
}
}
// The plural rule never asks for a form the catalog doesn't have.
for n in 0..200 {
assert!((lang.plural)(n) < lang.plural_forms, "{}: {n}", lang.code);
}
}
}
#[test]
fn every_msg_row_translates_its_own_text() {
for lang in catalogs() {
let cat = Catalog::parse(lang.source);
for (line, context, source, translation) in rows(lang) {
if context == "@msg" {
assert_eq!(cat.message(&unescape(source)).as_deref(), Some(unescape(translation).as_str()), "{} line {line}", lang.code);
}
}
}
}
#[test]
fn ellipsis_preserved() {
for lang in catalogs() {
for (line, context, source, translation) in rows(lang) {
if context.starts_with('@') {
continue;
}
assert_eq!(source.ends_with('…'), translation.ends_with('…'), "{} line {line}: {source:?} -> {translation:?}", lang.code);
}
}
}
/// Every string the UI looks up by literal (`tl!("…")`, `t("…")`) in the language of each catalog
/// that claims to be complete.
#[test]
fn complete_catalogs_cover_every_ui_literal() {
let sources = [
include_str!("../app.rs"),
include_str!("../details.rs"),
include_str!("../dialogs.rs"),
include_str!("../model.rs"),
include_str!("../notes.rs"),
include_str!("../settings.rs"),
include_str!("../views.rs"),
include_str!("../widgets.rs"),
// The desktop shell's tray menu and tooltip.
include_str!("../../../../apps/artcraft-launcher/src/tray.rs"),
];
let mut keys = BTreeSet::new();
for source in sources {
// `t("…")` calls, not `fmt(`, `.get(` and the like.
for (i, _) in source.match_indices("t(\"") {
if source[..i].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.') {
continue;
}
let start = i + 3;
if let Some(len) = source[start..].find('"') {
keys.insert(&source[start..start + len]);
}
}
for (i, _) in source.match_indices("tl!(\"") {
let start = i + 5;
if let Some(len) = source[start..].find('"') {
keys.insert(&source[start..start + len]);
}
}
}
assert!(keys.len() > 100, "found only {} UI literals", keys.len());
// Plural keys: `tn(n, "one", "other")`, `trn(lang, n, "one", "other")`.
let mut plurals = BTreeSet::new();
for source in sources {
for (i, call) in source.match_indices("tn(").chain(source.match_indices("trn(")) {
if source[..i].ends_with(|c: char| c.is_alphanumeric() || c == '_') || source[i + call.len()..].starts_with(')') {
continue;
}
let forms: Vec<&str> = source[i..].split('"').skip(1).step_by(2).take(2).collect();
if let [one, other] = forms.as_slice() {
plurals.insert(format!("{one}|{other}"));
}
}
}
assert!(plurals.len() >= 4, "found only {plurals:?}");
for lang in Lang::all().filter(|l| l.complete_menus()) {
let missing: Vec<_> = keys.iter().filter(|k| !super::has(lang, k)).collect();
assert!(missing.is_empty(), "{} is missing translations for {missing:#?}", lang.code());
let missing: Vec<_> = plurals.iter().filter(|k| lang.catalog().plural(k, 0).is_none()).collect();
assert!(missing.is_empty(), "{} is missing @plural rows for {missing:#?}", lang.code());
}
}
#[test]
fn complete_catalogs_translate_every_tagline() {
for lang in Lang::all().filter(|l| l.complete_menus()) {
for app in &artcraft_launcher_core::SUITE {
assert_ne!(super::tr_ctx(lang, app.id, app.tagline), app.tagline, "{}: {}", lang.code(), app.id);
}
}
}
#[test]
fn product_names_stay_in_english() {
for lang in Lang::all() {
for name in ["ArtCraft Launcher", "Craft App Suite", "Launcher"] {
assert_eq!(tr(lang, name), name, "{}", lang.code());
}
}
}
#[test]
fn russian_plural_rule_works() {
for n in [1, 21, 31, 101] {
assert_eq!(plural_east_slavic(n), 0, "{n}");
}
for n in [2, 3, 4, 22, 23, 24, 102] {
assert_eq!(plural_east_slavic(n), 1, "{n}");
}
for n in [0, 5, 11, 12, 13, 14, 15, 100, 111] {
assert_eq!(plural_east_slavic(n), 2, "{n}");
}
assert_eq!(trn(Lang::RU, 21, "{n} day ago", "{n} days ago"), "21 день назад");
assert_eq!(trn(Lang::RU, 3, "{n} day ago", "{n} days ago"), "3 дня назад");
}
#[test]
fn czech_plural_rule_works() {
for n in [0, 5, 11, 21, 22, 25] {
assert_eq!(plural_czech(n), 2, "{n}");
}
assert_eq!(plural_czech(1), 0);
for n in [2, 4] {
assert_eq!(plural_czech(n), 1, "{n}");
}
assert_eq!(trn(Lang::CS, 1, "{n} app", "{n} apps"), "1 aplikace");
assert_eq!(trn(Lang::CS, 2, "{n} app", "{n} apps"), "2 aplikace");
assert_eq!(trn(Lang::CS, 5, "{n} app", "{n} apps"), "5 aplikací");
}
#[test]
fn polish_plural_rule_works() {
// Polish says "one" only for 1 itself: 21, 31 and 101 take "many", unlike Russian.
assert_eq!(plural_polish(1), 0);
for n in [2, 3, 4, 22, 23, 24, 102, 122] {
assert_eq!(plural_polish(n), 1, "{n}");
}
for n in [0, 5, 11, 12, 13, 14, 15, 21, 25, 31, 100, 101, 111, 112] {
assert_eq!(plural_polish(n), 2, "{n}");
}
assert_eq!(trn(Lang::PL, 1, "{n} app", "{n} apps"), "1 aplikacja");
assert_eq!(trn(Lang::PL, 3, "{n} app", "{n} apps"), "3 aplikacje");
assert_eq!(trn(Lang::PL, 22, "{n} app", "{n} apps"), "22 aplikacje");
assert_eq!(trn(Lang::PL, 5, "{n} app", "{n} apps"), "5 aplikacji");
assert_eq!(trn(Lang::PL, 21, "{n} app", "{n} apps"), "21 aplikacji");
// A key the Polish catalog doesn't carry falls back to the English forms.
assert_eq!(trn(Lang::PL, 3, "{n} unknown item", "{n} unknown items"), "3 unknown items");
}
#[test]
fn untranslated_plurals_fall_back_to_english_by_count() {
// Russian's rule says "one" for 21, but English says "other".
assert_eq!(trn(Lang::RU, 21, "{n} unknown item", "{n} unknown items"), "21 unknown items");
assert_eq!(trn(Lang::RU, 1, "{n} unknown item", "{n} unknown items"), "1 unknown item");
assert_eq!(trn(Lang::EN, 0, "{n} app", "{n} apps"), "0 apps");
}
#[test]
fn locale_tags_resolve() {
for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS_cz"] {
assert_eq!(lang_from_tag(tag), Some(Lang::CS), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "cs-CZ", "en-US"]), Some(Lang::CS));
assert_eq!(lang_from_tag("pt_BR.UTF-8"), Some(Lang::PT_BR));
assert_eq!(lang_from_tag("pt-BR"), Some(Lang::PT_BR));
assert_eq!(lang_from_tag("pt"), Some(Lang::PT_BR));
assert_eq!(lang_from_tag("ru_RU.UTF-8"), Some(Lang::RU));
assert_eq!(lang_from_tag("ru-RU"), Some(Lang::RU));
assert_eq!(lang_from_tag("en-US"), Some(Lang::EN));
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
assert_eq!(lang_from_tag("C.UTF-8"), Some(Lang::EN));
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
assert_eq!(lang_from_tag("xx-XX"), None);
assert_eq!(lang_from_tag(""), None);
assert_eq!(super::detect::first_supported(["xx-XX", "ru-RU", "en-US"]), Some(Lang::RU));
}
#[test]
fn german_locales_resolve() {
for tag in ["de", "de-DE", "de_DE.UTF-8", "de-AT", "de-CH", "de_CH.UTF-8", "DE_de"] {
assert_eq!(lang_from_tag(tag), Some(Lang::DE), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "de-DE", "en-US"]), Some(Lang::DE));
}
#[test]
fn german_counts_dates_and_messages_work() {
assert_eq!(trn(Lang::DE, 1, "{n} app", "{n} apps"), "1 App");
assert_eq!(trn(Lang::DE, 0, "{n} app", "{n} apps"), "0 Apps");
assert_eq!(trn(Lang::DE, 21, "{n} day ago", "{n} days ago"), "vor 21 Tagen");
assert_eq!(trn(Lang::DE, 1, "{n} day ago", "{n} days ago"), "vor 1 Tag");
assert_eq!(super::tr_ctx(Lang::DE, "month", "Mar"), "März");
assert_eq!(tr(Lang::DE, "{month} {day}, {year}"), "{day}. {month} {year}");
assert_eq!(message(Lang::DE, "PhotoCraft updated to 0.5.0"), "PhotoCraft auf 0.5.0 aktualisiert");
assert_eq!(tr(Lang::DE, "Untranslated diagnostic"), "Untranslated diagnostic");
}
#[test]
fn hungarian_locales_and_plurals_work() {
for tag in ["hu", "hu-HU", "hu_HU.UTF-8", "HU_hu"] {
assert_eq!(lang_from_tag(tag), Some(Lang::HU), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "hu-HU", "en-US"]), Some(Lang::HU));
// After a number the noun stays singular; 1 is still the "one" form.
assert_eq!(trn(Lang::HU, 0, "{n} app", "{n} apps"), "0 alkalmazás");
assert_eq!(trn(Lang::HU, 1, "{n} app", "{n} apps"), "1 alkalmazás");
assert_eq!(trn(Lang::HU, 2, "{n} app", "{n} apps"), "2 alkalmazás");
assert_eq!(trn(Lang::HU, 21, "{n} day ago", "{n} days ago"), "21 napja");
assert_eq!(trn(Lang::HU, 1, "{n} day ago", "{n} days ago"), "1 napja");
assert_eq!(super::tr_ctx(Lang::HU, "month", "Mar"), "márc.");
assert_eq!(tr(Lang::HU, "{month} {day}, {year}"), "{year}. {month} {day}.");
assert_eq!(message(Lang::HU, "PhotoCraft updated to 0.5.0"), "PhotoCraft frissítve. Verzió: 0.5.0");
assert_eq!(tr(Lang::HU, "Untranslated diagnostic"), "Untranslated diagnostic");
}
#[test]
fn french_locales_and_plurals_work() {
for tag in ["fr", "fr-FR", "fr_CA.UTF-8", "fr-BE", "FR_ch"] {
assert_eq!(lang_from_tag(tag), Some(Lang::FR), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "fr-FR", "en-US"]), Some(Lang::FR));
// 0 and 1 are singular in French, unlike English.
assert_eq!(trn(Lang::FR, 0, "{n} app", "{n} apps"), "0 application");
assert_eq!(trn(Lang::FR, 1, "{n} app", "{n} apps"), "1 application");
assert_eq!(trn(Lang::FR, 2, "{n} app", "{n} apps"), "2 applications");
assert_eq!(trn(Lang::FR, 3, "{n} day ago", "{n} days ago"), "il y a 3 jours");
}
#[test]
fn spanish_locales_and_plurals_work() {
for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419", "es-PA", "ES_ar"] {
assert_eq!(lang_from_tag(tag), Some(Lang::ES), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "es-PA", "en-US"]), Some(Lang::ES));
// Only 1 is singular in Spanish, as in English.
assert_eq!(trn(Lang::ES, 0, "{n} app", "{n} apps"), "0 aplicaciones");
assert_eq!(trn(Lang::ES, 1, "{n} app", "{n} apps"), "1 aplicación");
assert_eq!(trn(Lang::ES, 3, "{n} day ago", "{n} days ago"), "hace 3 días");
assert_eq!(tr(Lang::ES, "{month} {day}, {year}"), "{day} {month} {year}");
assert_eq!(message(Lang::ES, "PhotoCraft updated to 0.5.0"), "PhotoCraft actualizada a 0.5.0");
}
#[test]
fn polish_locales_resolve() {
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL_pl"] {
assert_eq!(lang_from_tag(tag), Some(Lang::PL), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "pl-PL", "en-US"]), Some(Lang::PL));
}
#[test]
fn simplified_chinese_locales_respect_the_script() {
for tag in ["zh", "zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "ZH_cn"] {
assert_eq!(lang_from_tag(tag), Some(Lang::ZH_CN), "{tag}");
}
for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-CN", "zh-Hant-SG", "zh_TW.UTF-8"] {
assert_eq!(lang_from_tag(tag), None, "{tag}");
}
// A Traditional Chinese system that also lists English gets English, not Simplified.
assert_eq!(super::detect::first_supported(["zh-Hant-TW", "en-US"]), Some(Lang::EN));
}
#[test]
fn chinese_counts_and_english_fallback_work() {
for n in [0, 1, 2, 11, 100] {
assert_eq!(trn(Lang::ZH_CN, n, "{n} app", "{n} apps"), format!("{n} 个应用"));
assert_eq!(trn(Lang::ZH_CN, n, "{n} min ago", "{n} min ago"), format!("{n} 分钟前"));
}
assert_eq!(trn(Lang::ZH_CN, 2, "{n} unknown item", "{n} unknown items"), "2 unknown items");
assert_eq!(tr(Lang::ZH_CN, "Untranslated diagnostic"), "Untranslated diagnostic");
assert_eq!(message(Lang::ZH_CN, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0");
}
#[test]
fn japanese_locales_and_counts_work() {
for tag in ["ja", "ja-JP", "ja_JP.UTF-8", "JA_jp"] {
assert_eq!(lang_from_tag(tag), Some(Lang::JA), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "ja-JP", "en-US"]), Some(Lang::JA));
// Japanese has one plural form.
for n in [0, 1, 2, 11, 100] {
assert_eq!(trn(Lang::JA, n, "{n} app", "{n} apps"), format!("{n} 個のアプリ"));
assert_eq!(trn(Lang::JA, n, "{n} day ago", "{n} days ago"), format!("{n} 日前"));
}
assert_eq!(trn(Lang::JA, 2, "{n} unknown item", "{n} unknown items"), "2 unknown items");
assert_eq!(tr(Lang::JA, "Untranslated diagnostic"), "Untranslated diagnostic");
assert_eq!(super::tr_ctx(Lang::JA, "month", "Mar"), "3月");
assert_eq!(tr(Lang::JA, "{month} {day}, {year}"), "{year}年{month}{day}日");
assert_eq!(message(Lang::JA, "PhotoCraft updated to 0.5.0"), "PhotoCraft を 0.5.0 に更新しました");
}
/// With the system's Chinese font installed, every UI family can draw the whole Chinese catalog.
/// Machines without one (CI, the web) skip this; the UI then stays in English.
#[test]
fn system_font_covers_chinese_in_every_ui_family() {
use egui::{FontFamily, FontId};
if !crate::theme::cjk_font_available(Lang::ZH_CN) {
return;
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, Some(Lang::ZH_CN));
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
let text: String = Lang::ZH_CN
.0
.source
.lines()
.filter_map(|line| line.split('\t').nth(2))
.flat_map(str::chars)
.filter(|c| ('\u{3400}'..='\u{9fff}').contains(c))
.collect();
assert!(text.chars().count() > 500);
for family in [
FontFamily::Proportional,
FontFamily::Monospace,
crate::theme::medium(),
crate::theme::semibold(),
crate::theme::display(),
crate::theme::mono(),
crate::theme::mono_bold(),
] {
ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing glyph in {family:?}"));
}
}
/// With a system CJK font installed, every UI family can draw the whole Japanese catalog.
/// Machines without one (CI, the web) skip this.
#[test]
fn system_font_covers_japanese_in_every_ui_family() {
use egui::{FontFamily, FontId};
if !crate::theme::cjk_font_available(Lang::JA) {
return;
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, Some(Lang::JA));
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
let text: String = Lang::JA
.0
.source
.lines()
.filter_map(|line| line.split('\t').nth(2))
.flat_map(str::chars)
.filter(|c| ('\u{3040}'..='\u{30ff}').contains(c) || ('\u{3400}'..='\u{9fff}').contains(c))
.collect();
assert!(text.chars().count() > 200);
for family in [
FontFamily::Proportional,
FontFamily::Monospace,
crate::theme::medium(),
crate::theme::semibold(),
crate::theme::display(),
crate::theme::mono(),
crate::theme::mono_bold(),
] {
ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing glyph in {family:?}"));
}
}
#[test]
fn language_labels_come_from_the_registry() {
use crate::settings::Settings;
for lang in Lang::all().filter(|l| !l.needs_cjk_font()) {
assert_eq!(Settings::language_label(lang.code(), None), lang.name());
}
// A CJK language is named in English, and its own name is added only while its face is the one
// in the font stack: no other face draws it.
assert_eq!(Settings::language_label(Lang::ZH_CN.code(), None), "Simplified Chinese");
assert_eq!(Settings::language_label(Lang::ZH_CN.code(), Some(Lang::ZH_CN)), "Simplified Chinese (简体中文)");
assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::ZH_CN)), "Japanese");
assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::JA)), "Japanese (日本語)");
assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO, None), "Auto");
assert_eq!(Settings::language_label("xx", None), "Auto");
}
// The parser.
#[test]
fn parse_rows_of_every_kind() {
let cat = Catalog::parse(
"# comment\n\thello\tworld\nmenu\tOpen\tОткрыть\n@id\tfile.open\tОткрыть файл\n@plural\t{n} file|{n} files\t{n} файл|{n} файла|{n} файлов\n\
@plural\t{n} app|{n} apps\t{n} 个应用\nbroken row\n",
);
assert_eq!(cat.plain("hello"), Some("world"));
assert_eq!(cat.contextual("menu", "Open"), Some("Открыть"));
assert_eq!(cat.id("file.open"), Some("Открыть файл"));
assert_eq!(cat.plural("{n} file|{n} files", 0), Some("{n} файл"));
assert_eq!(cat.plural("{n} file|{n} files", 2), Some("{n} файлов"));
// One form serves every count.
assert_eq!(cat.plural("{n} app|{n} apps", 1), Some("{n} 个应用"));
}
#[test]
fn unescape_works() {
assert_eq!(unescape("hello\\tworld"), "hello\tworld");
assert_eq!(unescape("line1\\nline2"), "line1\nline2");
assert_eq!(unescape("backslash\\\\here"), "backslash\\here");
}
#[test]
fn message_templates_match_real_messages() {
let cat = Catalog::parse(
"@msg\tDownload cancelled\tСкачивание отменено\n\
@msg\tCouldn't open {name}: {e}\tНе удалось открыть {name}: {e}\n\
@msg\t{name} updated to {version}\t{name} обновлено до {version}\n\
@msg\t{name} updated to {version} (no checksum)\t{name} обновлено до {version} (без суммы)\n\
@msg\t{a}{b}\tnever\n",
);
assert_eq!(cat.message("Download cancelled").as_deref(), Some("Скачивание отменено"));
assert_eq!(cat.message("Couldn't open PhotoCraft: no such file: x").as_deref(), Some("Не удалось открыть PhotoCraft: no such file: x"));
assert_eq!(cat.message("PhotoCraft updated to 0.5.0").as_deref(), Some("PhotoCraft обновлено до 0.5.0"));
// The template that spells out more of the message wins.
assert_eq!(cat.message("PhotoCraft updated to 0.5.0 (no checksum)").as_deref(), Some("PhotoCraft обновлено до 0.5.0 (без суммы)"));
// Placeholders never match nothing, and unknown messages aren't touched.
assert_eq!(cat.message("Couldn't open : x"), None);
assert_eq!(cat.message("Something else"), None);
assert_eq!(message(Lang::EN, "Something else"), "Something else");
}
#[test]
fn complete_catalogs_translate_package_formats() {
use artcraft_launcher_core::PackageKind;
let kinds =
[PackageKind::Msi, PackageKind::PortableZip, PackageKind::Dmg, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz];
for lang in Lang::all().filter(|l| l.complete_menus()) {
for kind in kinds {
assert!(super::has(lang, kind.label()), "{}: {}", lang.code(), kind.label());
}
}
}

View File

@ -1,97 +0,0 @@
//! Looking strings up in the language the UI is drawn in.
use std::sync::atomic::{AtomicUsize, Ordering};
use super::lang::{LANGUAGES, Lang};
/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame.
static CURRENT: AtomicUsize = AtomicUsize::new(0);
/// Set the UI language for drawing (the shell calls this once per frame from the preference), so
/// widgets can translate without every call site carrying a language around.
pub fn set_current(lang: Lang) {
CURRENT.store(lang.index(), Ordering::Relaxed);
}
/// The language the UI is drawn in.
pub fn current() -> Lang {
LANGUAGES.get(CURRENT.load(Ordering::Relaxed)).map_or(Lang::EN, Lang)
}
/// Does `lang` have a catalog entry for this plain string? (English never does: it is the source.)
pub fn has(lang: Lang, s: &str) -> bool {
lang.catalog().plain(s).is_some()
}
/// Translate an English UI string into the current language ([`tr`] with [`current`]).
pub fn t(s: &str) -> &str {
tr(current(), s)
}
/// Translate an English UI string; unknown strings come back unchanged.
pub fn tr(lang: Lang, s: &str) -> &str {
lang.catalog().plain(s).unwrap_or(s)
}
/// Like [`tr`], for an English string that needs a disambiguating `context`.
pub fn tr_ctx<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
lang.catalog().contextual(context, s).unwrap_or_else(|| tr(lang, s))
}
/// A string keyed by its command id, falling back to the translation of the English `label`.
pub fn tr_id<'a>(lang: Lang, id: &str, label: &'a str) -> &'a str {
lang.catalog().id(id).unwrap_or_else(|| tr(lang, label))
}
/// [`tr_id`] in the current language.
pub fn t_id<'a>(id: &str, label: &'a str) -> &'a str {
tr_id(current(), id, label)
}
/// Fill `{name}` placeholders. Unknown placeholders are left as written.
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
let mut out = template.to_string();
for (k, v) in args {
out = out.replace(&format!("{{{k}}}"), v);
}
out
}
/// A plural-aware string: `one`/`other` are the English forms (with `{n}` where the count goes),
/// and together (`one|other`) the key of the language's `@plural` row, which holds as many forms
/// as the language has. Without a row, English: `one` for 1, `other` for everything else.
pub fn trn(lang: Lang, n: u64, one: &str, other: &str) -> String {
let idx = (lang.0.plural)(n);
let english = if n == 1 { one } else { other };
let text = lang.catalog().plural(&format!("{one}|{other}"), idx).unwrap_or(english);
fmt(text, &[("n", &n.to_string())])
}
/// [`trn`] in the current language.
pub fn tn(n: u64, one: &str, other: &str) -> String {
trn(current(), n, one, other)
}
/// Translate a status or error message for display (see the module docs). Unknown messages, and
/// the parts of a message a template's placeholders stand for (names, versions, reasons), stay
/// as they are.
pub fn message(lang: Lang, s: &str) -> String {
lang.catalog().message(s).unwrap_or_else(|| s.to_string())
}
/// [`message`] in the current language, remembering the last answer: the status bar asks for the
/// same message every frame.
pub fn msg(s: &str) -> String {
static LAST: std::sync::Mutex<Option<(&'static str, String, String)>> = std::sync::Mutex::new(None);
let lang = current();
let mut last = LAST.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((code, src, out)) = last.as_ref()
&& *code == lang.code()
&& src == s
{
return out.clone();
}
let out = message(lang, s);
*last = Some((lang.code(), s.to_string(), out.clone()));
out
}

View File

@ -1,314 +0,0 @@
# Simplified Chinese (zh-CN) catalog for ArtCraft Launcher.
# Format: context<TAB>English source<TAB>translation.
# Original translations from the English UI. Product and app names (ArtCraft Launcher and its
# "Launcher" tag, Craft App Suite, PhotoCraft…) and third-party names stay unchanged.
# Shown with the system's Chinese font (PingFang, Microsoft YaHei, Noto Sans CJK…): see cjk_font.rs.
# Chinese has one plural form. Placeholders and trailing ellipses are preserved.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders 为创作者和开发者打造的开源工具
Settings 设置
Check for updates 检查更新
Grid 网格
Table 表格
Show apps as cards 以卡片显示应用
Show apps as a table 以表格显示应用
All 全部
Updates 更新
Not installed 未安装
Search apps 搜索应用
Sort 排序
Suite order 默认顺序
Name 名称
Status 状态
Installed version 已安装版本
Latest version 最新版本
Newest release 发布日期
Discord Discord
GitHub GitHub
Website 网站
Classic apps 经典应用
New apps 新应用
NEW 新
Install 安装
Update to {v} 更新至 {v}
Open 打开
Download for {os} 下载 {os} 版
Download 下载
Coming soon 即将推出
Checking… 正在检查…
Not available 不可用
Offline 离线
Downloading {done} of {total} 正在下载 {done} / {total}
Downloading… 正在下载…
Reconnecting… 正在重新连接…
Verifying… 正在校验…
Installing, then restarting… 正在安装,完成后重启…
Installing… 正在安装…
Removing… 正在卸载…
Pause download 暂停下载
Cancel download 取消下载
Resume download 继续下载
Discard download 丢弃下载
Release notes 更新说明
Reinstall 重新安装
Uninstall… 卸载…
All releases 所有版本
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} 已可更新。
Dismiss 忽略
Update and restart 更新并重启
A new version is available. Would you like to relaunch with the updated version? 有新版本可用。是否使用更新后的版本重新启动?
Later 稍后
What's new 更新内容
No notes were published with this release. 此版本未提供更新说明。
Released {date} 发布于 {date}
Open on GitHub 在 GitHub 上打开
Close 关闭
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask 仅手动检查
Automatically download and install updates 自动下载并安装更新
Notify me when an installed app has an update 已安装应用有更新时通知我
Include pre-releases (betas and release candidates) 包含预发布版本(测试版和候选版)
Installing on Linux Linux 安装设置
Install new apps as 新应用安装格式
System package (recommended) 系统软件包(推荐)
Updates always use the format an app was installed with. 更新时始终使用应用原来的安装格式。
Running in the background 后台运行
Keep running in the tray when the window is closed 关闭窗口后继续在系统托盘中运行
Show the window on the current desktop 在当前桌面显示窗口
Start ArtCraft Launcher when I log in 登录时启动 ArtCraft Launcher
Community 社区
Questions, ideas and news from the ArtCraft team 与 ArtCraft 团队交流问题、想法和动态
Appearance 外观
Quit ArtCraft Launcher 退出 ArtCraft Launcher
Done 完成
Uninstall {name}? 卸载 {name}?
The app will be moved to the Trash. Your documents and settings stay. 应用将移到废纸篓。你的文档和设置会保留。
Windows will remove it like any installed program. Your documents and settings stay. Windows 将按常规方式卸载此应用。你的文档和设置会保留。
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. 应用将被卸载。你的文档和设置会保留。
Uninstall 卸载
Cancel 取消
Checked {ago} 上次检查:{ago}
just now 刚刚
yesterday 昨天
{month} {day}, {year} {year}年{month}{day}日
month Jan 1月
month Feb 2月
month Mar 3月
month Apr 4月
month May 5月
month Jun 6月
month Jul 7月
month Aug 8月
month Sep 9月
month Oct 10月
month Nov 11月
month Dec 12月
Not checked yet 尚未检查
GitHub's hourly limit was reached; full details return in {min} min 已达到 GitHub 每小时请求上限;{min} 分钟后可获取完整信息
GitHub limit hit; back in {min} min 已达 GitHub 限额;{min} 分钟后恢复
Installing for {platform} 安装平台:{platform}
Downloads for {platform} 下载平台:{platform}
No downloads for this system 此系统暂无可用下载
Join the ArtCraft community on Discord 加入 Discord 上的 ArtCraft 社区
ArtCraft on {name} ArtCraft 的 {name} 主页
Available to install 可安装
Available 可用
Installed · up to date 已安装 · 已是最新
Update available 有可用更新
Installed 已安装
Not available here 此平台不可用
Updating… 正在更新…
Update paused 更新已暂停
Download paused 下载已暂停
{version} installed → {latest} released 已安装 {version} → 最新 {latest}
Version {version} 版本 {version}
{installed} installed · latest {latest} 已安装 {installed} · 最新 {latest}
Installed · latest {latest} 已安装 · 最新 {latest}
Version {version} installed 已安装版本 {version}
Version {latest} 版本 {latest}
No release published yet 尚未发布版本
ArtCraft Launcher ArtCraft Launcher
App 应用
Latest 最新
Released 发布日期
Auto 跟随系统
Language 语言
Ascending 升序
Descending 降序
Click to reverse the order 点击切换排序方向
Click to sort by this column 点击按此列排序
{done} of {total} {done} / {total}
Downloading 正在下载
Verifying checksum… 正在验证校验和…
Resume 继续
{done} of {total} downloaded 已下载 {done} / {total}
{done} downloaded 已下载 {done}
Checking for updates… 正在检查更新…
Downloads the new version, replaces this one and opens it again 下载新版本,替换当前版本并重新打开
Couldn't check for updates: {f} 无法检查更新:{f}
GitHub repository GitHub 仓库
ArtCraft ArtCraft
Update 1 app 更新 1 个应用
Update all ({count}) 全部更新({count})
⊞ Grid ⊞ 网格
☰ Table ☰ 表格
🔍 Search apps 🔍 搜索应用
{name} {version} {name} {version}
Match system 跟随系统
Dark 深色
Light 浅色
Full changelog 完整更新日志
link 链接
No apps match 没有匹配的应用
Open ArtCraft Launcher to update. 打开 ArtCraft Launcher 进行更新。
{name} {v} is available {name} {v} 已可更新
{count} updates are available 有 {count} 个可用更新
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. 未能获取更新说明(已达到 GitHub 请求上限)。可前往版本发布页面查看。
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. 开启后,从菜单栏打开启动器时,窗口会显示在当前桌面。关闭后,macOS 会切换到窗口原来所在的桌面。
Couldn't change the login item: {e} 无法更改登录启动项:{e}
column App 应用
column Installed 已安装版本
column Latest 最新版本
column Released 发布日期
filter Installed 已安装
{name} details {name} 详情
@plural {n} min ago|{n} min ago {n} 分钟前
@plural {n} h ago|{n} h ago {n} 小时前
@plural {n} day ago|{n} days ago {n} 天前
@id app.install 安装
@id app.update 更新至 {v}
@id app.open 打开
@id app.download 下载 {os} 版
@id app.reveal 在访达中显示
@id app.reinstall 重新安装
@id app.uninstall 卸载…
@id app.notes 更新说明
@id app.website 网站
@id app.github GitHub 仓库
@id app.releases 所有版本
@id launcher.update 更新并重启
@id launcher.download 下载
@id launcher.notes 更新内容
@id launcher.dismiss 忽略
@id settings.open 设置
@id settings.quit 退出 ArtCraft Launcher
@id view.grid 网格
@id view.table 表格
@id filter.all 全部
@id filter.installed 已安装
@id filter.updates 更新
@id filter.notinstalled 未安装
@id sort.suite 默认顺序
@id sort.name 名称
@id sort.status 状态
@id sort.installed 已安装版本
@id sort.latest 最新版本
@id sort.released 发布日期
@id help.discord Discord
@id help.github GitHub
@id help.website 网站
@id update.check 检查更新
@id update.check_auto 自动检查更新
@id update.notify 已安装应用有更新时通知我
@id update.prereleases 包含预发布版本(测试版和候选版)
@id linux.preferred_kind 新应用安装格式
@id tray.close_to_tray 关闭窗口后继续在系统托盘中运行
@id tray.show_on_current_desktop 在当前桌面显示窗口
@id tray.login_item 登录时启动 ArtCraft Launcher
@id appearance.theme 外观
Update 更新
New 新
Classic 经典
Join the Discord 加入 Discord
Minimize 最小化
Maximize 最大化
Restore 还原
Show in Finder 在访达中显示
Show in Explorer 在资源管理器中显示
Show ArtCraft Launcher 显示 ArtCraft Launcher
Check for Updates 检查更新
Visit getartcraft.com 访问 getartcraft.com
ArtCraft on GitHub ArtCraft 的 GitHub 主页
Join the ArtCraft Discord 加入 ArtCraft Discord 社区
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · 有 {n} 个可用更新
@plural {n} app|{n} apps {n} 个应用
photocraft Image editing 图像编辑
vectorcraft Vector illustration 矢量插画
filmcraft Video editing, color and sound 视频剪辑、调色和声音处理
lightcraft Photo library and raw development 照片管理与 RAW 处理
pdfcraft Reading, organizing and protecting PDFs PDF 阅读、整理和保护
effectcraft Motion graphics and visual effects 动态图形与视觉特效
designcraft Page layout and publishing 页面排版与出版
wordcraft Writing and document design 写作与文档设计
deckcraft Presentations and slide shows 演示文稿与幻灯片
gridcraft Spreadsheets and data 电子表格与数据处理
cadcraft Computer-aided design and drafting 计算机辅助设计与制图
soundcraft Recording, editing and mixing audio 音频录制、编辑与混音
figcraft Interface design and prototyping 界面设计与原型制作
Show in Files 在文件管理器中显示
Loading ArtCraft Launcher… 正在加载 ArtCraft Launcher…
Download cancelled 下载已取消
Automatically 自动
Checked {ago} · using {sources} 上次检查:{ago} · 来源:{sources}
{a} and {b} {a} 和 {b}
Learn more 了解更多
OK 确定
Open link 打开链接
{name} icon {name} 图标
installed 已安装
Every Craft app 所有 Craft 应用
Apps on this computer 此电脑上的应用
Installed apps with a newer version 有新版本的已安装应用
Apps you can install 可安装的应用
Hide App 隐藏应用
Unhide App 取消隐藏应用
Hidden Apps 已隐藏的应用
Apps you hid 你隐藏的应用
Cancel update 取消更新
Stop waiting; the update can be started again later 停止等待;稍后可重新开始更新
Download again 重新下载
Install cancelled 安装已取消
Install cancelled. The download is kept for the next try. 安装已取消。已下载的文件会保留,供下次使用。
Update cancelled 更新已取消
Uninstall cancelled 卸载已取消
Retry install 重试安装
Retry update to {version} 重试更新至 {version}
The download is still here. It's checked again, then installed. 已下载的文件仍在。会先重新校验,然后安装。
Waiting for another app… 正在等待其他应用…
Waiting for other installs… 正在等待其他安装…
# Package formats offered for download (PackageKind::label)
Installer (.msi) 安装程序 (.msi)
Portable (.zip) 便携版 (.zip)
Disk image (.dmg) 磁盘映像 (.dmg)
Debian package (.deb) Debian 软件包 (.deb)
RPM package (.rpm) RPM 软件包 (.rpm)
Flatpak bundle Flatpak 包
Tarball (.tar.gz) 压缩包 (.tar.gz)
App details 应用详情
Close (Esc) 关闭(Esc)
Features 功能特性
Latest release 最新发布
Version 版本
Platforms 平台
Releases 版本发布
Enlarge picture 放大图片
Click to enlarge 点击放大
Previous picture (←) 上一张(←)
Next picture (→) 下一张(→)
Loading pictures… 正在加载图片…
Pictures couldn't be loaded. Reopen this page to try again. 无法加载图片。重新打开此页面可重试。
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} 无法打开 {name}:{e}
@msg Download cancelled 下载已取消
@msg {name} updated to {version} {name} 已更新至 {version}
@msg {name} installed: {version} {name} 已安装:{version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} 已更新至 {version}(此版本未发布校验和,因此只检查了文件大小)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} 已安装:{version}(此版本未发布校验和,因此只检查了文件大小)
@msg {name} was removed {name} 已卸载
@msg {name} is running. Quit it, then try again. {name} 正在运行。请先退出,然后重试。
Can't render this file because it contains an unexpected character in line 4 and column 3.

View File

@ -17,7 +17,6 @@ pub fn app(id: &str) -> Option<ImageSource<'static>> {
"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"),
"figcraft" => egui::include_image!("../../../assets/app-icons/figcraft.png"),
_ => return None,
})
}

View File

@ -8,23 +8,10 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// Translate an English UI string into the current language (see [`i18n`]).
#[macro_export]
macro_rules! tl {
($s:literal) => {
$crate::i18n::t($s)
};
}
mod app;
#[cfg(not(target_arch = "wasm32"))]
mod cjk_font;
mod details;
mod dialogs;
pub mod i18n;
pub mod icons;
pub mod model;
mod motion;
mod notes;
pub mod settings;
pub mod theme;
@ -32,8 +19,4 @@ mod views;
mod widgets;
pub use app::{Host, LauncherUi, TitleBar};
pub use i18n::{
Lang, LangInfo, current, detect_system_lang_in_background, fmt, has, lang_from_tag, message, msg, set_current, set_system_locales, system_lang,
t, t_id, tn, tr, tr_ctx, tr_id, trn,
};
pub use settings::Settings;

View File

@ -6,8 +6,6 @@ use artcraft_launcher_core::release::Release;
use artcraft_launcher_core::status::{self, AppStatus, Latest};
use artcraft_launcher_core::{CraftApp, Installed};
use crate::i18n::t;
#[derive(Clone, Debug)]
pub struct AppEntry {
pub app: &'static CraftApp,
@ -20,13 +18,11 @@ pub struct AppEntry {
pub origin: Option<artcraft_launcher_core::protocol::Origin>,
/// A download waiting to be resumed.
pub paused: Option<PausedDownload>,
/// A finished download waiting for another try at installing it.
pub ready: bool,
}
impl AppEntry {
pub fn new(app: &'static CraftApp) -> Self {
Self { app, latest: None, installed: None, detected: false, job: None, origin: None, paused: None, ready: false }
Self { app, latest: None, installed: None, detected: false, job: None, origin: None, paused: None }
}
pub fn release(&self) -> Option<&Release> {
@ -57,7 +53,7 @@ impl AppEntry {
}
pub fn installed_label(&self) -> Option<String> {
self.installed.as_ref().map(|i| i.version.as_ref().map(ToString::to_string).unwrap_or_else(|| t("Installed").to_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> {
@ -82,27 +78,27 @@ pub enum Tone {
Faint,
}
pub fn status_text(status: &AppStatus, manage_apps: bool) -> (String, Tone) {
pub fn status_text(status: &AppStatus, manage_apps: bool) -> (&'static str, Tone) {
match status {
AppStatus::Checking => (t("Checking…").to_string(), Tone::Faint),
AppStatus::Available if manage_apps => (t("Available to install").to_string(), Tone::Info),
AppStatus::Available => (t("Available").to_string(), Tone::Info),
AppStatus::UpToDate => (t("Installed · up to date").to_string(), Tone::Good),
AppStatus::UpdateAvailable => (t("Update available").to_string(), Tone::Warn),
AppStatus::Installed => (t("Installed").to_string(), Tone::Neutral),
AppStatus::Unavailable => (t("Not available here").to_string(), Tone::Faint),
AppStatus::NoRelease => (t("Coming soon").to_string(), Tone::Faint),
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),
}
}
/// [`status_text`], but saying so when a download is paused or reconnecting.
pub fn entry_status_text(e: &AppEntry, status: &AppStatus, manage_apps: bool) -> (String, Tone) {
pub fn entry_status_text(e: &AppEntry, status: &AppStatus, manage_apps: bool) -> (&'static str, Tone) {
match (e.job, e.paused) {
(Some(JobPhase::Reconnecting { .. }), _) => (t("Reconnecting…").to_string(), Tone::Warn),
(Some(JobPhase::Downloading { .. }), _) if status.is_installed() => (t("Updating…").to_string(), Tone::Info),
(Some(JobPhase::Downloading { .. }), _) => (t("Downloading…").to_string(), Tone::Info),
(None, Some(_)) if status.is_installed() => (t("Update paused").to_string(), Tone::Warn),
(None, Some(_)) => (t("Download paused").to_string(), Tone::Warn),
(Some(JobPhase::Reconnecting { .. }), _) => ("Reconnecting…", Tone::Warn),
(Some(JobPhase::Downloading { .. }), _) if status.is_installed() => ("Updating…", Tone::Info),
(Some(JobPhase::Downloading { .. }), _) => ("Downloading…", Tone::Info),
(None, Some(_)) if status.is_installed() => ("Update paused", Tone::Warn),
(None, Some(_)) => ("Download paused", Tone::Warn),
_ => status_text(status, manage_apps),
}
}

View File

@ -1,155 +0,0 @@
//! Fewer animations when the OS asks for that: Windows' "Animation effects" switched off, macOS'
//! "Reduce motion", a Linux desktop's animations setting, a browser's `prefers-reduced-motion`.
//! The shell says what the OS wants ([`crate::Host::reduce_motion`]); this makes egui follow it.
//! Progress bars and spinners keep moving: they say that work is going on.
use egui::{Event, MouseWheelUnit, RawInput, TouchPhase};
/// Seconds between asking the shell again: the setting can change while the launcher runs.
pub(crate) const RECHECK_EVERY: f64 = 1.0;
/// With `reduce`, popups, tooltips and dialogs appear at once instead of fading in, and scrolling
/// to a place jumps there. Without it, egui's own timings.
pub(crate) fn apply(ctx: &egui::Context, reduce: bool) {
let stock = egui::Style::default();
ctx.all_styles_mut(|style| {
style.animation_time = if reduce { 0.0 } else { stock.animation_time };
style.scroll_animation = if reduce { egui::style::ScrollAnimation::none() } else { stock.scroll_animation };
});
}
/// The most one step of a rewritten wheel event moves, in points: under what egui takes for a
/// trackpad's step (8), also once Shift has folded both axes into one.
const WHEEL_STEP: f32 = 4.0;
/// A wheel event that would take more steps than this is left as it came.
const WHEEL_MAX_STEPS: f32 = 4096.0;
/// Makes a mouse wheel scroll at once. egui spreads each notch of a wheel over several frames and
/// has no switch for that, but it applies a trackpad's small steps as they come: so every wheel
/// event is rewritten as a run of such steps, adding up to the same distance.
pub(crate) fn unsmooth_wheel(ctx: &egui::Context, raw: &mut RawInput) {
let wheel = |event: &Event| matches!(event, Event::MouseWheel { phase: TouchPhase::Move, .. });
if !raw.events.iter().any(wheel) {
return;
}
let line = ctx.options(|o| o.input_options.line_scroll_speed);
let page = raw.screen_rect.unwrap_or_else(|| ctx.viewport_rect()).height();
let mut events = Vec::with_capacity(raw.events.len());
for event in std::mem::take(&mut raw.events) {
let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } = &event else {
events.push(event);
continue;
};
let points = match unit {
MouseWheelUnit::Point => *delta,
MouseWheelUnit::Line => line * *delta,
MouseWheelUnit::Page => page * *delta,
};
let steps = ((points.x.abs() + points.y.abs()) / WHEEL_STEP).ceil();
// Nothing to move, or nonsense (NaN included): egui's to deal with.
if !(1.0..=WHEEL_MAX_STEPS).contains(&steps) {
events.push(event);
continue;
}
let step = Event::MouseWheel { unit: MouseWheelUnit::Point, delta: points / steps, phase: TouchPhase::Move, modifiers: *modifiers };
events.extend(std::iter::repeat_n(step, steps as usize));
}
raw.events = events;
}
#[cfg(test)]
mod tests {
use egui::{Modifiers, Vec2, vec2};
use super::*;
const FRAME: f64 = 1.0 / 60.0;
fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event {
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers }
}
/// One frame: how far egui scrolls in it.
fn scrolled(ctx: &egui::Context, frame: u32, events: Vec<Event>, reduce: bool) -> Vec2 {
let mut raw = RawInput { time: Some(f64::from(frame) * FRAME), events, ..Default::default() };
if reduce {
unsmooth_wheel(ctx, &mut raw);
}
let mut seen = Vec2::ZERO;
ctx.run_ui(raw, |ui| seen = ui.input(|i| i.smooth_scroll_delta)).drop_without_applying_deltas();
seen
}
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 0.01
}
#[test]
fn egui_spreads_a_wheel_notch_over_frames() {
let ctx = egui::Context::default();
let line = ctx.options(|o| o.input_options.line_scroll_speed);
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(0.0, -3.0), Modifiers::NONE)], false);
assert!(first.y < 0.0 && first.y > -1.5 * line, "{first:?}");
assert!(scrolled(&ctx, 1, Vec::new(), false).y < 0.0);
}
#[test]
fn a_wheel_notch_scrolls_at_once() {
let ctx = egui::Context::default();
let line = ctx.options(|o| o.input_options.line_scroll_speed);
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(0.0, -3.0), Modifiers::NONE)], true);
assert!(close(first, vec2(0.0, -3.0 * line)), "{first:?}");
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
}
#[test]
fn a_big_step_in_points_scrolls_at_once_too() {
let ctx = egui::Context::default();
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Point, vec2(30.0, -90.0), Modifiers::NONE)], true);
assert!(close(first, vec2(30.0, -90.0)), "{first:?}");
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
}
#[test]
fn shift_scrolls_sideways_at_once() {
let ctx = egui::Context::default();
let line = ctx.options(|o| o.input_options.line_scroll_speed);
// Both axes at once: Shift adds them up, which must still pass for a trackpad's step.
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(1.0, 2.0), Modifiers::SHIFT)], true);
assert!(close(first, vec2(3.0 * line, 0.0)), "{first:?}");
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
}
#[test]
fn other_input_is_left_alone() {
let ctx = egui::Context::default();
let events = vec![
Event::PointerMoved(egui::pos2(10.0, 10.0)),
wheel(MouseWheelUnit::Line, Vec2::ZERO, Modifiers::NONE),
wheel(MouseWheelUnit::Line, vec2(0.0, f32::NAN), Modifiers::NONE),
wheel(MouseWheelUnit::Page, vec2(f32::INFINITY, 0.0), Modifiers::NONE),
wheel(MouseWheelUnit::Point, vec2(0.0, 1.0e9), Modifiers::NONE),
Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 40.0), phase: TouchPhase::Start, modifiers: Modifiers::NONE },
];
let mut raw = RawInput { events: events.clone(), ..Default::default() };
unsmooth_wheel(&ctx, &mut raw);
// NaN is not equal to itself: compare what the events look like.
assert_eq!(format!("{:?}", raw.events), format!("{events:?}"));
}
#[test]
fn the_styles_follow_the_setting() {
let ctx = egui::Context::default();
let stock = egui::Style::default();
apply(&ctx, true);
for theme in [egui::Theme::Dark, egui::Theme::Light] {
assert_eq!(ctx.style_of(theme).animation_time, 0.0);
assert_eq!(ctx.style_of(theme).scroll_animation, egui::style::ScrollAnimation::none());
}
apply(&ctx, false);
for theme in [egui::Theme::Dark, egui::Theme::Light] {
assert_eq!(ctx.style_of(theme).animation_time, stock.animation_time);
assert_eq!(ctx.style_of(theme).scroll_animation, stock.scroll_animation);
}
}
}

View File

@ -37,7 +37,7 @@ pub(crate) fn classify(line: &str) -> Line<'_> {
pub(crate) fn show(ui: &mut Ui, notes: &str) {
let t = Tokens::get(ui.ctx());
if notes.trim().is_empty() {
ui.label(RichText::new(tl!("No notes were published with this release.")).color(t.muted));
ui.label(RichText::new("No notes were published with this release.").color(t.muted));
return;
}
for line in notes.lines() {
@ -52,7 +52,7 @@ pub(crate) fn show(ui: &mut Ui, notes: &str) {
ui.label(RichText::new("•").color(t.faint));
ui.label(RichText::new(text).color(t.ink));
if let Some(url) = link {
let short = url.rsplit('/').next().map_or_else(|| tl!("link").to_string(), |n| format!("#{n}"));
let short = url.rsplit('/').next().map_or_else(|| "link".to_string(), |n| format!("#{n}"));
ui.hyperlink_to(RichText::new(short).size(12.0), url);
}
});
@ -60,7 +60,7 @@ pub(crate) fn show(ui: &mut Ui, notes: &str) {
Line::Text(text) => {
if let Some(url) = text.strip_prefix("**Full Changelog**: ") {
ui.add_space(6.0);
ui.hyperlink_to(tl!("Full changelog"), url.trim());
ui.hyperlink_to("Full changelog", url.trim());
} else {
ui.label(RichText::new(text).color(t.muted));
}

View File

@ -5,7 +5,6 @@ use std::collections::BTreeMap;
use artcraft_launcher_core::PackageKind;
use serde::{Deserialize, Serialize};
use crate::i18n::t;
use crate::theme::ThemeChoice;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -22,17 +21,16 @@ pub enum Filter {
Installed,
Updates,
NotInstalled,
/// Apps the user chose to hide from the other lists.
Hidden,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SortKey {
/// Suite order: classic apps, then new. Settings saved with the removed "Inspired by" sort load as this.
/// Suite order: classic apps, then new.
#[default]
#[serde(alias = "InspiredBy")]
Suite,
Name,
/// The product an app is inspired by.
InspiredBy,
Status,
/// Installed version.
Installed,
@ -64,10 +62,10 @@ impl CheckEvery {
/// mean "automatically" too.
pub const ALL: [Self; 2] = [Self::Hour, Self::Never];
pub fn label(self) -> String {
pub fn label(self) -> &'static str {
match self {
Self::Hour | Self::FourHours | Self::Day => t("Automatically").to_string(),
Self::Never => t("Only when I ask").to_string(),
Self::Hour | Self::FourHours | Self::Day => "Automatically",
Self::Never => "Only when I ask",
}
}
@ -89,18 +87,12 @@ pub struct Settings {
pub sort: SortKey,
pub sort_descending: bool,
pub theme: ThemeChoice,
/// Interface language: `auto` (follow the system) or a language code from the registry
/// (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `pl`).
pub language: String,
/// Closing the window keeps the launcher in the tray (menu bar) instead of quitting.
pub close_to_tray: bool,
/// macOS: showing the window brings it to the current desktop (Space) instead of switching to
/// the desktop it was on.
pub show_on_current_desktop: bool,
pub check_every: CheckEvery,
/// Automatically download and install updates when found.
#[serde(default)]
pub auto_update: bool,
/// Show a system notification when an installed app has an update.
pub notify: bool,
pub prereleases: bool,
@ -112,58 +104,6 @@ pub struct Settings {
pub notified: BTreeMap<String, String>,
/// Unix seconds of the last finished check.
pub last_check: Option<i64>,
/// Catalog ids of apps hidden from the main lists (`photocraft`, …).
pub hidden: std::collections::BTreeSet<String>,
}
impl Settings {
/// Machine-readable code for "follow the system language".
pub const LANGUAGE_AUTO: &'static str = "auto";
/// Display name for a language `code`: a language's name in itself, from the registry
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English,
/// with its own name added only while `installed` — the CJK face in the current font stack — is
/// that language (no other face draws it); "Auto" for `auto` and unknown codes, like
/// [`Lang::from_pref`](crate::i18n::Lang::from_pref).
pub fn language_label(code: &str, installed: Option<crate::i18n::Lang>) -> String {
match crate::i18n::Lang::from_code(code) {
Some(lang) if lang.needs_cjk_font() && installed == Some(lang) => format!("{} ({})", lang.english_name(), lang.name()),
Some(lang) if lang.needs_cjk_font() => lang.english_name().to_string(),
Some(lang) => lang.name().to_string(),
None => t("Auto").to_string(),
}
}
}
/// Whether this process is running inside a Flatpak container sandbox.
pub fn is_flatpak_sandbox() -> bool {
#[cfg(target_os = "linux")]
{
std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists()
}
#[cfg(not(target_os = "linux"))]
false
}
/// Whether this process was launched from an AppImage.
pub fn is_appimage() -> bool {
#[cfg(target_os = "linux")]
{
std::env::var_os("APPIMAGE").is_some()
}
#[cfg(not(target_os = "linux"))]
false
}
/// Default package kinds preferred on Linux based on how the launcher itself was installed.
pub fn default_preferred_kinds() -> Vec<PackageKind> {
if is_flatpak_sandbox() {
vec![PackageKind::Flatpak]
} else if is_appimage() {
vec![PackageKind::AppImage]
} else {
Vec::new()
}
}
impl Default for Settings {
@ -174,18 +114,15 @@ impl Default for Settings {
sort: SortKey::Suite,
sort_descending: false,
theme: ThemeChoice::System,
language: "auto".to_string(),
close_to_tray: true,
show_on_current_desktop: true,
check_every: CheckEvery::Hour,
auto_update: false,
notify: true,
prereleases: false,
preferred_kinds: default_preferred_kinds(),
preferred_kinds: Vec::new(),
dismissed_notices: std::collections::BTreeSet::new(),
notified: BTreeMap::new(),
last_check: None,
hidden: std::collections::BTreeSet::new(),
}
}
}

View File

@ -8,8 +8,6 @@ use egui::epaint::text::VariationCoords;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
use serde::{Deserialize, Serialize};
use crate::i18n::Lang;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThemeChoice {
/// Follow the system's light or dark setting.
@ -22,14 +20,11 @@ pub enum ThemeChoice {
impl ThemeChoice {
pub const ALL: [Self; 3] = [Self::System, Self::Dark, Self::Light];
/// The English key translated at draw time into the current UI language
/// (the shell sets it per frame via `i18n::set_current`). Same-crate lookup,
/// pure and wasm-safe; no new dependency. Call sites stay unchanged.
pub fn label(self) -> &'static str {
match self {
Self::System => crate::i18n::t("Match system"),
Self::Dark => crate::i18n::t("Dark"),
Self::Light => crate::i18n::t("Light"),
Self::System => "Match system",
Self::Dark => "Dark",
Self::Light => "Light",
}
}
}
@ -145,27 +140,6 @@ pub fn shadow(t: &Tokens) -> egui::epaint::Shadow {
egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 36 }) }
}
/// The dim over the page behind a drawer; `amount` runs from 0 (none) to 1.
pub fn scrim(t: &Tokens, amount: f32) -> Color32 {
let alpha = if t.dark { 170.0 } else { 105.0 } * amount.clamp(0.0, 1.0);
Color32::from_black_alpha(alpha.round() as u8)
}
/// Behind an enlarged picture: near-black in both appearances, as pictures are judged on black.
pub fn backdrop() -> Color32 {
Color32::from_black_alpha(232)
}
/// A frame standing in for a picture, over [`backdrop`].
pub fn backdrop_panel() -> Color32 {
Color32::from_white_alpha(14)
}
/// Ink on [`backdrop`], at `alpha` out of 255.
pub fn backdrop_ink(alpha: u8) -> Color32 {
Color32::from_white_alpha(alpha)
}
fn visuals(t: &Tokens) -> Visuals {
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.bg;
@ -268,9 +242,7 @@ pub fn mono_bold() -> FontFamily {
type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
/// Registers the site's three variable fonts, each at the weights (and width) it uses there.
/// `cjk`: the CJK language to draw, whose system font is added behind everything else, or `None`
/// for a language that needs no CJK font.
pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
pub fn install_fonts(ctx: &egui::Context) {
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
const ARCHIVO: &[u8] = include_bytes!("../../../assets/fonts/Archivo-Variable.ttf");
const GEIST_MONO: &[u8] = include_bytes!("../../../assets/fonts/GeistMono-Variable.ttf");
@ -287,68 +259,11 @@ pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
("GeistMono-Bold", GEIST_MONO, &[(*b"wght", 700.0)], mono_bold()),
];
for (name, bytes, coords, family) in faces {
// No subpixel binning: these fonts carry no hinting instructions, so a glyph drawn at a
// fractional x smears each stem across two pixels, leaving small text blurry at 100 %.
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), subpixel_binning: Some(false), ..Default::default() };
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), ..Default::default() };
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes).tweak(tweak)));
let mut stack = vec![name.to_owned()];
stack.extend(fallback.iter().cloned());
fonts.families.insert(family, stack);
}
// The CJK face, behind everything else, from the system: only when asked for, as the font is
// large.
if let Some(lang) = cjk
&& let Some(font) = cjk_font(lang)
{
fonts.font_data.insert("CJK".to_owned(), font);
for stack in fonts.families.values_mut() {
stack.push("CJK".to_owned());
}
}
ctx.set_fonts(fonts);
}
/// The system's font for a CJK language, read once per language (tens of MB). Desktop only: the
/// web build has none, so CJK text can't be drawn there.
pub fn cjk_font(lang: Lang) -> Option<Arc<FontData>> {
#[cfg(not(target_arch = "wasm32"))]
{
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(lang.code()).or_insert_with(|| load_cjk(lang)).clone()
}
#[cfg(target_arch = "wasm32")]
{
let _ = lang;
None
}
}
/// Loads the system font for the CJK language `lang`: the face for its script, or `None` where the
/// system has none. Nothing falls back to another script's face — a face for a different script
/// leaves its characters as tofu — so a language without its font stays in English, like any other.
#[cfg(not(target_arch = "wasm32"))]
fn load_cjk(lang: Lang) -> Option<Arc<FontData>> {
crate::cjk_font::load(lang.script()?).map(Arc::new)
}
/// The system fonts looked for so far, by language code.
#[cfg(not(target_arch = "wasm32"))]
static CJK_FONTS: std::sync::Mutex<std::collections::BTreeMap<&'static str, Option<Arc<FontData>>>> =
std::sync::Mutex::new(std::collections::BTreeMap::new());
/// Has the system font for `lang` already been loaded? Never loads it.
pub fn cjk_font_loaded(lang: Lang) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(lang.code()).is_some_and(Option::is_some)
}
#[cfg(target_arch = "wasm32")]
{
let _ = lang;
false
}
}
/// Can the CJK language `lang` be drawn? Loads the system font on the first call ([`cjk_font`]).
pub fn cjk_font_available(lang: Lang) -> bool {
cjk_font(lang).is_some()
}

File diff suppressed because it is too large Load Diff

View File

@ -51,26 +51,7 @@ pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {
resp
}
/// A mono label cut to one line with "…" where it is wider than `max_width`; the full text shows
/// on hover and is what screen readers read.
pub fn hud_elided(ui: &mut Ui, text: &str, color: Color32, max_width: f32) -> Response {
let format = egui::TextFormat {
font_id: egui::FontId::new(theme::HUD_SIZE, theme::mono()),
color,
extra_letter_spacing: theme::HUD_SPACING,
..Default::default()
};
let mut job = egui::text::LayoutJob::single_section(text.to_uppercase(), format);
job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.min(ui.available_width()).max(0.0));
let galley = ui.painter().layout_job(job);
let elided = galley.elided;
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover());
paint_left(ui, rect, galley, color);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
if elided { resp.on_hover_text(text) } else { resp }
}
/// "01 / Features": a section eyebrow, the index faint.
/// "01 / Classic apps": a section eyebrow, the index faint.
pub fn eyebrow(ui: &mut Ui, index: &str, label: &str) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
@ -197,10 +178,10 @@ fn window_button(ui: &mut Ui, button: WindowButton, height: f32) -> Response {
let c = rect.center();
let stroke = Stroke::new(1.0, ink);
let label = match button {
WindowButton::Minimize => tl!("Minimize"),
WindowButton::Maximize => tl!("Maximize"),
WindowButton::Restore => tl!("Restore"),
WindowButton::Close => tl!("Close"),
WindowButton::Minimize => "Minimize",
WindowButton::Maximize => "Maximize",
WindowButton::Restore => "Restore",
WindowButton::Close => "Close",
};
// Half the glyph's size: 10 px, like Windows' own caption buttons.
let s = 5.0;
@ -311,12 +292,6 @@ pub enum Kind {
Danger,
}
/// The natural width of a button, including its horizontal padding.
pub fn button_width(ui: &Ui, text: &str, minimum: f32) -> f32 {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::BUTTON_SPACING, Tokens::get(ui.ctx()).ink);
(galley.size().x + 32.0).max(minimum)
}
/// A square button with a mono upper-case label, at least `width` wide.
pub fn button(ui: &mut Ui, text: &str, kind: Kind, width: f32) -> Response {
let t = Tokens::get(ui.ctx());
@ -417,16 +392,14 @@ pub fn progress(ui: &mut Ui, fraction: Option<f32>, caption: &str, color: Color3
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f.clamp(0.0, 1.0), bar.height())), CornerRadius::ZERO, color);
}
None => {
// Keep the segment moving through the track boundary without resetting visibly.
let width = bar.width();
let segment_width = width * 0.3;
let x = (ui.input(|i| i.time) * 0.8).fract() as f32 * width;
for left in [x, x - width] {
let seg = Rect::from_min_size(egui::pos2(bar.left() + left, bar.top()), vec2(segment_width, bar.height())).intersect(bar);
if seg.is_positive() {
p.rect_filled(seg, CornerRadius::ZERO, color);
}
}
// Indeterminate: a sweeping segment.
let x = (ui.input(|i| i.time) as f32 * 0.8).fract();
let seg = 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, bar.height()),
)
.intersect(bar);
p.rect_filled(seg, CornerRadius::ZERO, color);
ui.ctx().request_repaint();
}
}
@ -508,7 +481,7 @@ pub fn social_icons(ui: &mut Ui, size: f32) {
let color = if resp.hovered() { t.ink_strong } else { t.faint };
egui::Image::new(icon).tint(color).paint_at(ui, rect.shrink(pad));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, *name));
let resp = resp.on_hover_text(crate::i18n::fmt(tl!("ArtCraft on {name}"), &[("name", name)])).on_hover_cursor(egui::CursorIcon::PointingHand);
let resp = resp.on_hover_text(format!("ArtCraft on {name}")).on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(*url));
}

View File

@ -1,199 +0,0 @@
//! The Simplified Chinese UI, in its own test binary: the UI language is process-wide, so it can't
//! share one with the English UI tests. Chinese is drawn with the system's Chinese font; where
//! there is none (CI, minimal Linux installs), the UI must stay in English and say why.
//! `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with `-- --test-threads=1`).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, 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,
clock: Rc<Cell<i64>>,
}
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", self_update: self.manage }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
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)),
from: Origin::ArtCraftApi,
});
}
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });
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),
package: None,
});
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::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) });
ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None });
ev
}
fn harness_language(
view: ViewMode,
theme: egui::Theme,
manage: bool,
language: &str,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() };
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).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();
}
// One test: the UI language is process-wide.
#[test]
fn simplified_chinese_switches_persists_and_keeps_install_actions() {
let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en");
h.run_steps(6);
h.get_by_label("Settings").click();
h.run_steps(3);
h.get_by_value("English").click();
h.run_steps(2);
// Opening Settings doesn't load the (large) Chinese font: the picker names it in English.
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN));
h.get_by_label("Simplified Chinese").click();
h.run_steps(3);
if !artcraft_launcher_ui_egui::theme::cjk_font_available(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN) {
// No Chinese font: choosing it keeps the UI in English, and Settings says why.
assert_eq!(h.state().as_ref().unwrap().settings.language, "zh-CN");
h.get_by_label_contains("No font for this language was found");
h.get_by_label("Notify me when an installed app has an update");
snapshot(&mut h, "settings-zh-CN-no-font");
return;
}
// Choosing it loaded the font: now the picker shows both names.
h.get_by_value("Simplified Chinese (简体中文)");
h.get_by_label("已安装应用有更新时通知我");
snapshot(&mut h, "settings-zh-CN-dark");
let settings = h.state().as_ref().unwrap().settings.clone();
assert_eq!(settings.language, "zh-CN");
let saved = serde_json::to_string(&settings).unwrap();
let restored: Settings = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, "zh-CN");
h.get_by_label("完成").click();
h.run_steps(3);
h.get_by_label("图像编辑");
h.get_by_label_contains("上次检查:3 分钟前 · 来源:ArtCraft API");
h.get_by_label("更新至 0.5.0");
// Product names stay in English.
h.get_by_label("Launcher");
snapshot(&mut h, "grid-zh-CN-dark");
h.get_by_label("全部更新(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, ["photocraft", "effectcraft"]);
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);
table.run_steps(6);
table.get_by_label("已安装版本");
assert!(table.query_all_by_label_contains("年10月").count() >= 1);
snapshot(&mut table, "table-zh-CN-light");
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
web.run_steps(6);
assert!(web.query_all_by_label("下载 macOS 版").count() >= 3);
}

View File

@ -1,129 +0,0 @@
//! The French UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`, see `ui.rs`).
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, 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>,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
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: String::new(),
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,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
// A backend message, translated through its `@msg` template where it's shown.
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
let settings =
Settings { view, notify: false, language: "fr".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
)
}
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();
}
// One test: the UI language is process-wide.
#[test]
fn french_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
snapshot(&mut h, "grid-fr-dark");
// Product names stay in English.
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Retouche d'images");
h.get_by_label("Mettre à jour vers 0.5.0");
h.get_by_label_contains("Vérification de la somme de contrôle…");
h.get_by_label_contains("Vérifié il y a 3 min · via ArtCraft API");
h.get_by_label("LightCraft mis à jour vers 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-fr-dark");
table.get_by_label("Installée");
assert!(table.query_all_by_label_contains("août 2026").count() >= 1);
}

View File

@ -1,200 +0,0 @@
//! The German UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, 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,
clock: Rc<Cell<i64>>,
}
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", self_update: self.manage }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
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)),
from: Origin::ArtCraftApi,
});
}
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });
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),
package: None,
});
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::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) });
ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None });
ev
}
fn harness_language(
view: ViewMode,
theme: egui::Theme,
manage: bool,
language: &str,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() };
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).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();
}
// One test: the UI language is process-wide.
#[test]
fn german_switches_persists_and_keeps_install_actions() {
let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en");
h.run_steps(6);
h.get_by_label("Settings").click();
h.run_steps(3);
h.get_by_value("English").click();
h.run_steps(2);
h.get_by_label("Deutsch").click();
h.run_steps(3);
h.get_by_value("Deutsch");
h.get_by_label("Benachrichtigen, wenn es für eine installierte App ein Update gibt");
snapshot(&mut h, "settings-de-dark");
let settings = h.state().as_ref().unwrap().settings.clone();
assert_eq!(settings.language, "de");
let saved = serde_json::to_string(&settings).unwrap();
let restored: Settings = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, "de");
h.get_by_label("Fertig").click();
h.run_steps(3);
h.get_by_label("Bildbearbeitung");
h.get_by_label_contains("Zuletzt geprüft: vor 3 Min. · über ArtCraft API");
h.get_by_label("Auf 0.5.0 aktualisieren");
// Product names stay in English.
h.get_by_label("Launcher");
snapshot(&mut h, "grid-de-dark");
h.get_by_label("Alle aktualisieren (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, ["photocraft", "effectcraft"]);
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);
table.run_steps(6);
table.get_by_label("Datum");
assert!(table.query_all_by_label_contains(". Okt. 2026").count() >= 1);
snapshot(&mut table, "table-de-light");
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
web.run_steps(6);
assert!(web.query_all_by_label("Für macOS herunterladen").count() >= 3);
for width in [720.0, 800.0, 1180.0] {
web.set_size(egui::vec2(width, 900.0));
web.run_steps(6);
for label in ["Grid", "Table", "Settings", "Check for updates"] {
let rect = web.get_by_label(artcraft_launcher_ui_egui::i18n::t(label)).rect();
assert!(rect.left() >= 0.0 && rect.right() <= width, "{width}: {label} {rect:?}");
for label in ["Launcher", "Craft App Suite"] {
if let Some(text) = web.query_by_label(label) {
assert!(!rect.intersects(text.rect()), "{width}: {label} overlaps {rect:?}");
}
}
}
}
}

View File

@ -1,195 +0,0 @@
//! The Hungarian UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`, see `ui.rs`).
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, 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>,
clock: Rc<Cell<i64>>,
os: Os,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: self.os, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
struct DesktopHost;
impl Host for DesktopHost {
fn has_tray(&self) -> bool {
true
}
fn login_item(&self) -> Option<bool> {
Some(false)
}
}
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: String::new(),
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,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
// A backend message, translated through its `@msg` template where it's shown.
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
harness_on(view, Os::MacOs)
}
fn harness_on(view: ViewMode, os: Os) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)), os });
let settings =
Settings { view, notify: false, language: "hu".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut DesktopHost);
l.ui(ui, &mut DesktopHost);
}
},
None,
)
}
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();
}
// One test: the UI language is process-wide.
#[test]
fn hungarian_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
snapshot(&mut h, "grid-hu-dark");
// Product names stay in English.
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Képszerkesztés");
h.get_by_label("Frissítés: 0.5.0");
h.get_by_label_contains("Ellenőrzőösszeg ellenőrzése…");
h.get_by_label_contains("Ellenőrizve: 3 perce · ArtCraft API");
h.get_by_label("LightCraft frissítve. Verzió: 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-hu-dark");
table.get_by_label("Telepített");
assert!(table.query_all_by_label_contains("2026. aug.").count() >= 1);
check_settings_layout();
}
/// Checks the actual modal after resizing it while open, including Linux's extra controls.
fn check_settings_layout() {
let mut h = harness_on(ViewMode::Grid, Os::Linux);
h.run_steps(8);
h.get_by_label("Beállítások").click();
h.run_steps(8);
for (width, height) in [(1180.0, 900.0), (720.0, 500.0), (480.0, 500.0), (1180.0, 900.0)] {
h.set_size(egui::vec2(width, height));
h.run_steps(8);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height));
let done = h.get_by_label("Kész").rect();
let quit = h.get_by_label("Kilépés az ArtCraft Launcherből").rect();
assert!(screen.contains_rect(done), "{width}x{height}: Done is clipped: {done:?}");
assert!(screen.contains_rect(quit), "{width}x{height}: Quit is clipped: {quit:?}");
assert!(!done.intersects(quit));
let title = h.get_all_by_label("Beállítások").last().unwrap().rect();
assert!(screen.contains_rect(title), "{width}x{height}: title is clipped: {title:?}");
h.get_by_label("Rendszerbeállítás szerint").scroll_to_me();
h.run_steps(8);
// The footer has the same services, and the cards behind the modal have Webhely and GitHub
// links as one 14px line of text; keep only the settings body's buttons (34px) and icons (24px).
let discord = h.get_by_label("Csatlakozás a Discordhoz").rect();
let theme = h.get_by_label("Rendszerbeállítás szerint").rect();
assert!(screen.contains_rect(discord), "community links must be reachable by scrolling");
assert!(theme.bottom() < done.top(), "appearance controls must scroll above the fixed actions");
let mut links = vec![discord];
for label in ["Webhely", "GitHub", "X", "Instagram", "LinkedIn", "YouTube", "TikTok"] {
let candidates: Vec<_> = h
.query_all_by_label(label)
.map(|n| n.rect())
.filter(|r| r.top() >= discord.top() - 1.0 && r.bottom() < theme.top() && r.height() > 20.0)
.collect();
assert_eq!(candidates.len(), 1, "{width}x{height}: settings link {label}: {candidates:?}");
links.extend(candidates);
}
for (i, r) in links.iter().enumerate() {
assert!(screen.contains_rect(*r), "{width}x{height}: link is clipped: {r:?}");
for other in links.iter().skip(i + 1) {
assert!(!r.intersects(*other), "{width}x{height}: links overlap: {r:?}, {other:?}");
}
}
snapshot(&mut h, &format!("settings-hu-{width}x{height}-bottom"));
h.get_by_label("Nyelv").scroll_to_me();
h.run_steps(8);
let language = h.get_by_label("Nyelv").rect();
assert!(screen.contains_rect(language), "the top of settings must remain reachable");
snapshot(&mut h, &format!("settings-hu-{width}x{height}-top"));
}
h.get_by_label("Kész").click();
h.run_steps(4);
assert!(h.query_by_label("Kész").is_none(), "Done must still close the resized dialog");
}

View File

@ -1,202 +0,0 @@
//! The Japanese UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. Japanese is drawn with the system's Japanese font; where there is
//! none (CI, minimal Linux installs), the UI stays in English and says why.
//! `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with `-- --test-threads=1`).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, 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,
clock: Rc<Cell<i64>>,
}
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", self_update: self.manage }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
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)),
from: Origin::ArtCraftApi,
});
}
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });
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),
package: None,
});
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::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) });
ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None });
ev
}
fn harness_language(
view: ViewMode,
theme: egui::Theme,
manage: bool,
language: &str,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() };
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).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();
}
// One test: the UI language is process-wide.
#[test]
fn japanese_switches_persists_and_keeps_install_actions() {
use artcraft_launcher_ui_egui::i18n::Lang;
let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en");
h.run_steps(6);
h.get_by_label("Settings").click();
h.run_steps(3);
h.get_by_value("English").click();
h.run_steps(2);
// Opening Settings doesn't load the (large) Japanese font: the picker names it in English.
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(Lang::JA));
// The list is long enough to scroll, so the last option may be out of view: click it through
// accesskit, which reaches widgets that aren't visible.
h.get_by_label("Japanese").click_accesskit();
h.run_steps(3);
if !artcraft_launcher_ui_egui::theme::cjk_font_available(Lang::JA) {
// No font for Japanese: choosing it keeps the UI in English, and Settings says why.
assert_eq!(h.state().as_ref().unwrap().settings.language, "ja");
h.get_by_label_contains("No font for this language was found");
h.get_by_label("Notify me when an installed app has an update");
snapshot(&mut h, "settings-ja-no-font");
return;
}
// Choosing it loaded the font: now the picker shows both names.
h.get_by_value("Japanese (日本語)");
h.get_by_label("インストール済みアプリに更新があるときに通知する");
snapshot(&mut h, "settings-ja-dark");
let settings = h.state().as_ref().unwrap().settings.clone();
assert_eq!(settings.language, "ja");
let saved = serde_json::to_string(&settings).unwrap();
let restored: Settings = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, "ja");
h.get_by_label("完了").click();
h.run_steps(3);
h.get_by_label("画像編集");
h.get_by_label_contains("最終確認: 3 分前 · 取得元: ArtCraft API");
h.get_by_label("0.5.0 に更新");
// Product names stay in English.
h.get_by_label("Launcher");
snapshot(&mut h, "grid-ja-dark");
h.get_by_label("すべて更新(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, ["photocraft", "effectcraft"]);
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);
table.run_steps(6);
table.get_by_label("インストール済みバージョン");
assert!(table.query_all_by_label_contains("年10月").count() >= 1);
snapshot(&mut table, "table-ja-light");
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
web.run_steps(6);
assert!(web.query_all_by_label("macOS 版をダウンロード").count() >= 3);
}

View File

@ -1,129 +0,0 @@
//! The Polish UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`, see `ui.rs`).
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, 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>,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
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: String::new(),
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,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
// A backend message, translated through its `@msg` template where it's shown.
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
let settings =
Settings { view, notify: false, language: "pl".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
)
}
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();
}
// One test: the UI language is process-wide.
#[test]
fn polish_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
snapshot(&mut h, "grid-pl-dark");
// Product names stay in English.
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Edycja zdjęć");
h.get_by_label("Zaktualizuj do 0.5.0");
h.get_by_label_contains("Weryfikowanie sumy kontrolnej…");
h.get_by_label_contains("Ostatnio sprawdzono 3 min temu · przy użyciu ArtCraft API");
h.get_by_label("Zaktualizowano LightCraft do wersji 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-pl-dark");
table.get_by_label("Zainstalowana");
assert!(table.query_all_by_label_contains("sie 2026").count() >= 1);
}

View File

@ -1,116 +0,0 @@
//! The Brazilian Portuguese (pt-BR) UI test and snapshot generator.
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, 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>,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Explorer", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::Windows, arch: Arch::X64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
struct NoHost;
impl Host for NoHost {}
fn release(id: &str, v: &str, at: i64) -> Release {
let name = format!("{id}-{v}-windows-x64.msi");
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: String::new(),
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,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::Msi,
location: format!("C:\\Program Files\\{}\\{}.exe", app.name, app.id),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
let settings =
Settings { view, notify: false, language: "pt-BR".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
)
}
#[test]
fn portuguese_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Edição de imagens");
h.get_by_label("Atualizar para 0.5.0");
h.get_by_label_contains("Conferindo a soma de verificação…");
h.get_by_label_contains("Verificado há 3 min · via ArtCraft API");
h.get_by_label("LightCraft atualizado para 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
table.get_by_label("Instalada");
assert!(table.query_all_by_label_contains("de ago. de 2026").count() >= 1);
}

View File

@ -1,129 +0,0 @@
//! The Russian UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`, see `ui.rs`).
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, 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>,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
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: String::new(),
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,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
// A backend message, translated through its `@msg` template where it's shown.
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
let settings =
Settings { view, notify: false, language: "ru".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
)
}
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();
}
// One test: the UI language is process-wide.
#[test]
fn russian_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
snapshot(&mut h, "grid-ru-dark");
// Product names stay in English.
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Редактирование изображений");
h.get_by_label("Обновить до 0.5.0");
h.get_by_label_contains("Проверка контрольной суммы…");
h.get_by_label_contains("Проверено 3 минуты назад · через ArtCraft API");
h.get_by_label("Приложение LightCraft обновлено до 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-ru-dark");
table.get_by_label("Текущая");
assert!(table.query_all_by_label_contains("авг. 2026").count() >= 1);
}

View File

@ -1,129 +0,0 @@
//! The Spanish UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`, see `ui.rs`).
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, 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>,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
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: String::new(),
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,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
// A backend message, translated through its `@msg` template where it's shown.
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
let settings =
Settings { view, notify: false, language: "es".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
)
}
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();
}
// One test: the UI language is process-wide.
#[test]
fn spanish_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
snapshot(&mut h, "grid-es-dark");
// Product names stay in English.
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Edición de imágenes");
h.get_by_label("Actualizar a 0.5.0");
h.get_by_label_contains("Verificando la suma de comprobación…");
h.get_by_label_contains("Comprobado hace 3 min · con ArtCraft API");
h.get_by_label("LightCraft actualizada a 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-es-dark");
table.get_by_label("Instalada");
assert!(table.query_all_by_label_contains("ago. 2026").count() >= 1);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,96 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1791594108,
"narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6510408d147d8c5e6d76043e899e9251756f6a75",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1791707771,
"narHash": "sha256-w3m8HtAVCfGTHw+gcCTAy7jFHSdGbokm8NngbeFrkek=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "c1cfa7a2c809d364ac1a7e9b3ec418afa3f19793",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,82 +0,0 @@
{
description = "Experimental Nix flake for ArtCraft Launcher";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rust = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rustfmt" "clippy" "rust-src" ];
};
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
commonDeps = with pkgs; [
pkg-config
openssl
libxkbcommon
libx11
libxcursor
libxrandr
libxi
libxcb
libGL
vulkan-loader
mesa
wayland
wayland-protocols
fontconfig
freetype
alsa-lib
zlib
];
in
{
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
rust
cmake
ninja
libclang
];
buildInputs = commonDeps;
shellHook = ''
export LIBCLANG_PATH="${pkgs.libclang.lib}/lib"
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"
'';
};
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "artcraft-launcher";
version = version;
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = with pkgs; [
pkg-config
cmake
ninja
libclang
];
buildInputs = commonDeps;
doCheck = false;
};
apps.default = {
type = "app";
program = "${self.packages.${system}.default}/bin/artcraft-launcher";
};
});
}

View File

@ -13,8 +13,8 @@
<description>
<p>
ArtCraft Launcher lists every app in the Craft App Suite (PhotoCraft, LightCraft, FilmCraft,
DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
SoundCraft and FigCraft), installs the right build for your computer, and keeps them up to date.
DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft
and SoundCraft), installs the right build for your computer, and keeps them up to date.
</p>
<ul>
<li>One place to find, install and open every Craft app</li>

View File

@ -24,27 +24,18 @@ finish-args:
- --share=ipc
# GPU access for egui's wgpu renderer (Vulkan, or GL as a fallback).
- --device=dri
# SoundCraft's audio, for the case where it runs inside this sandbox rather than on the host
# (the launcher falls back to its release tarball when the host has no usable Flatpak): its
# ALSA goes through the runtime's PulseAudio plugin to this socket, which PipeWire's pulse
# server provides on modern desktops. Note this socket also covers the microphone (SoundCraft
# records, so it needs it); it grants recording, not raw hardware access.
- --socket=pulseaudio
# The launcher reads the Craft apps' GitHub releases and downloads them.
- --share=network
# Tray icon (StatusNotifierItem) and desktop notifications, both over the session bus.
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=org.freedesktop.Notifications
# Apps normally install as Flatpaks and run on the host: `flatpak-spawn --host` (below) starts
# them, and the home directory is granted so files opened by path work. When the host has no
# usable Flatpak the launcher falls back to the release tarball, which it unpacks into its own
# folder (~/.var/app/ai.storyteller.artcraft-launcher/data/artcraft-launcher/apps/<app>) and
# runs in the same sandbox with HOME pointed at a home/ folder beside it, so each app's
# settings stay in the sandbox too.
# LIMITATION: installing software on the host still needs `flatpak-spawn --host`. Distro
# packages (.deb/.rpm) or anything needing root still have to be installed by the user.
# org.freedesktop.Flatpak is a sandbox escape that Flathub reviews case by case; the AppImage,
# .deb and .rpm builds are the recommended way to run the launcher on Linux.
# LIMITATION: a launcher inside a Flatpak sandbox cannot install software on the host by
# itself. It may run `flatpak-spawn --host` (org.freedesktop.Flatpak) to start installed apps
# and host installers, and it gets the home directory so downloaded AppImages and tarballs land
# where the host can run them (~/Applications, ~/.local). Distro packages (.deb/.rpm) and
# anything needing root still have to be installed by the user. org.freedesktop.Flatpak is a
# sandbox escape that Flathub reviews case by case; the AppImage, .deb and .rpm builds are the
# recommended way to run the launcher on Linux.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=home
modules:

View File

@ -26,27 +26,18 @@ finish-args:
- --share=ipc
# GPU access for egui's wgpu renderer (Vulkan, or GL as a fallback).
- --device=dri
# SoundCraft's audio, for the case where it runs inside this sandbox rather than on the host
# (the launcher falls back to its release tarball when the host has no usable Flatpak): its
# ALSA goes through the runtime's PulseAudio plugin to this socket, which PipeWire's pulse
# server provides on modern desktops. Note this socket also covers the microphone (SoundCraft
# records, so it needs it); it grants recording, not raw hardware access.
- --socket=pulseaudio
# The launcher reads the Craft apps' GitHub releases and downloads them.
- --share=network
# Tray icon (StatusNotifierItem) and desktop notifications, both over the session bus.
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=org.freedesktop.Notifications
# Apps normally install as Flatpaks and run on the host: `flatpak-spawn --host` (below) starts
# them, and the home directory is granted so files opened by path work. When the host has no
# usable Flatpak the launcher falls back to the release tarball, which it unpacks into its own
# folder (~/.var/app/ai.storyteller.artcraft-launcher/data/artcraft-launcher/apps/<app>) and
# runs in the same sandbox with HOME pointed at a home/ folder beside it, so each app's
# settings stay in the sandbox too.
# LIMITATION: installing software on the host still needs `flatpak-spawn --host`. Distro
# packages (.deb/.rpm) or anything needing root still have to be installed by the user.
# org.freedesktop.Flatpak is a sandbox escape that Flathub reviews case by case; the AppImage,
# .deb and .rpm builds are the recommended way to run the launcher on Linux.
# LIMITATION: a launcher inside a Flatpak sandbox cannot install software on the host by
# itself. It may run `flatpak-spawn --host` (org.freedesktop.Flatpak) to start installed apps
# and host installers, and it gets the home directory so downloaded AppImages and tarballs land
# where the host can run them (~/Applications, ~/.local). Distro packages (.deb/.rpm) and
# anything needing root still have to be installed by the user. org.freedesktop.Flatpak is a
# sandbox escape that Flathub reviews case by case; the AppImage, .deb and .rpm builds are the
# recommended way to run the launcher on Linux.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=home
build-options:

View File

@ -19,9 +19,9 @@ license: MIT OR Apache-2.0
description: |-
Install, update and launch the Craft App Suite.
ArtCraft Launcher lists PhotoCraft, LightCraft, FilmCraft, DesignCraft,
VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
SoundCraft and FigCraft, installs the right build for this computer and keeps
them up to date from GitHub releases. It can live in the system tray.
VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft
and SoundCraft, installs the right build for this computer and keeps them up
to date from GitHub releases. It can live in the system tray.
# The binary links only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
# GPU (Vulkan, EGL/GL) are loaded at runtime with dlopen, so they aren't ELF NEEDED entries and

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Build and package ArtCraft Launcher for Linux (<arch> is x86_64, aarch64 or riscv64):
# Build and package ArtCraft Launcher for Linux (<arch> is x86_64 or aarch64):
#
# $DIST/artcraft-launcher-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/artcraft-launcher-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
@ -7,16 +7,12 @@
# $DIST/artcraft-launcher-<version>-linux-<arch>.rpm Fedora, openSUSE, RHEL, ...
# $DIST/artcraft-launcher-<version>-linux-<arch>.tar.gz plain FHS-style tree (bin/, share/)
#
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar" or appimage,deb,rpm,tar]
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"]
#
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
# zsyncmake (the zsync package) for the AppImage's .zsync.
#
# Cross builds (CI: riscv64, deb/rpm/tar.gz): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust
# triple), CROSS_COMPILE (binutils prefix, for strip) and optionally EMULATOR (e.g. qemu-riscv64)
# to smoke-test the CLI.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
@ -30,22 +26,13 @@ while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
-h | --help) sed -n '2,19p' "$0"; exit 0 ;;
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
# Accept "deb,rpm,tar" as well as "deb rpm tar" (craft-common-actions passes commas).
FORMATS="$(printf %s "$FORMATS" | tr ',' ' ')"
for fmt in $FORMATS; do
case "$fmt" in
appimage | deb | rpm | tar) ;;
*) echo "unknown format: $fmt (expected appimage, deb, rpm or tar)" >&2; exit 2 ;;
esac
done
ARCH="${CROSS_ARCH:-$(uname -m)}"
ARCH="$(uname -m)"
case "$ARCH" in
riscv64) DEB_ARCH=riscv64 ;;
x86_64) DEB_ARCH=amd64 ;;
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
@ -56,20 +43,16 @@ BASENAME="$PKG-$VERSION-linux-$ARCH"
echo "==> ArtCraft Launcher $VERSION for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
if [ -n "${CROSS_TARGET:-}" ]; then
(cd "$ROOT" && cargo build --release --locked -p "$PKG" --target "$CROSS_TARGET")
else
(cd "$ROOT" && cargo build --release --locked -p "$PKG")
fi
(cd "$ROOT" && cargo build --release --locked -p "$PKG")
fi
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
BIN="$CARGO_TARGET_DIR/release"
WORK="$CARGO_TARGET_DIR/linux-package"
STAGE="$WORK/root"
rm -rf "$WORK"
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
install -Dm755 "$BIN/$PKG" "$STAGE/usr/bin/$PKG"
"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/$PKG" 2>/dev/null || true
strip "$STAGE/usr/bin/$PKG" 2>/dev/null || true
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
mkdir -p "$STAGE/usr/share/metainfo"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$ARTCRAFT_LAUNCHER_BUILD_DATE/g" \
@ -156,10 +139,5 @@ if has appimage; then
fi
fi
if [ -z "${CROSS_TARGET:-}" ]; then
"$STAGE/usr/bin/$PKG" --version
elif [ -n "${EMULATOR:-}" ]; then
"$EMULATOR" "$STAGE/usr/bin/$PKG" --version
fi
echo "==> done"
ls -lh "$DIST"

View File

@ -1,47 +0,0 @@
# macOS DMG window
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side, laid out like the other Craft apps' DMGs. `package.sh` copies
these files into the image; nothing here is generated at build time, so the DMG builds with a plain
`hdiutil create -srcfolder` (no Finder scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
| `background.svg` | Source of the background: the ArtCraft mark in black (`docs/brand/artcraft-mark-black.svg`, linked, not copied) whole on the mark's blue (`#4e7bfb`), Ink and Paper. The mark is a trademark: it stays exactly as supplied, never cropped or recoloured (`docs/brand/LICENSE-brand.txt`), unlike the cropped icons on the other apps' DMGs. Its text (Inter, Geist Mono from `assets/fonts`) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, ArtCraft Launcher.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `ArtCraft Launcher`, not `ArtCraft Launcher <version>`. The layout
points at the background through an alias that includes the volume name, and Finder resolves it by
that name: with a versioned name the window keeps its size and icon positions but shows no
background. The DMG file name (`artcraft-launcher-<version>-macos-<arch>.dmg`) still carries the
version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg --skip-system-fonts --use-font-file assets/fonts/Inter-Variable.ttf
--use-font-file assets/fonts/GeistMono-Variable.ttf` converts text to paths). The window is
660 × 432 with Finder's 32 pt title bar; the content area is 660 × 400.
This follows the other Craft apps' `packaging/macos/dmg/` (storytold/wordcraft#21,
storytold/photocraft#1201, first in storytold/vectorcraft#493).

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

View File

@ -1,158 +0,0 @@
#!/usr/bin/env python3
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
Needs resvg on PATH and:
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
python3 packaging/macos/dmg/generate.py
"""
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
from ds_store import DSStore
from mac_alias import Alias, TargetInfo, VolumeInfo
from PIL import Image, ImageCms
HERE = os.path.dirname(os.path.abspath(__file__))
# Keep in sync with package.sh (volume name) and README.md (layout).
VOLUME = "ArtCraft Launcher"
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
TITLE_BAR = 32
ICON_SIZE = 128
PALETTE = 16 # colours in background.tiff
ICONS = {"ArtCraft Launcher.app": (326, 205), "Applications": (574, 205)}
# A fixed date for the alias and the colour profile. Finder never matches it against the image
# (each build is a new volume): it finds the background by volume name and path.
FIXED_DATE = datetime.datetime(2026, 10, 11, tzinfo=datetime.timezone.utc)
def render(svg, width, out):
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
# same pixels.
subprocess.run(
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
check=True,
)
with Image.open(out) as im:
return im.convert("RGB")
def quantize(im):
# No dithering: dither noise compresses badly and the artwork has flat fills.
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
def srgb_profile():
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
d = FIXED_DATE
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
if icc[84:100] != bytes(16):
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
return bytes(icc)
def write_tiff(svg, out):
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
with tempfile.TemporaryDirectory() as tmp:
one, two = (
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
)
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
one.save(
out,
"TIFF",
save_all=True,
append_images=[two],
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
dpi=(72, 72),
icc_profile=srgb_profile(),
)
with Image.open(out) as im:
im.seek(1)
if im.info.get("dpi") != (144, 144):
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
strips = []
for page in (0, 1):
im.seek(page)
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
# leaves the padding byte after it uninitialised, so the file changed from run to run with
# identical pixels. Zero it: nothing refers to that byte.
starts = {o for o, _ in strips}
with open(out, "r+b") as f:
for o, n in strips:
if (o + n) % 2 and o + n not in starts:
f.seek(o + n)
f.write(bytes(1))
def background_alias():
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
# are only hints, as the volume's creation date.
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
volume.posix_path = f"/Volumes/{VOLUME}"
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
target.folder_name = ".background"
target.cnid_path = [25]
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
target.posix_path = "/.background/background.tiff"
return Alias(volume=volume, target=target).to_bytes()
def write_ds_store(out):
if os.path.exists(out):
os.remove(out)
with DSStore.open(out, "w+") as d:
d["."]["bwsp"] = {
"ShowStatusBar": False,
"ShowToolbar": False,
"ShowTabView": False,
"ContainerShowSidebar": False,
"ShowSidebar": False,
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
}
d["."]["icvp"] = {
"viewOptionsVersion": 1,
"backgroundType": 2,
"backgroundImageAlias": background_alias(),
"backgroundColorRed": 1.0,
"backgroundColorGreen": 1.0,
"backgroundColorBlue": 1.0,
"gridOffsetX": 0.0,
"gridOffsetY": 0.0,
"gridSpacing": 100.0,
"arrangeBy": "none",
"showIconPreview": True,
"showItemInfo": False,
"labelOnBottom": True,
"textSize": 16.0,
"iconSize": float(ICON_SIZE),
}
d["."]["vSrn"] = ("long", 1)
for name, pos in ICONS.items():
d[name]["Iloc"] = pos
def main():
if not shutil.which("resvg"):
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
if __name__ == "__main__":
main()

View File

@ -113,10 +113,6 @@ if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/
fi
printf 'APPL????' >"$APP/Contents/PkgInfo"
# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing:
# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle.
xattr -cr "$APP"
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/$APP_NAME"
@ -137,31 +133,12 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/$APP_NAME.app"
ln -s /Applications "$STAGE/Applications"
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG"
# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a
# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or
# similar detritus" on the app in the image and on a copy installed from it. `create` attaches a
# device while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a
# few tries. verify.sh checks the app inside the finished image with --strict. The volume name has
# no version: .DS_Store finds the background through an alias that includes it.
# Usage: hdiutil_retry <what> <hdiutil args…>; tries five times with growing pauses.
hdiutil_retry() {
local what="$1" attempt
shift
for attempt in 1 2 3 4 5; do
if hdiutil "$@"; then
return 0
fi
[ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
sleep $((attempt * 10))
done
}
hdiutil_retry create create -srcfolder "$STAGE" -volname "$APP_NAME" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "$APP_NAME $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
if [ "$NOTARIZE" = 1 ]; then

View File

@ -112,13 +112,10 @@ if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/
exit 1
fi
# Integrity first: this must hold even for ad-hoc builds, and with --strict, on the app in the
# image and on a copy installed from it with ditto. A valid DMG signature says nothing about the
# bundle inside: the old makehybrid image gave every file a com.apple.FinderInfo, which strict
# mode rejects as "detritus".
codesign --verify --strict --deep --verbose=2 "$APP"
ditto "$APP" "$WORK/copy/$APP_NAME.app"
codesign --verify --strict --deep --verbose=2 "$WORK/copy/$APP_NAME.app"
# Integrity first: this must hold even for ad-hoc builds. Not --strict here: files read back from
# the DMG's HFS volume (hdiutil makehybrid) report a com.apple.FinderInfo attribute, which strict
# mode calls "detritus". package.sh already ran the strict check on the bundle before imaging it.
codesign --verify --deep --verbose=2 "$APP"
details="$(codesign -d --verbose=2 "$APP" 2>&1)"
printf '%s\n' "$details" | grep -E '^(Identifier|Format|CodeDirectory|Authority|Timestamp|TeamIdentifier)[= ]' || true

View File

@ -111,7 +111,7 @@ function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {
$line = [System.Drawing.Color]::FromArgb(38, 242, 241, 238)
# The Craft App Suite, in the launcher's order (crates/core/src/catalog.rs).
$apps = 'photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft',
'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft', 'figcraft'
'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft'
$mark = [System.Drawing.Image]::FromFile((Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.artcraft-launcher.png'))
$wordmark = [System.Drawing.Image]::FromFile((Join-Path $Root 'docs\brand\artcraft-logo-white.png'))
$icons = $apps | ForEach-Object { [System.Drawing.Image]::FromFile((Join-Path $Root "assets\app-icons\$_.png")) }
@ -148,14 +148,14 @@ function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {
$x += $g.MeasureString([string]$ch, $labelFont, 100, [System.Drawing.StringFormat]::GenericTypographic).Width + 1.35
}
$brush.Dispose()
# The apps, three to a row, in 36 px cells divided by hairlines (five rows fit above 312).
# The apps, three to a row, in 40 px cells divided by hairlines.
$pen = New-Object System.Drawing.Pen $line, 1
$left = 22; $top = 118; $cell = 36
$left = 22; $top = 126; $cell = 40
for ($i = 0; $i -lt $icons.Count; $i++) {
$cx = $left + ($i % 3) * $cell
$cy = $top + [math]::Floor($i / 3) * $cell
$g.DrawRectangle($pen, $cx, $cy, $cell, $cell)
$g.DrawImage($icons[$i], $cx + 5, $cy + 5, $cell - 10, $cell - 10)
$g.DrawImage($icons[$i], $cx + 6, $cy + 6, $cell - 12, $cell - 12)
}
$pen.Dispose()
}

View File

@ -1,751 +0,0 @@
//! `cargo xtask local`: install the Craft apps from builds on this machine instead of GitHub.
//!
//! - `sync` pulls every app's checkout (the sibling folders of this repo), builds and packages the
//! ones whose version isn't in the mirror yet, and files the results under
//! `<mirror>/<app>/<version>/` with a `SHA256SUMS.txt`.
//! - `serve` serves the mirror on 127.0.0.1 and answers `release_info.json` with a feed (the same
//! shape as ArtCraft's) written fresh from whatever is in the mirror at that moment.
//!
//! Run the launcher with `ARTCRAFT_LAUNCHER_DOWNLOAD_BASE=http://127.0.0.1:<port>/` and it installs
//! and updates from the mirror alone.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// Every Craft App: (id = folder name, display name). Keep in step with `catalog::SUITE`.
const APPS: &[(&str, &str)] = &[
("photocraft", "PhotoCraft"),
("vectorcraft", "VectorCraft"),
("filmcraft", "FilmCraft"),
("lightcraft", "LightCraft"),
("pdfcraft", "PdfCraft"),
("effectcraft", "EffectCraft"),
("designcraft", "DesignCraft"),
("wordcraft", "WordCraft"),
("deckcraft", "DeckCraft"),
("gridcraft", "GridCraft"),
("cadcraft", "CADCraft"),
("soundcraft", "SoundCraft"),
("figcraft", "FigCraft"),
];
const DEFAULT_PORT: u16 = 8787;
/// Marks a version folder whose build finished; anything without it is ignored and rebuilt.
const COMPLETE: &str = ".complete";
const SUMS: &str = "SHA256SUMS.txt";
const NOTES: &str = "NOTES.md";
/// The commit a version folder was built from.
const COMMIT: &str = ".commit";
pub const USAGE: &str = "\
usage: cargo xtask local <command> [options]
commands:
sync [options] [-- <package.sh args>]
git pull every app checkout, then build and package each app whose version
isn't in the mirror yet
--root <dir> folder holding the app checkouts (default: this repo's parent)
--mirror <dir> where builds are kept (default: <root>/local-mirror)
--only <a,b> just these apps
--no-pull don't pull; build what's checked out
--rebuild build even when the version is already mirrored
--every-commit build each new commit, as <next patch>-dev.<commit time> (0.4.0 builds
as 0.4.1-dev.20261010183500), so the launcher offers it as an update
--arch <arch> macOS: universal | aarch64 | x86_64 (default: this Mac's)
--keep <n> versions kept per app (default 2)
serve [--mirror <dir>] [--port <n>] [--sync-every <interval> [sync options]]
serve the mirror and its feed on 127.0.0.1 (default port 8787); with
--sync-every (30m, 1h, 1d…), also run `sync` now and then on that interval
status [--root <dir>] [--mirror <dir>]
each app's checked-out version and commit next to what's mirrored
then: ARTCRAFT_LAUNCHER_DOWNLOAD_BASE=http://127.0.0.1:8787/ cargo run -p artcraft-launcher --features local-mirror
";
#[derive(Clone)]
struct Opts {
root: PathBuf,
mirror: PathBuf,
only: Option<Vec<String>>,
pull: bool,
rebuild: bool,
every_commit: bool,
arch: Option<String>,
keep: usize,
port: u16,
/// `serve` also syncs this often (seconds).
sync_every: Option<u64>,
passthrough: Vec<String>,
}
pub fn run(args: &[&str]) -> Result<(), String> {
let (cmd, rest) = args.split_first().ok_or_else(|| USAGE.to_string())?;
let opts = parse(rest)?;
match *cmd {
"sync" => sync(&opts),
"serve" => serve(&opts),
"status" => status(&opts),
"-h" | "--help" | "help" => {
print!("{USAGE}");
Ok(())
}
other => Err(format!("unknown command `local {other}`\n\n{USAGE}")),
}
}
fn parse(args: &[&str]) -> Result<Opts, String> {
let default_root = crate::root().parent().map(Path::to_path_buf).ok_or("this repo has no parent folder")?;
let mut root = None;
let mut mirror = None;
let mut o = Opts {
root: PathBuf::new(),
mirror: PathBuf::new(),
only: None,
pull: true,
rebuild: false,
every_commit: false,
arch: None,
keep: 2,
port: DEFAULT_PORT,
sync_every: None,
passthrough: Vec::new(),
};
let mut it = args.iter();
while let Some(arg) = it.next() {
let mut value = || it.next().map(|v| (*v).to_string()).ok_or_else(|| format!("{arg} needs a value"));
match *arg {
"--root" => root = Some(PathBuf::from(value()?)),
"--mirror" => mirror = Some(PathBuf::from(value()?)),
"--only" => o.only = Some(value()?.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect()),
"--no-pull" => o.pull = false,
"--rebuild" => o.rebuild = true,
"--every-commit" => o.every_commit = true,
"--arch" => o.arch = Some(value()?),
"--keep" => o.keep = value()?.parse().ok().filter(|n| *n >= 1).ok_or("--keep needs a number of at least 1")?,
"--port" => o.port = value()?.parse().map_err(|_| "--port needs a port number")?,
"--sync-every" => o.sync_every = Some(parse_interval(&value()?)?),
"--" => {
o.passthrough = it.by_ref().map(|s| (*s).to_string()).collect();
}
other => return Err(format!("unknown option {other}\n\n{USAGE}")),
}
}
o.root = root.unwrap_or(default_root);
o.mirror = mirror.unwrap_or_else(|| o.root.join("local-mirror"));
if let Some(only) = &o.only
&& let Some(bad) = only.iter().find(|id| !APPS.iter().any(|(a, _)| a == id))
{
return Err(format!("unknown app `{bad}` (known: {})", APPS.iter().map(|a| a.0).collect::<Vec<_>>().join(", ")));
}
Ok(o)
}
fn selected(o: &Opts) -> impl Iterator<Item = &'static (&'static str, &'static str)> + '_ {
APPS.iter().filter(|(id, _)| o.only.as_ref().is_none_or(|only| only.iter().any(|x| x == id)))
}
// ---- sync ---------------------------------------------------------------------------------------
enum Outcome {
Built(String),
UpToDate(String),
Skipped(String),
Failed(String),
}
fn sync(o: &Opts) -> Result<(), String> {
std::fs::create_dir_all(&o.mirror).map_err(|e| format!("couldn't create {} ({e})", o.mirror.display()))?;
let mut results = Vec::new();
for (id, name) in selected(o) {
eprintln!("\n=== {name} ===");
let outcome = sync_one(o, id, name).unwrap_or_else(Outcome::Failed);
results.push((*name, outcome));
}
println!("\nlocal sync: {}", o.mirror.display());
let mut failed = 0;
for (name, outcome) in &results {
let (tag, what) = match outcome {
Outcome::Built(w) => ("built", w),
Outcome::UpToDate(w) => ("ok", w),
Outcome::Skipped(w) => ("skip", w),
Outcome::Failed(w) => {
failed += 1;
("FAIL", w)
}
};
println!(" {tag:<6} {name:<12} {what}");
}
if failed == 0 { Ok(()) } else { Err(format!("{failed} app(s) failed")) }
}
fn sync_one(o: &Opts, id: &str, name: &str) -> Result<Outcome, String> {
let repo = o.root.join(id);
if !repo.join(".git").exists() {
return Ok(Outcome::Skipped(format!("no checkout at {} (git clone git@github.com:storytold/{id}.git)", repo.display())));
}
let mut pull_note = String::new();
if o.pull {
// --ff-only never makes a merge commit in someone's checkout; a diverged or dirty one is
// reported and built as it stands.
let ok = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["pull", "--ff-only", "--no-stat", "--recurse-submodules"])
.status()
.map_err(|e| format!("couldn't run git ({e})"))?
.success();
if !ok {
pull_note = " (pull failed; built the checkout as it is)".into();
}
}
let checkout = checkout_version(&repo)?;
let commit = git(&repo, &["rev-parse", "--short=12", "HEAD"])?;
let app_dir = o.mirror.join(id);
let version = if o.every_commit {
// The newest mirrored build is already this commit: nothing to do.
if !o.rebuild
&& let Some(latest) = latest_version(&app_dir)
&& std::fs::read_to_string(app_dir.join(&latest).join(COMMIT)).is_ok_and(|c| c.trim() == commit)
{
return Ok(Outcome::UpToDate(format!("{latest} is {commit}{pull_note}")));
}
let committed = git(&repo, &["show", "-s", "--format=%ct", "HEAD"])?.parse::<i64>().map_err(|_| "unreadable commit time")?;
dev_version(&checkout, committed)?
} else {
checkout
};
let dest = app_dir.join(&version);
if !o.rebuild && dest.join(COMPLETE).is_file() {
let built_from = std::fs::read_to_string(dest.join(COMMIT)).unwrap_or_default();
let note = if built_from.trim() == commit {
String::new()
} else {
format!(
"; {commit} is newer than the mirrored {} but has the same version (--every-commit builds each commit, --rebuild replaces this one)",
built_from.trim()
)
};
return Ok(Outcome::UpToDate(format!("{version} already mirrored{note}{pull_note}")));
}
// Build into a scratch folder, then swap it in, so a failed build never leaves half a version.
let building = app_dir.join(format!(".building-{version}"));
let _ = std::fs::remove_dir_all(&building);
std::fs::create_dir_all(&building).map_err(|e| format!("couldn't create {} ({e})", building.display()))?;
let mut cmd = package_command(&repo, o)?;
cmd.current_dir(&repo).env("DIST", &building);
if o.every_commit {
// Every app's packaging reads <APP>_VERSION in place of its Cargo.toml version, and keeps
// the full version (with the -dev suffix) in the installed copy, where the launcher reads it.
cmd.env(format!("{}_VERSION", id.to_ascii_uppercase()), &version);
}
eprintln!("$ {name}: packaging {version} ({commit})");
let status = cmd.status().map_err(|e| format!("couldn't run the packaging script ({e})"))?;
if !status.success() {
let _ = std::fs::remove_dir_all(&building);
return Err(format!("packaging failed ({status}){pull_note}"));
}
let files = asset_files(&building)?;
if files.is_empty() {
let _ = std::fs::remove_dir_all(&building);
return Err("packaging produced no files".into());
}
write_sums(&building, &files)?;
let previous = latest_version(&app_dir).filter(|v| *v != version);
let notes = notes(&repo, previous.as_ref().and_then(|v| std::fs::read_to_string(app_dir.join(v).join(COMMIT)).ok()).as_deref(), &commit);
write(&building.join(NOTES), &notes)?;
write(&building.join(COMMIT), &commit)?;
write(&building.join(COMPLETE), "")?;
let _ = std::fs::remove_dir_all(&dest);
std::fs::rename(&building, &dest).map_err(|e| format!("couldn't move the build into place ({e})"))?;
prune(&app_dir, o.keep);
Ok(Outcome::Built(format!("{version} ({commit}, {} files){pull_note}", files.len())))
}
/// This OS's packaging script for the checkout at `repo`.
fn package_command(repo: &Path, o: &Opts) -> Result<Command, String> {
let mut cmd = if cfg!(target_os = "macos") {
let mut c = Command::new("bash");
c.arg(repo.join("packaging/macos/package.sh"));
let arch = o.arch.clone().unwrap_or_else(|| if cfg!(target_arch = "aarch64") { "aarch64".into() } else { "x86_64".into() });
c.args(["--arch", &arch]);
c
} else if cfg!(target_os = "linux") || cfg!(target_os = "freebsd") {
let os = if cfg!(target_os = "linux") { "linux" } else { "freebsd" };
let mut c = Command::new("bash");
c.arg(repo.join(format!("packaging/{os}/package.sh")));
c
} else if cfg!(windows) {
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(repo.join("packaging/windows/package.ps1"));
c
} else {
return Err("no packaging script for this OS".into());
};
cmd.args(&o.passthrough);
Ok(cmd)
}
/// `[workspace.package] version` (or `[package]`) of the checkout.
fn checkout_version(repo: &Path) -> Result<String, String> {
let manifest = std::fs::read_to_string(repo.join("Cargo.toml")).map_err(|e| format!("couldn't read Cargo.toml ({e})"))?;
crate::version::read(&manifest)
}
fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git").arg("-C").arg(repo).args(args).output().map_err(|e| format!("couldn't run git ({e})"))?;
if !out.status.success() {
return Err(format!("git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// Release notes: the commits since the last mirrored build (or the last 20).
fn notes(repo: &Path, since: Option<&str>, commit: &str) -> String {
let range = since.map(str::trim).filter(|s| !s.is_empty()).map(|s| format!("{s}..HEAD"));
let mut args = vec!["log", "--no-merges", "--format=* %s (%h)"];
match &range {
Some(r) => args.push(r),
None => args.push("-20"),
}
let log = git(repo, &args).unwrap_or_default();
let mut out = format!("Built locally from `{commit}`.\n");
if !log.is_empty() {
out.push_str("\n## Changes\n");
out.push_str(&log);
out.push('\n');
}
out
}
/// Keeps the newest `keep` complete versions of an app.
fn prune(app_dir: &Path, keep: usize) {
let mut versions = complete_versions(app_dir);
versions.sort_by(|a, b| compare_versions(b, a));
for old in versions.iter().skip(keep) {
let _ = std::fs::remove_dir_all(app_dir.join(old));
}
}
fn write(path: &Path, text: &str) -> Result<(), String> {
std::fs::write(path, text).map_err(|e| format!("couldn't write {} ({e})", path.display()))
}
// ---- status -------------------------------------------------------------------------------------
fn status(o: &Opts) -> Result<(), String> {
println!("{:<12} {:<12} {:<14} {:<12} built from", "app", "checkout", "commit", "mirrored");
for (id, name) in selected(o) {
let repo = o.root.join(id);
let (version, commit) = if repo.join(".git").exists() {
(checkout_version(&repo).unwrap_or_else(|_| "?".into()), git(&repo, &["rev-parse", "--short=12", "HEAD"]).unwrap_or_else(|_| "?".into()))
} else {
("-".into(), "-".into())
};
let app_dir = o.mirror.join(id);
let mirrored = latest_version(&app_dir);
let built_from = mirrored.as_ref().and_then(|v| std::fs::read_to_string(app_dir.join(v).join(COMMIT)).ok()).unwrap_or_default();
println!("{name:<12} {version:<12} {commit:<14} {:<12} {}", mirrored.as_deref().unwrap_or("-"), built_from.trim());
}
Ok(())
}
// ---- mirror contents ----------------------------------------------------------------------------
fn complete_versions(app_dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(app_dir) else { return Vec::new() };
entries
.flatten()
.filter(|e| e.path().join(COMPLETE).is_file())
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.filter(|v| parse_version(v).is_some())
.collect()
}
fn latest_version(app_dir: &Path) -> Option<String> {
complete_versions(app_dir).into_iter().max_by(|a, b| compare_versions(a, b))
}
/// The installable files in a version folder (everything but our own bookkeeping).
fn asset_files(dir: &Path) -> Result<Vec<String>, String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("couldn't read {} ({e})", dir.display()))?;
let mut names: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_file())
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.filter(|n| !n.starts_with('.') && n != SUMS && n != NOTES && safe_name(n))
.collect();
names.sort();
Ok(names)
}
fn write_sums(dir: &Path, files: &[String]) -> Result<(), String> {
let mut sums = String::new();
for name in files {
let _ = writeln!(sums, "{} {name}", sha256(&dir.join(name))?);
}
write(&dir.join(SUMS), &sums)
}
fn read_sums(dir: &Path) -> BTreeMap<String, String> {
std::fs::read_to_string(dir.join(SUMS))
.unwrap_or_default()
.lines()
.filter_map(|l| {
let (hash, name) = l.split_once(char::is_whitespace)?;
Some((name.trim().trim_start_matches('*').to_string(), hash.to_ascii_lowercase()))
})
.collect()
}
/// SHA-256 through the system's own tool (files are hundreds of megabytes).
fn sha256(path: &Path) -> Result<String, String> {
let out = if cfg!(windows) {
Command::new("certutil").arg("-hashfile").arg(path).arg("SHA256").output()
} else if cfg!(target_os = "macos") {
Command::new("shasum").args(["-a", "256"]).arg(path).output()
} else {
Command::new("sha256sum").arg(path).output()
}
.map_err(|e| format!("couldn't hash {} ({e})", path.display()))?;
let text = String::from_utf8_lossy(&out.stdout);
// shasum/sha256sum: "<hash> <file>"; certutil: the hash alone on the second line.
text.split_whitespace()
.find(|w| w.len() == 64 && w.bytes().all(|b| b.is_ascii_hexdigit()))
.map(str::to_ascii_lowercase)
.ok_or_else(|| format!("couldn't hash {}", path.display()))
}
// ---- feed ---------------------------------------------------------------------------------------
/// A feed (ArtCraft's `release_info` shape) for the newest complete version of every app.
fn feed(mirror: &Path, base: &str) -> String {
let now = rfc3339(unix_now());
let mut apps = String::new();
for (id, name) in APPS {
let app_dir = mirror.join(id);
let Some(version) = latest_version(&app_dir) else { continue };
let dir = app_dir.join(&version);
let Ok(files) = asset_files(&dir) else { continue };
let sums = read_sums(&dir);
let mut assets = Vec::new();
for f in &files {
let size = std::fs::metadata(dir.join(f)).map(|m| m.len()).unwrap_or(0);
let sha = sums.get(f).map_or_else(|| "null".into(), |h| json_str(h));
assets.push(format!(
r#"{{"name":{},"size_bytes":{size},"download_url":{},"sha256":{sha}}}"#,
json_str(f),
json_str(&format!("{base}{id}/{version}/{f}"))
));
}
let built = std::fs::metadata(dir.join(COMPLETE)).and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok());
let published = built.map_or_else(|| now.clone(), |d| rfc3339(i64::try_from(d.as_secs()).unwrap_or(0)));
let notes = std::fs::read_to_string(dir.join(NOTES)).unwrap_or_default();
if !apps.is_empty() {
apps.push(',');
}
let _ = write!(
apps,
r#"{}:{{"name":{},"repo":{},"latest_release":{{"tag":{},"name":{},"published_at":{},"html_url":"","prerelease":false,"notes_markdown":{},"assets":[{}]}},"app_last_fetched":{}}}"#,
json_str(id),
json_str(name),
json_str(&format!("storytold/{id}")),
json_str(&format!("v{version}")),
json_str(&format!("{name} v{version} (local build)")),
json_str(&published),
json_str(&notes),
assets.join(","),
json_str(&now),
);
}
format!(
r#"{{"success":true,"release_info":{{"apps":{{{apps}}},"last_fetched":{}}},"app_renames":[],"repo_renames":[],"important_notices":{{"announcements":[],"alerts":[]}}}}"#,
json_str(&now)
)
}
fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn unix_now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0))
}
/// `2026-10-10T12:00:00Z` (civil-from-days, Howard Hinnant's algorithm).
fn rfc3339(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(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);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}
// ---- versions -----------------------------------------------------------------------------------
/// `1.2.3` or `1.2.3-pre.4`: the numeric core and the pre-release, if any.
fn parse_version(v: &str) -> Option<([u64; 3], Option<String>)> {
let (core, pre) = match v.split_once('-') {
Some((c, p)) => (c, Some(p.to_string())),
None => (v, None),
};
let mut parts = core.split('.').map(|p| p.parse::<u64>().ok());
let n = [parts.next()??, parts.next()??, parts.next()??];
parts.next().is_none().then_some((n, pre))
}
/// The version a build of an unreleased commit gets: after `checkout` and before whatever is
/// released next, ordered by commit time. `0.4.0` → `0.4.1-dev.20261010183500`;
/// `1.0.0-rc.1` → `1.0.0-rc.1.dev.20261010183500`.
fn dev_version(checkout: &str, committed: i64) -> Result<String, String> {
let ([major, minor, patch], pre) = parse_version(checkout).ok_or_else(|| format!("can't read version `{checkout}`"))?;
let stamp: String = rfc3339(committed).chars().filter(char::is_ascii_digit).collect();
Ok(match pre {
None => format!("{major}.{minor}.{}-dev.{stamp}", patch.checked_add(1).ok_or("version too large")?),
Some(pre) => format!("{major}.{minor}.{patch}-{pre}.dev.{stamp}"),
})
}
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (parse_version(a), parse_version(b)) {
(Some((na, pa)), Some((nb, pb))) => na.cmp(&nb).then_with(|| match (pa, pb) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(x), Some(y)) => x.cmp(&y),
}),
_ => a.cmp(b),
}
}
// ---- serve --------------------------------------------------------------------------------------
fn serve(o: &Opts) -> Result<(), String> {
let listener = TcpListener::bind(("127.0.0.1", o.port)).map_err(|e| format!("couldn't listen on 127.0.0.1:{} ({e})", o.port))?;
let base = format!("http://127.0.0.1:{}/", o.port);
println!("serving {} at {base}", o.mirror.display());
println!("feed: {base}release_info.json");
println!("launch: ARTCRAFT_LAUNCHER_DOWNLOAD_BASE={base} cargo run -p artcraft-launcher --features local-mirror");
if let Some(every) = o.sync_every {
// Syncs run one after another on their own thread, so a long build never holds up a
// download; a version only joins the feed once its build is complete.
let opts = o.clone();
std::thread::spawn(move || {
loop {
if let Err(e) = sync(&opts) {
eprintln!("sync: {e}");
}
eprintln!("next sync in {}", describe_interval(every));
std::thread::sleep(std::time::Duration::from_secs(every));
}
});
}
for stream in listener.incoming().flatten() {
let (mirror, base) = (o.mirror.clone(), base.clone());
std::thread::spawn(move || {
if let Err(e) = handle(stream, &mirror, &base) {
eprintln!(" {e}");
}
});
}
Ok(())
}
fn handle(stream: TcpStream, mirror: &Path, base: &str) -> Result<(), String> {
let io = |e: std::io::Error| e.to_string();
let mut reader = BufReader::new(stream.try_clone().map_err(io)?);
let mut line = String::new();
reader.read_line(&mut line).map_err(io)?;
let mut parts = line.split_whitespace();
let (method, target) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
let mut range_start: Option<u64> = None;
loop {
let mut h = String::new();
if reader.read_line(&mut h).map_err(io)? == 0 || h.trim().is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':')
&& k.trim().eq_ignore_ascii_case("range")
{
range_start = v.trim().strip_prefix("bytes=").and_then(|r| r.strip_suffix('-')).and_then(|n| n.parse().ok());
}
}
let mut out = stream;
let head = method == "HEAD";
if method != "GET" && !head {
return respond(&mut out, "405 Method Not Allowed", "text/plain", b"", head);
}
let path = target.split(['?', '#']).next().unwrap_or("").trim_start_matches('/');
eprintln!("{method} /{path}");
if path == "release_info.json" {
return respond(&mut out, "200 OK", "application/json", feed(mirror, base).as_bytes(), head);
}
// Only <app>/<version>/<file>, and only plain names: nothing outside the mirror.
let segments: Vec<&str> = path.split('/').collect();
if segments.len() != 3 || !segments.iter().all(|s| safe_name(s)) || segments.iter().any(|s| s.starts_with('.')) {
return respond(&mut out, "404 Not Found", "text/plain", b"not found", head);
}
let file_path = mirror.join(segments.join("/"));
let Ok(mut file) = std::fs::File::open(&file_path) else {
return respond(&mut out, "404 Not Found", "text/plain", b"not found", head);
};
let size = file.metadata().map_err(io)?.len();
let start = range_start.unwrap_or(0);
if start > 0 && start >= size {
let header = format!("HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */{size}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
return out.write_all(header.as_bytes()).map_err(io);
}
let header = if start > 0 {
format!(
"HTTP/1.1 206 Partial Content\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{}/{size}\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n",
size - start,
size.saturating_sub(1)
)
} else {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {size}\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n"
)
};
out.write_all(header.as_bytes()).map_err(io)?;
if head {
return Ok(());
}
file.seek(SeekFrom::Start(start)).map_err(io)?;
std::io::copy(&mut file.take(size - start), &mut out).map(|_| ()).map_err(io)
}
/// `90s`, `30m`, `1h`, `1d` (or plain seconds); at least a minute.
fn parse_interval(text: &str) -> Result<u64, String> {
let text = text.trim();
let split = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
let (number, unit) = text.split_at(split);
let n: u64 = number.parse().map_err(|_| format!("`{text}` isn't an interval (try 30m, 1h or 1d)"))?;
let unit = match unit {
"" | "s" => 1,
"m" => 60,
"h" => 3600,
"d" => 86_400,
_ => return Err(format!("`{text}` isn't an interval (try 30m, 1h or 1d)")),
};
n.checked_mul(unit).filter(|s| *s >= 60).ok_or_else(|| format!("--sync-every {text}: use at least a minute"))
}
fn describe_interval(secs: u64) -> String {
match secs {
s if s % 86_400 == 0 => format!("{}d", s / 86_400),
s if s % 3600 == 0 => format!("{}h", s / 3600),
s if s % 60 == 0 => format!("{}m", s / 60),
s => format!("{s}s"),
}
}
fn respond(out: &mut TcpStream, status: &str, kind: &str, body: &[u8], head: bool) -> Result<(), String> {
let header = format!(
"HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
body.len()
);
out.write_all(header.as_bytes()).map_err(|e| e.to_string())?;
if !head {
out.write_all(body).map_err(|e| e.to_string())?;
}
Ok(())
}
fn safe_name(s: &str) -> bool {
!s.is_empty() && s != "." && s != ".." && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates_and_versions() {
assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339(1_791_500_000), "2026-10-08T22:53:20Z");
assert_eq!(compare_versions("0.10.0", "0.9.0"), std::cmp::Ordering::Greater);
assert_eq!(compare_versions("1.0.0", "1.0.0-rc.1"), std::cmp::Ordering::Greater);
assert!(parse_version("1.2").is_none() && parse_version(".building-1.2.3").is_none());
}
#[test]
fn writes_a_feed_the_launcher_reads() {
let mirror = std::env::temp_dir().join(format!("xtask-local-{}", std::process::id()));
let dir = mirror.join("photocraft").join("0.6.0");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("photocraft-0.6.0-macos-aarch64.dmg"), b"dmg").unwrap();
std::fs::write(dir.join(SUMS), format!("{} photocraft-0.6.0-macos-aarch64.dmg\n", "ab".repeat(32))).unwrap();
std::fs::write(dir.join(NOTES), "Built \"locally\".\n").unwrap();
// Unfinished builds are invisible.
std::fs::create_dir_all(mirror.join("photocraft").join("0.7.0")).unwrap();
std::fs::write(dir.join(COMPLETE), "").unwrap();
let json = feed(&mirror, "http://127.0.0.1:8787/");
let _ = std::fs::remove_dir_all(&mirror);
assert!(json.contains(r#""tag":"v0.6.0""#), "{json}");
assert!(json.contains(r#""download_url":"http://127.0.0.1:8787/photocraft/0.6.0/photocraft-0.6.0-macos-aarch64.dmg""#));
assert!(json.contains(r#""size_bytes":3"#) && json.contains(&"ab".repeat(32)));
assert!(json.contains(r#"Built \"locally\".\n"#));
assert!(!json.contains("0.7.0"));
}
#[test]
fn dev_versions_sit_between_releases_and_follow_commit_time() {
const T: i64 = 1_791_500_000; // 2026-10-08T22:53:20Z
assert_eq!(dev_version("0.4.0", T).as_deref(), Ok("0.4.1-dev.20261008225320"));
assert_eq!(dev_version("1.0.0-rc.1", T).as_deref(), Ok("1.0.0-rc.1.dev.20261008225320"));
let (dev, later) = (dev_version("0.4.0", T).unwrap(), dev_version("0.4.0", T + 60).unwrap());
assert_eq!(compare_versions(&dev, "0.4.0"), std::cmp::Ordering::Greater);
assert_eq!(compare_versions(&later, &dev), std::cmp::Ordering::Greater);
assert_eq!(compare_versions("0.4.1", &later), std::cmp::Ordering::Greater);
assert!(dev_version("main", T).is_err());
}
#[test]
fn intervals() {
assert_eq!(parse_interval("30m"), Ok(1800));
assert_eq!(parse_interval("1h"), Ok(3600));
assert_eq!(parse_interval("2d"), Ok(172_800));
assert_eq!(parse_interval("120"), Ok(120));
for bad in ["", "h", "10s", "1w", "-1h", "99999999999999999999d"] {
assert!(parse_interval(bad).is_err(), "{bad}");
}
assert_eq!(describe_interval(5400), "90m");
}
#[test]
fn only_plain_names_are_served() {
assert!(safe_name("photocraft-0.6.0-macos-universal.dmg"));
for bad in ["", ".", "..", "a/b", "a\\b", "a%2e"] {
assert!(!safe_name(bad), "{bad}");
}
}
}

View File

@ -5,7 +5,6 @@
mod icns;
mod ico;
mod local;
mod version;
use std::path::{Path, PathBuf};
@ -23,9 +22,6 @@ commands:
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
icns <out.icns> <in.png>...
pack square PNGs (16-1024 px) into a macOS .icns, on any OS
local sync | serve | status
build the Craft apps from their checkouts and install them from this machine
(cargo xtask local help)
";
/// Packages that must build for wasm32-unknown-unknown: the pure core and the web app (which
@ -41,7 +37,6 @@ fn main() -> ExitCode {
Some("version") => version::run(&root(), &rest),
Some("ico") => ico::run(&rest),
Some("icns") => icns::run(&rest),
Some("local") => local::run(&rest),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())