Compare commits
81 Commits
bt-service
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
1579aa8c02 | |
|
|
cbae640688 | |
|
|
d6fdab6596 | |
|
|
1aaf7b27be | |
|
|
2b86d4ca52 | |
|
|
cb82e851cd | |
|
|
0e87e90aef | |
|
|
78ef4a8c77 | |
|
|
1c03e2d2ea | |
|
|
cf573b2d35 | |
|
|
250cc54335 | |
|
|
93f12a5d9e | |
|
|
57105f8445 | |
|
|
94d6480a11 | |
|
|
6d59370024 | |
|
|
a464e432b3 | |
|
|
d4c61ec110 | |
|
|
155d6bd6b0 | |
|
|
a702ef16c8 | |
|
|
0c3e639b4d | |
|
|
41ded15851 | |
|
|
98c31aa066 | |
|
|
c064fde960 | |
|
|
96ed437966 | |
|
|
e49bdc94dc | |
|
|
0358cda174 | |
|
|
7938ed90ac | |
|
|
ba8a704908 | |
|
|
1f92b7db17 | |
|
|
80f70a1a33 | |
|
|
31a5e426e7 | |
|
|
165f5358e0 | |
|
|
93caf01c4e | |
|
|
7ba85e54f0 | |
|
|
6124cf0693 | |
|
|
257e16c325 | |
|
|
6e7a122866 | |
|
|
b66486df82 | |
|
|
41369d3416 | |
|
|
c273a94a9a | |
|
|
e2b01e7425 | |
|
|
c1a9b36f82 | |
|
|
c5b073f5f7 | |
|
|
8d76d193db | |
|
|
e69164d8e5 | |
|
|
648546e942 | |
|
|
6f9722c91a | |
|
|
714522d577 | |
|
|
5b7d4781f5 | |
|
|
92e23e0348 | |
|
|
2b1b4412e5 | |
|
|
89854ff8f2 | |
|
|
bd4fd187c7 | |
|
|
5ee17dae1f | |
|
|
abcc9ca649 | |
|
|
755a557f12 | |
|
|
58be7edfbd | |
|
|
136c4ade3b | |
|
|
28ef3ad924 | |
|
|
9eb2c60f18 | |
|
|
dba8ea12cc | |
|
|
4003ecd809 | |
|
|
e8614afa3f | |
|
|
633f09c7cc | |
|
|
797071e591 | |
|
|
bd1182e75c | |
|
|
a0b301b8df | |
|
|
bf452db810 | |
|
|
cf5604dc65 | |
|
|
8d855ecbcb | |
|
|
5f74027176 | |
|
|
7a193042fd | |
|
|
f464b8145e | |
|
|
b9397ff845 | |
|
|
bee2ffc283 | |
|
|
e91d26d99f | |
|
|
3a1140e949 | |
|
|
9a961dc4de | |
|
|
182530fbc8 | |
|
|
b42d1f49ec | |
|
|
4a86071159 |
|
|
@ -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@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# 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@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
|
|
@ -60,12 +60,12 @@ jobs:
|
|||
env:
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: wasm32
|
||||
- name: Check wasm-safe crates
|
||||
|
|
|
|||
|
|
@ -25,35 +25,5 @@ permissions:
|
|||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build, test and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
# egui/winit/wgpu libraries. The tray (ksni) and notifications (notify-rust over zbus)
|
||||
# are pure-Rust D-Bus clients: no gtk3, libxdo, appindicator or libdbus needed.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked -- --test-threads=1
|
||||
# The release package, as release.yml builds it. Free the debug build's disk first.
|
||||
rm -rf target/debug
|
||||
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/artcraft-launcher-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/artcraft-launcher$|/share/applications/ai\.storyteller\.artcraft-launcher\.desktop$|/share/doc/artcraft-launcher/LICENSE-MIT$'
|
||||
# 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
|
||||
|
|
@ -19,61 +19,5 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
- name: FreeBSD package layout (dry run)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
./packaging/windows/check-icons.ps1
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/artcraft-launcher.wxs packaging/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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
# 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:
|
||||
|
|
@ -20,389 +11,32 @@ on:
|
|||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
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:
|
||||
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"
|
||||
# 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 }}
|
||||
|
|
|
|||
|
|
@ -23,92 +23,12 @@ concurrency:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
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'
|
||||
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
|
||||
|
|
|
|||
42
AGENTS.md
42
AGENTS.md
|
|
@ -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). It is written in **Rust only** (no JavaScript or TypeScript): the desktop
|
||||
CADCraft, SoundCraft, FigCraft). 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,9 +58,20 @@ 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
|
||||
|
||||
|
|
@ -69,6 +80,23 @@ 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,
|
||||
|
|
@ -110,7 +138,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}` |
|
||||
| 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}` |
|
||||
| FreeBSD | `artcraft-launcher-<ver>-freebsd-x86_64.tar.gz` |
|
||||
| Web | `artcraft-launcher-web-<ver>.zip` |
|
||||
|
||||
|
|
@ -130,6 +158,16 @@ 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.
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ 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) |
|
||||
|
|
@ -40,6 +42,7 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
|||
|
||||
[[package]]
|
||||
name = "artcraft-launcher"
|
||||
version = "0.1.2"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"artcraft-launcher-core",
|
||||
"artcraft-launcher-native",
|
||||
|
|
@ -290,13 +290,17 @@ 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.1.2"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
|
@ -305,14 +309,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "artcraft-launcher-native"
|
||||
version = "0.1.2"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"artcraft-launcher-core",
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"log",
|
||||
"plist",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -324,7 +327,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "artcraft-launcher-ui-egui"
|
||||
version = "0.1.2"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"artcraft-launcher-core",
|
||||
"egui",
|
||||
|
|
@ -333,11 +336,14 @@ dependencies = [
|
|||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa",
|
||||
"sys-locale",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft-launcher-web"
|
||||
version = "0.1.2"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"artcraft-launcher-core",
|
||||
"artcraft-launcher-ui-egui",
|
||||
|
|
@ -848,7 +854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2279,6 +2285,8 @@ dependencies = [
|
|||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3925,6 +3933,33 @@ 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"
|
||||
|
|
@ -4342,6 +4377,15 @@ 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"
|
||||
|
|
@ -4766,6 +4810,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
]
|
||||
|
|
@ -5137,6 +5182,15 @@ 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"
|
||||
|
|
@ -5749,7 +5803,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.2"
|
||||
version = "0.4.1"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ resolver = "3"
|
|||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
version = "0.4.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -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 and SoundCraft: the right build for your computer, kept up to date.
|
||||
DeckCraft, GridCraft, CADCraft, SoundCraft and FigCraft: 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, grouped into classic and new apps" 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" 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 what each app is inspired by, its 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 each app's status, installed and latest versions, and release date" width="900"><br>
|
||||
<sub>Switch between the grid and a sortable table.</sub>
|
||||
</p>
|
||||
|
||||
|
|
@ -82,6 +82,27 @@ 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
|
||||
|
|
@ -93,6 +114,7 @@ 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**
|
||||
|
|
@ -102,6 +124,12 @@ 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`.
|
||||
|
|
@ -128,9 +156,11 @@ 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 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}.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}.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)
|
||||
|
|
@ -170,12 +200,31 @@ 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 and which app you were installing) or tell us on
|
||||
> issues (include your OS, which app you were installing and the launcher's log file) or tell us on
|
||||
> [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## The Craft App Suite
|
||||
|
|
@ -194,10 +243,11 @@ 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) | [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) |
|
||||
| <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) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
|
@ -263,8 +313,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] }
|
||||
|
|
|
|||
|
|
@ -3,17 +3,72 @@
|
|||
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};
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings, i18n};
|
||||
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 {
|
||||
|
|
@ -41,8 +96,14 @@ pub fn start() {
|
|||
});
|
||||
}
|
||||
|
||||
struct NoHost;
|
||||
impl Host for NoHost {}
|
||||
/// 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 WebApp {
|
||||
ui: LauncherUi,
|
||||
|
|
@ -50,11 +111,15 @@ struct WebApp {
|
|||
|
||||
impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.ui.logic(ctx, &mut NoHost);
|
||||
self.ui.logic(ctx, &mut WebHost);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.ui.ui(ui, &mut NoHost);
|
||||
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);
|
||||
}
|
||||
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
|
|
@ -68,11 +133,43 @@ 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 }
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
fn check(&self) {
|
||||
|
|
@ -111,6 +208,49 @@ 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 {
|
||||
|
|
@ -158,7 +298,13 @@ fn visitor_platform() -> Option<Platform> {
|
|||
} else {
|
||||
return None;
|
||||
};
|
||||
let arch = if ua.contains("aarch64") || ua.contains("arm64") || ua.contains("armv8") { Arch::Arm64 } else { Arch::X64 };
|
||||
let arch = if ua.contains("aarch64") || ua.contains("arm64") || ua.contains("armv8") {
|
||||
Arch::Arm64
|
||||
} else if ua.contains("riscv") {
|
||||
Arch::Riscv64
|
||||
} else {
|
||||
Arch::X64
|
||||
};
|
||||
Some(Platform { os, arch })
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +312,7 @@ 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;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ 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 }
|
||||
|
|
@ -22,18 +26,36 @@ 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). 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) 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.
|
||||
[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", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
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"] }
|
||||
|
||||
[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"] }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
//! 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(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
|
@ -0,0 +1,113 @@
|
|||
//! 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]));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
//! 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
//! 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};
|
||||
|
|
@ -20,6 +22,8 @@ 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> {
|
||||
|
|
@ -36,21 +40,31 @@ 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.
|
||||
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);
|
||||
/// `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);
|
||||
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
|
||||
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 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 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 = TrayIconBuilder::new()
|
||||
let icon = identity(TrayIconBuilder::new())
|
||||
.with_menu(Box::new(menu))
|
||||
.with_icon(load_icon()?)
|
||||
.with_icon_as_template(cfg!(target_os = "macos"))
|
||||
|
|
@ -76,6 +90,9 @@ impl Tray {
|
|||
} else {
|
||||
return;
|
||||
};
|
||||
if matches!(action, TrayAction::Quit) {
|
||||
on_quit();
|
||||
}
|
||||
let _ = tx.send(action);
|
||||
wake();
|
||||
}));
|
||||
|
|
@ -86,7 +103,15 @@ impl Tray {
|
|||
wake();
|
||||
}
|
||||
}));
|
||||
Ok(Self { icon, show: show_id, check: check_id, quit: quit_id, rx, tooltip: String::new() })
|
||||
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 })
|
||||
}
|
||||
|
||||
pub fn actions(&self) -> Vec<TrayAction> {
|
||||
|
|
@ -94,10 +119,17 @@ impl Tray {
|
|||
}
|
||||
|
||||
pub fn set_updates(&mut self, count: usize) {
|
||||
let tip = match count {
|
||||
0 => "ArtCraft Launcher".to_string(),
|
||||
1 => "ArtCraft Launcher · 1 update available".to_string(),
|
||||
n => format!("ArtCraft Launcher · {n} updates available"),
|
||||
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")
|
||||
};
|
||||
if tip != self.tooltip {
|
||||
let _ = self.icon.set_tooltip(Some(&tip));
|
||||
|
|
@ -110,3 +142,20 @@ 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.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -1,15 +1,18 @@
|
|||
//! The Craft App Suite: the twelve apps the launcher knows about.
|
||||
//! The Craft App Suite: the thirteen 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, each with a page on getartcraft.com.
|
||||
/// The seven original apps.
|
||||
Classic,
|
||||
/// The five apps added after them.
|
||||
/// The six apps added after them.
|
||||
New,
|
||||
}
|
||||
|
||||
|
|
@ -29,10 +32,8 @@ pub struct CraftApp {
|
|||
pub id: &'static str,
|
||||
/// Display name (`PhotoCraft`).
|
||||
pub name: &'static str,
|
||||
/// What it's for, in a few words.
|
||||
/// What it's for, in a few words (English; the UI translates it by app id).
|
||||
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],
|
||||
|
|
@ -44,14 +45,11 @@ 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)
|
||||
|
|
@ -65,9 +63,9 @@ impl CraftApp {
|
|||
format!("https://github.com/{}", self.repo)
|
||||
}
|
||||
|
||||
/// The app's page on getartcraft.com (classic apps only).
|
||||
pub fn website(&self) -> Option<String> {
|
||||
(self.group == AppGroup::Classic).then(|| format!("https://getartcraft.com/apps/{}", self.id))
|
||||
/// The app's page on getartcraft.com.
|
||||
pub fn website(&self) -> String {
|
||||
format!("https://getartcraft.com/apps/{}", self.id)
|
||||
}
|
||||
|
||||
/// Asset stems this app's releases may use, current first.
|
||||
|
|
@ -75,6 +73,11 @@ 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)
|
||||
}
|
||||
|
|
@ -84,21 +87,19 @@ 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, like, group, color, ink_light, ink_dark, repo, former_ids: &[] }
|
||||
CraftApp { id, name, tagline, group, color, ink_light, ink_dark, repo, former_ids: &[], former_names: &[] }
|
||||
}
|
||||
|
||||
/// The Craft App Suite, in display order: the seven classic apps, then the five new ones.
|
||||
pub const SUITE: [CraftApp; 12] = [
|
||||
/// The Craft App Suite, in display order: the seven classic apps, then the six new ones.
|
||||
pub const SUITE: [CraftApp; 13] = [
|
||||
app(
|
||||
"photocraft",
|
||||
"PhotoCraft",
|
||||
"Image editing",
|
||||
"Adobe Photoshop",
|
||||
AppGroup::Classic,
|
||||
[[0x2f, 0x7b, 0xf5], [0x1a, 0x5b, 0xc4], [0x7f, 0xb0, 0xff]],
|
||||
"storytold/photocraft",
|
||||
|
|
@ -107,7 +108,6 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"vectorcraft",
|
||||
"VectorCraft",
|
||||
"Vector illustration",
|
||||
"Adobe Illustrator",
|
||||
AppGroup::Classic,
|
||||
[[0xe8, 0x57, 0x3f], [0xb8, 0x3a, 0x24], [0xff, 0x8a, 0x70]],
|
||||
"storytold/vectorcraft",
|
||||
|
|
@ -116,7 +116,6 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"filmcraft",
|
||||
"FilmCraft",
|
||||
"Video editing, color and sound",
|
||||
"Adobe Premiere Pro",
|
||||
AppGroup::Classic,
|
||||
[[0x8b, 0x5c, 0xf6], [0x6a, 0x3f, 0xd6], [0xb6, 0x9c, 0xff]],
|
||||
"storytold/filmcraft",
|
||||
|
|
@ -125,18 +124,17 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"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",
|
||||
|
|
@ -146,7 +144,6 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"effectcraft",
|
||||
"EffectCraft",
|
||||
"Motion graphics and visual effects",
|
||||
"Adobe After Effects",
|
||||
AppGroup::Classic,
|
||||
[[0xe0, 0x36, 0x8f], [0xb0, 0x20, 0x6c], [0xff, 0x7a, 0xb8]],
|
||||
"storytold/effectcraft",
|
||||
|
|
@ -155,7 +152,6 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"designcraft",
|
||||
"DesignCraft",
|
||||
"Page layout and publishing",
|
||||
"Adobe InDesign",
|
||||
AppGroup::Classic,
|
||||
[[0x7b, 0xb5, 0x1c], [0x45, 0x70, 0x08], [0xa6, 0xe0, 0x4a]],
|
||||
"storytold/designcraft",
|
||||
|
|
@ -164,7 +160,6 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"wordcraft",
|
||||
"WordCraft",
|
||||
"Writing and document design",
|
||||
"Microsoft Word",
|
||||
AppGroup::New,
|
||||
[[0x3f, 0x5f, 0xe0], [0x2a, 0x44, 0xb8], [0x8e, 0xa2, 0xff]],
|
||||
"storytold/wordcraft",
|
||||
|
|
@ -173,7 +168,6 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"deckcraft",
|
||||
"DeckCraft",
|
||||
"Presentations and slide shows",
|
||||
"Microsoft PowerPoint",
|
||||
AppGroup::New,
|
||||
[[0xf0, 0x7a, 0x1e], [0xa8, 0x4f, 0x0a], [0xff, 0xa0, 0x60]],
|
||||
"storytold/deckcraft",
|
||||
|
|
@ -182,7 +176,6 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"gridcraft",
|
||||
"GridCraft",
|
||||
"Spreadsheets and data",
|
||||
"Microsoft Excel",
|
||||
AppGroup::New,
|
||||
[[0x1f, 0x9d, 0x55], [0x12, 0x70, 0x3a], [0x4f, 0xd6, 0x8a]],
|
||||
"storytold/gridcraft",
|
||||
|
|
@ -191,7 +184,6 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"cadcraft",
|
||||
"CADCraft",
|
||||
"Computer-aided design and drafting",
|
||||
"Autodesk AutoCAD",
|
||||
AppGroup::New,
|
||||
[[0x3e, 0xa6, 0xe8], [0x1a, 0x6f, 0xa8], [0x7c, 0xc8, 0xff]],
|
||||
"storytold/cadcraft",
|
||||
|
|
@ -200,23 +192,25 @@ pub const SUITE: [CraftApp; 12] = [
|
|||
"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.
|
||||
|
|
@ -238,7 +232,6 @@ 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",
|
||||
|
|
@ -254,9 +247,9 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn suite_has_seven_classic_and_five_new_apps_with_unique_ids() {
|
||||
fn suite_has_seven_classic_and_six_new_apps_with_unique_ids() {
|
||||
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::Classic).count(), 7);
|
||||
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::New).count(), 5);
|
||||
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::New).count(), 6);
|
||||
let mut ids: Vec<_> = SUITE.iter().map(|a| a.id).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
|
|
@ -267,22 +260,12 @@ 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.website().as_deref(), Some("https://getartcraft.com/apps/pdfcraft"));
|
||||
assert_eq!(CraftApp::by_id("wordcraft").unwrap().website(), None);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,483 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,13 +8,16 @@
|
|||
//! 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;
|
||||
use crate::catalog::{CraftApp, GITHUB_OWNER};
|
||||
use crate::release::{self, Asset, Release};
|
||||
use crate::time::parse_rfc3339;
|
||||
use crate::version;
|
||||
|
|
@ -33,9 +36,13 @@ 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)]
|
||||
|
|
@ -111,6 +118,7 @@ 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 {
|
||||
|
|
@ -125,7 +133,16 @@ impl Feed {
|
|||
Some(feed)
|
||||
}
|
||||
|
||||
/// The repo to ask GitHub about for `app`, following the feed's repo renames.
|
||||
/// 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.
|
||||
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.
|
||||
|
|
@ -143,7 +160,8 @@ 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.
|
||||
if now.saturating_sub(fetched_at) > MAX_AGE {
|
||||
// A local mirror is as fresh as its last sync.
|
||||
if self.mirror.is_none() && 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.
|
||||
|
|
@ -155,7 +173,7 @@ impl Feed {
|
|||
}
|
||||
match &entry.release {
|
||||
None => FeedAnswer::Fresh(None),
|
||||
Some(r) => match r.to_release(&repo) {
|
||||
Some(r) => match r.to_release(&repo, self.mirror.as_deref()) {
|
||||
Some(release) => FeedAnswer::Fresh(Some(release)),
|
||||
None => FeedAnswer::AskGitHub,
|
||||
},
|
||||
|
|
@ -164,16 +182,17 @@ impl Feed {
|
|||
}
|
||||
|
||||
impl FeedRelease {
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
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) && url.starts_with(&prefix) && !url.contains(['?', '#', '\\']))
|
||||
.filter(|(name, _, url, _)| release::valid_file_name(name) && allowed(url) && !url.contains(['?', '#', '\\']))
|
||||
.map(|(name, size, url, sha)| Asset {
|
||||
name: name.clone(),
|
||||
size: *size,
|
||||
|
|
@ -347,10 +366,12 @@ 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.is_empty() && !b.is_empty()
|
||||
matches!((parts.next(), parts.next(), parts.next()), (Some(a), Some(b), None) if a.eq_ignore_ascii_case(GITHUB_OWNER) && !b.is_empty()
|
||||
&& r.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_./".contains(&c)))
|
||||
};
|
||||
(ok(&self.old_name) && ok(&self.new_name)).then(|| RepoRename {
|
||||
|
|
@ -507,20 +528,62 @@ mod tests {
|
|||
let moved = sample()
|
||||
.replace(
|
||||
"\"repo_renames\": []",
|
||||
r#""repo_renames": [{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null}]"#,
|
||||
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}]"#,
|
||||
)
|
||||
.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_eq!(feed.repo_for(app("photocraft")), "art/photocraft");
|
||||
let FeedAnswer::Fresh(Some(r)) = feed.answer(app("photocraft"), NOW) else { panic!() };
|
||||
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!(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);
|
||||
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);
|
||||
}
|
||||
|
||||
/// Contract check against what storyteller-web really serves: point `CRAFT_APPS_FEED_SAMPLE`
|
||||
/// at a response it produced (its `live_round_fetches_real_releases` test writes one).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
#![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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
//!
|
||||
//! 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};
|
||||
|
||||
|
|
@ -31,6 +34,7 @@ pub enum Arch {
|
|||
X64,
|
||||
X86,
|
||||
Arm64,
|
||||
Riscv64,
|
||||
}
|
||||
|
||||
impl Arch {
|
||||
|
|
@ -39,6 +43,7 @@ impl Arch {
|
|||
Self::X64 => "x64",
|
||||
Self::X86 => "x86",
|
||||
Self::Arm64 => "ARM64",
|
||||
Self::Riscv64 => "RISC-V",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +120,8 @@ impl Platform {
|
|||
Arch::X86
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
Arch::Arm64
|
||||
} else if cfg!(target_arch = "riscv64") {
|
||||
Arch::Riscv64
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
|
@ -135,9 +142,11 @@ 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, _) => &[],
|
||||
|
|
@ -231,6 +240,7 @@ 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",
|
||||
|
|
@ -253,6 +263,9 @@ 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]
|
||||
|
|
@ -265,6 +278,7 @@ 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!(
|
||||
|
|
@ -272,5 +286,60 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,12 +37,18 @@ pub enum Command {
|
|||
Pause {
|
||||
app: String,
|
||||
},
|
||||
/// Stop a running download, or throw away a paused one.
|
||||
/// Stop a running download, or throw away a paused one or one kept after a failed install.
|
||||
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),
|
||||
}
|
||||
|
|
@ -79,6 +85,8 @@ 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,
|
||||
|
|
@ -96,6 +104,9 @@ 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)]
|
||||
|
|
@ -171,11 +182,24 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,37 @@
|
|||
//! 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> {
|
||||
|
|
@ -61,23 +94,51 @@ 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 = MONTHS.get(m.saturating_sub(1) as usize).copied().unwrap_or("?");
|
||||
let month = month_short_en(m);
|
||||
format!("{month} {d}, {y}")
|
||||
}
|
||||
|
||||
/// `just now`, `5 min ago`, `3 h ago`, `yesterday`, `4 days ago`, or the date.
|
||||
pub fn format_ago(secs: i64, now: i64) -> String {
|
||||
/// 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);
|
||||
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),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,4 +169,44 @@ 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), "?");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ 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 }
|
||||
|
|
@ -17,10 +23,9 @@ serde_json = { workspace = true }
|
|||
log = { workspace = true }
|
||||
dirs = "6"
|
||||
sha2 = "0.10"
|
||||
# HTTPS over rustls with the OS's trusted roots, like PdfCraft's update check (webpki-roots and
|
||||
# webpki-root-certs are CDLA-licensed, so no bundled root list).
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring", "gzip"] }
|
||||
rustls-native-certs = "0.8"
|
||||
# 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"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
flate2 = "1"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ 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, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PAUSED, PausedDownload, Source,
|
||||
Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, LatestRelease, Origin, PAUSED, PausedDownload,
|
||||
Source,
|
||||
};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::{CraftApp, Installed, PackageKind, SUITE, version};
|
||||
|
|
@ -21,6 +22,9 @@ 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;
|
||||
|
|
@ -51,6 +55,8 @@ 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,
|
||||
|
|
@ -71,6 +77,8 @@ 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 {
|
||||
|
|
@ -113,6 +121,7 @@ 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),
|
||||
|
|
@ -124,6 +133,7 @@ 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() };
|
||||
|
|
@ -169,8 +179,9 @@ impl NativeBackend {
|
|||
let platform = self.platform;
|
||||
let options = self.options();
|
||||
std::thread::spawn(move || {
|
||||
// Leftovers from interrupted installs or older versions of the launcher.
|
||||
http::clean_downloads(&paths.downloads(), Duration::from_secs(30 * 86_400));
|
||||
// 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));
|
||||
let snapshot = crate::detect::Snapshot::take();
|
||||
let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
|
||||
for app in &SUITE {
|
||||
|
|
@ -213,10 +224,15 @@ 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 pre {
|
||||
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 {
|
||||
// The feed serves stable releases only; with pre-releases on, GitHub decides.
|
||||
(None, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY })
|
||||
} else {
|
||||
|
|
@ -233,7 +249,10 @@ impl NativeBackend {
|
|||
scope.spawn(|| {
|
||||
loop {
|
||||
let Some(app) = queue.lock().unwrap_or_else(PoisonError::into_inner).pop() else { break };
|
||||
let (latest, from, used) = latest_for(app, feed.as_ref(), fallback, source.as_ref(), &shared, pre, force, now);
|
||||
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),
|
||||
};
|
||||
if let Some(used) = used {
|
||||
sources.lock().unwrap_or_else(PoisonError::into_inner).push(used);
|
||||
}
|
||||
|
|
@ -268,6 +287,11 @@ 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
|
||||
|
|
@ -281,19 +305,30 @@ impl NativeBackend {
|
|||
let at = crate::now();
|
||||
*shared.last_check.lock().unwrap_or_else(PoisonError::into_inner) = Some(at);
|
||||
shared.checking.store(false, Ordering::SeqCst);
|
||||
shared.emit(Event::CheckFinished { at, source: source_used, rate_limited_until: source.rate_limited_until() });
|
||||
let rate_limited_until = limit_notice(source_used, source.rate_limited_until());
|
||||
shared.emit(Event::CheckFinished { at, source: source_used, 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;
|
||||
}
|
||||
jobs.insert(app.id.to_string(), stop.clone());
|
||||
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;
|
||||
}
|
||||
let shared = self.shared.clone();
|
||||
let paths = self.paths.clone();
|
||||
|
|
@ -305,8 +340,9 @@ 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()).map(|()| {
|
||||
Some(inst) => install::uninstall(app, inst, record.as_ref(), &paths, &stop, &mut phase).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());
|
||||
|
|
@ -329,6 +365,17 @@ 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 {
|
||||
|
|
@ -372,14 +419,21 @@ 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),
|
||||
Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase, &last),
|
||||
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.emit(Event::JobFinished { app: LAUNCHER_ID.to_string(), result: Err(e) }),
|
||||
Err(e) => {
|
||||
shared.restarting.store(false, Ordering::SeqCst);
|
||||
shared.emit(Event::JobFinished { app: LAUNCHER_ID.to_string(), result: Err(e) });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -425,7 +479,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, verified) = install::install(app, &release, &asset, kind, existing, &mut job)?;
|
||||
let new_record = install::install(app, &release, &asset, kind, existing, record, &mut job)?;
|
||||
{
|
||||
let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let changed = match new_record {
|
||||
|
|
@ -440,12 +494,76 @@ fn run_install(
|
|||
log::warn!("couldn't save the install record: {e}");
|
||||
}
|
||||
}
|
||||
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)",
|
||||
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"),
|
||||
};
|
||||
Ok(format!("{} {verb} {}{note}", app.name, release.version_label()))
|
||||
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."
|
||||
)
|
||||
}
|
||||
|
||||
/// The file an install of `app` would download now: same format as the installed copy, else
|
||||
|
|
@ -463,20 +581,28 @@ 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.
|
||||
/// Tells the UI whether `app` has a paused download waiting, or a finished one to install.
|
||||
fn report_partial(app: &CraftApp, shared: &Shared, paths: &Paths, platform: Option<Platform>, options: &BackendOptions) {
|
||||
let download = partial_for(app, shared, paths, platform, options).map(|(asset, done)| PausedDownload { done, total: asset.size });
|
||||
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 }));
|
||||
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 });
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
/// 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)> {
|
||||
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())?;
|
||||
let done = http::partial_bytes(&install::download_path(paths, &asset), asset.size)?;
|
||||
Some((asset, done))
|
||||
artcraft_launcher_core::release::valid_file_name(&asset.name).then(|| (install::download_path(paths, &asset), asset.size))
|
||||
}
|
||||
|
||||
/// How much the feed is trusted in one check, and how freely GitHub may be asked.
|
||||
|
|
@ -572,6 +698,13 @@ 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))
|
||||
|
|
@ -646,10 +779,68 @@ 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://") || u.starts_with("http://127.0.0.1:") || u.starts_with("http://localhost:"))
|
||||
.filter(|u| u.starts_with("https://") || is_loopback(u))
|
||||
.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))
|
||||
|
|
@ -670,6 +861,36 @@ 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 {
|
||||
|
|
@ -686,21 +907,22 @@ 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: throw its partial file away.
|
||||
// A paused download, or one kept after a failed install: throw the file away.
|
||||
None => {
|
||||
if let Some(def) = CraftApp::by_id(&app)
|
||||
&& let Some((asset, _)) = partial_for(def, &self.shared, &self.paths, self.platform, &self.options())
|
||||
&& let Some((dest, size)) = planned_download(def, &self.shared, &self.paths, self.platform, &self.options())
|
||||
{
|
||||
http::discard_partial(&install::download_path(&self.paths, &asset), asset.size);
|
||||
http::discard(&dest, size);
|
||||
}
|
||||
self.shared.emit(Event::Paused { app, download: None });
|
||||
self.shared.emit(Event::Paused { app: app.clone(), download: None });
|
||||
self.shared.emit(Event::ReadyToInstall { app, ready: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Launch { app } => {
|
||||
let Some(def) = CraftApp::by_id(&app) else { return };
|
||||
let result = match self.installed(&app) {
|
||||
Some(inst) => install::launch(def, &inst),
|
||||
Some(inst) => install::launch(def, &inst, &self.paths),
|
||||
None => Err(format!("{} isn't installed", def.name)),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
|
|
@ -717,6 +939,7 @@ 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);
|
||||
|
|
@ -724,6 +947,11 @@ 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);
|
||||
}
|
||||
|
|
@ -765,6 +993,9 @@ 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;
|
||||
|
||||
|
|
@ -796,6 +1027,7 @@ 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),
|
||||
|
|
@ -808,6 +1040,7 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -877,6 +1110,18 @@ 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.
|
||||
|
|
@ -907,6 +1152,25 @@ 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();
|
||||
|
|
@ -945,4 +1209,210 @@ 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(©("0.4.1")), Some(©("0.5.0")), &latest));
|
||||
assert!(landed(None, Some(©("0.5.0")), &latest));
|
||||
// Nothing changed, so the error stands.
|
||||
assert!(!landed(Some(©("0.4.1")), Some(©("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(©("0.5.0")), Some(©("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(©("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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,13 @@
|
|||
//! - macOS: `<App>.app` in `/Applications` or `~/Applications`, read from its `Info.plist`.
|
||||
//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), and portable
|
||||
//! copies the launcher unpacked.
|
||||
//! - Linux: AppImages in `~/Applications`, Debian/RPM packages, Flatpaks, and tarball installs.
|
||||
//! - Linux: AppImages in `~/Applications` (where the launcher and AppImageLauncher put them),
|
||||
//! `~/AppImages` (Gear Lever) and `~/.local/bin`, 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;
|
||||
|
|
@ -66,6 +70,73 @@ 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};
|
||||
|
|
@ -112,7 +183,6 @@ 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 {
|
||||
|
|
@ -130,7 +200,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 name != app.name || publisher != PUBLISHER || out.iter().any(|m: &MsiInstall| m.product_code == code) {
|
||||
if !is_installer_entry(app, &name, &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));
|
||||
|
|
@ -150,25 +220,26 @@ 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> {
|
||||
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);
|
||||
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 mut dirs: Vec<PathBuf> = location.into_iter().collect();
|
||||
let mut roots = Vec::new();
|
||||
for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Some(pf) = std::env::var_os(var) {
|
||||
dirs.push(PathBuf::from(pf).join(app.name));
|
||||
roots.push(PathBuf::from(pf));
|
||||
}
|
||||
}
|
||||
dirs.into_iter().map(|d| d.join(&exe)).find(|p| p.exists())
|
||||
exe_candidates(app, location.as_deref(), &roots).into_iter().find(|p| p.exists())
|
||||
}
|
||||
|
||||
pub fn detect(app: &CraftApp) -> Option<Installed> {
|
||||
|
|
@ -184,11 +255,9 @@ pub mod windows {
|
|||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux {
|
||||
use std::path::PathBuf;
|
||||
use std::path::{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;
|
||||
|
|
@ -198,7 +267,7 @@ pub mod linux {
|
|||
pub fn packages() -> Vec<Package> {
|
||||
let ours = |name: &str| {
|
||||
artcraft_launcher_core::SUITE.iter().any(|a| {
|
||||
[InstallSource::Deb, InstallSource::Pacman, InstallSource::Flatpak]
|
||||
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
|
||||
.iter()
|
||||
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
|
||||
})
|
||||
|
|
@ -214,14 +283,19 @@ pub mod linux {
|
|||
if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) {
|
||||
add(InstallSource::Rpm, packages::parse_rpm(&list));
|
||||
}
|
||||
if let Some(list) = capture("pacman", &["-Q"], t) {
|
||||
// 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)
|
||||
{
|
||||
add(InstallSource::Pacman, packages::parse_pacman(&list));
|
||||
}
|
||||
if let Some(list) = capture("flatpak", &["list", "--app", "--columns=application,version"], t) {
|
||||
let arch = format!("--arch={}", std::env::consts::ARCH);
|
||||
if let Some(list) = crate::process::capture_flatpak(&["list", "--app", &arch, "--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) = capture("flatpak", &["info", id], t).as_deref().and_then(packages::parse_flatpak_info) {
|
||||
if let Some(v) = crate::process::capture_flatpak(&["info", &arch, id], t).as_deref().and_then(packages::parse_flatpak_info) {
|
||||
*version = v;
|
||||
}
|
||||
}
|
||||
|
|
@ -230,6 +304,14 @@ 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")
|
||||
|
|
@ -244,34 +326,82 @@ pub mod linux {
|
|||
dirs::home_dir().map(|h| h.join("Applications"))
|
||||
}
|
||||
|
||||
/// AppImages of `app` in `~/Applications`, newest first.
|
||||
pub fn appimages(app: &CraftApp) -> Vec<(PathBuf, Option<version::Version>)> {
|
||||
let Some(dir) = appimage_dir() else { return Vec::new() };
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { return Vec::new() };
|
||||
/// 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>)> {
|
||||
let stems: Vec<&str> = app.stems().collect();
|
||||
let mut out: Vec<(PathBuf, Option<version::Version>)> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().into_string().ok()?;
|
||||
let parsed = parse_asset_name(&name, &stems)?;
|
||||
(parsed.kind == artcraft_launcher_core::PackageKind::AppImage).then(|| (e.path(), version::parse(&parsed.version)))
|
||||
})
|
||||
.collect();
|
||||
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.
|
||||
out.sort_by(|a, b| match (&b.1, &a.1) {
|
||||
(Some(x), Some(y)) => version::compare(x, y),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
(Some(_), None) => std::cmp::Ordering::Greater,
|
||||
(None, Some(_)) => std::cmp::Ordering::Less,
|
||||
(None, None) => 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];
|
||||
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()) })
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,13 +417,196 @@ pub mod freebsd {
|
|||
if !bin.exists() {
|
||||
return None;
|
||||
}
|
||||
Some(Installed { version: binary_version(&bin, app.id), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
|
||||
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
|
||||
}
|
||||
|
||||
/// `<app> --version` prints `<app> 0.5.0 (sha, date)`.
|
||||
fn binary_version(bin: &Path, id: &str) -> Option<version::Version> {
|
||||
fn binary_version(bin: &Path, app: &CraftApp) -> Option<version::Version> {
|
||||
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
|
||||
let rest = out.trim().strip_prefix(id)?.trim_start();
|
||||
version::parse(rest.split_whitespace().next()?)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,10 +51,15 @@ pub struct FeedError {
|
|||
|
||||
impl Http {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let roots = os_roots()?;
|
||||
let tls = || ureq::tls::TlsConfig::builder().root_certs(roots.clone()).build();
|
||||
// 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 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)
|
||||
|
|
@ -62,6 +67,7 @@ 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)
|
||||
|
|
@ -70,6 +76,7 @@ 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)
|
||||
|
|
@ -79,6 +86,7 @@ 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.
|
||||
|
|
@ -118,12 +126,11 @@ impl Http {
|
|||
404 => Ok(Fetched::NotFound),
|
||||
403 | 429 => {
|
||||
let remaining = header("x-ratelimit-remaining");
|
||||
let reset = header("x-ratelimit-reset").and_then(|r| r.parse::<i64>().ok());
|
||||
let retry_after = header("retry-after").and_then(|r| r.parse::<i64>().ok()).map(|s| crate::now() + s);
|
||||
if status == 429 || remaining.as_deref() == Some("0") || retry_after.is_some() {
|
||||
Ok(Fetched::RateLimited { reset: reset.or(retry_after) })
|
||||
} else {
|
||||
Err(format!("GitHub refused the request (HTTP {status})"))
|
||||
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})")),
|
||||
}
|
||||
}
|
||||
s => Err(format!("GitHub answered HTTP {s}")),
|
||||
|
|
@ -153,6 +160,27 @@ 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)))?;
|
||||
|
|
@ -171,23 +199,15 @@ 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: Option<&str>,
|
||||
stop: &Stop,
|
||||
progress: &mut dyn FnMut(Transfer),
|
||||
) -> Result<Verified, String> {
|
||||
pub fn download(&self, url: &str, dest: &Path, size: u64, sha256: &str, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), String> {
|
||||
if let Some(dir) = dest.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
|
||||
}
|
||||
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) {
|
||||
// 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) {
|
||||
progress(Transfer::Bytes { done: size, total: size });
|
||||
return Ok(verified);
|
||||
return Ok(());
|
||||
}
|
||||
// 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.
|
||||
|
|
@ -202,9 +222,9 @@ impl Http {
|
|||
}
|
||||
return Err(e);
|
||||
}
|
||||
if matches_published(&part, size, sha256) {
|
||||
if matches_published(&part, sha256) {
|
||||
std::fs::rename(&part, dest).map_err(|e| format!("couldn't save the download ({e})"))?;
|
||||
return Ok(verified);
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::remove_file(&part);
|
||||
if fresh_start_used {
|
||||
|
|
@ -351,15 +371,6 @@ 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);
|
||||
|
|
@ -387,12 +398,9 @@ impl Stop {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// How much of a download is already on disk (a paused or interrupted one).
|
||||
|
|
@ -400,10 +408,22 @@ 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)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let now = std::time::SystemTime::now();
|
||||
for entry in entries.flatten() {
|
||||
|
|
@ -422,16 +442,19 @@ pub fn clean_downloads(dir: &Path, max_age: Duration) {
|
|||
.strip_suffix(".part")
|
||||
.and_then(|stem| stem.rsplit_once('.'))
|
||||
.is_some_and(|(_, size)| !size.is_empty() && size.bytes().all(|b| b.is_ascii_digit()));
|
||||
if !current_part || age > max_age {
|
||||
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 {
|
||||
log::info!("removing stale download {}", path.display());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws away a paused download.
|
||||
pub fn discard_partial(dest: &Path, size: u64) {
|
||||
/// Throws away a paused download, and a finished one kept for another try at installing.
|
||||
pub fn discard(dest: &Path, size: u64) {
|
||||
let _ = std::fs::remove_file(part_path(dest, size));
|
||||
let _ = std::fs::remove_file(dest);
|
||||
}
|
||||
|
||||
enum AttemptError {
|
||||
|
|
@ -462,16 +485,6 @@ 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| {
|
||||
|
|
@ -488,14 +501,36 @@ 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)]
|
||||
mod tests {
|
||||
pub(crate) 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();
|
||||
|
|
@ -511,13 +546,61 @@ 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(), Duration::from_secs(30 * 86_400));
|
||||
assert!(keep.exists() && fresh.exists());
|
||||
assert!(!legacy.exists() && !finished.exists());
|
||||
clean_downloads(dir.path(), Duration::from_secs(60));
|
||||
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);
|
||||
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))));
|
||||
|
|
@ -532,7 +615,7 @@ mod tests {
|
|||
|
||||
/// How the fake server answers one request.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Reply {
|
||||
pub(crate) enum Reply {
|
||||
/// The whole file.
|
||||
Full,
|
||||
/// Promise the whole file, send `n` bytes, hang up.
|
||||
|
|
@ -547,7 +630,7 @@ mod tests {
|
|||
|
||||
/// Serves `body` over plain HTTP following `script`, one reply per connection; records the
|
||||
/// `Range` header each request carried.
|
||||
fn serve(body: Vec<u8>, script: Vec<Reply>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
|
||||
pub(crate) 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()));
|
||||
|
|
@ -593,7 +676,7 @@ mod tests {
|
|||
(url, ranges)
|
||||
}
|
||||
|
||||
fn body() -> (Vec<u8>, String) {
|
||||
pub(crate) 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)
|
||||
|
|
@ -606,15 +689,12 @@ 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, Some(&sha), &Stop::default(), &mut |t| {
|
||||
let result = http.download(&url, &dest, body.len() as u64, &sha, &Stop::default(), &mut |t| {
|
||||
if let Transfer::Bytes { done, total } = t {
|
||||
last = (done, total);
|
||||
}
|
||||
});
|
||||
let result = result.map(|v| {
|
||||
assert_eq!(v, Verified::Checksum);
|
||||
std::fs::read(&dest).unwrap()
|
||||
});
|
||||
let result = result.map(|()| std::fs::read(&dest).unwrap());
|
||||
if result.is_ok() {
|
||||
assert_eq!(last, (body.len() as u64, body.len() as u64));
|
||||
}
|
||||
|
|
@ -622,6 +702,25 @@ 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.
|
||||
|
|
@ -650,17 +749,6 @@ 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();
|
||||
|
|
@ -672,7 +760,7 @@ mod tests {
|
|||
let stop = Stop::default();
|
||||
// Pause as soon as the first bytes arrive.
|
||||
let err = http
|
||||
.download(&url, &dest, len, Some(&sha), &stop, &mut |t| {
|
||||
.download(&url, &dest, len, &sha, &stop, &mut |t| {
|
||||
if matches!(t, Transfer::Bytes { done, .. } if done > 0) {
|
||||
stop.pause();
|
||||
}
|
||||
|
|
@ -681,7 +769,7 @@ mod tests {
|
|||
assert_eq!(err, PAUSED);
|
||||
let kept = partial_bytes(&dest, len).unwrap();
|
||||
assert!(kept > 0 && kept < len, "{kept}");
|
||||
http.download(&url, &dest, len, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
|
||||
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), body);
|
||||
assert_eq!(ranges.lock().unwrap()[1], Some(kept.to_string()));
|
||||
}
|
||||
|
|
@ -696,7 +784,7 @@ mod tests {
|
|||
let stop = Stop::default();
|
||||
let err = Http::new()
|
||||
.unwrap()
|
||||
.download(&url, &dest, len, Some(&sha), &stop, &mut |t| {
|
||||
.download(&url, &dest, len, &sha, &stop, &mut |t| {
|
||||
if matches!(t, Transfer::Bytes { done, .. } if done > 0) {
|
||||
stop.cancel();
|
||||
}
|
||||
|
|
@ -721,7 +809,7 @@ 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, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
|
||||
Http::new().unwrap().download(&url, &dest, body.len() as u64, &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());
|
||||
|
|
@ -742,10 +830,65 @@ 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, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
|
||||
http.download(&asset.url, &dest, asset.size, &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
|
|
@ -8,6 +8,7 @@
|
|||
#![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;
|
||||
|
|
@ -17,6 +18,9 @@ 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;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ 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)) => Some((name.to_string(), ver.to_string())),
|
||||
(Some(name), Some("installed"), Some(ver)) => {
|
||||
let name = name.split_once(':').map(|(n, _)| n).unwrap_or(name);
|
||||
Some((name.to_string(), ver.to_string()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
|
|
@ -62,11 +65,15 @@ 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`).
|
||||
/// 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`).
|
||||
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")]).collect(),
|
||||
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(),
|
||||
_ => app.stems().map(str::to_string).collect(),
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +101,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn reads_each_package_managers_listing() {
|
||||
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_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_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!(
|
||||
|
|
@ -106,6 +116,23 @@ 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();
|
||||
|
|
|
|||
|
|
@ -46,14 +46,34 @@ 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.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ 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.
|
||||
/// 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.
|
||||
pub fn command(program: impl AsRef<std::ffi::OsStr>) -> Command {
|
||||
#[allow(unused_mut)]
|
||||
let mut cmd = Command::new(program);
|
||||
|
|
@ -14,18 +15,51 @@ 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> {
|
||||
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
|
||||
});
|
||||
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 start = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
|
|
@ -34,12 +68,28 @@ pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String
|
|||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
return Err(format!("{program} didn't finish within {} seconds", timeout.as_secs()));
|
||||
}
|
||||
}
|
||||
};
|
||||
let out = reader.join().ok()?;
|
||||
status.success().then(|| String::from_utf8_lossy(&out).into_owned())
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts `cmd` without waiting for it, and reaps it when it exits.
|
||||
|
|
@ -51,31 +101,60 @@ 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 = command(exe);
|
||||
let mut cmd = if in_sandbox() {
|
||||
let mut c = command("flatpak-spawn");
|
||||
c.arg("--host").arg(exe);
|
||||
c
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +166,10 @@ 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")
|
||||
};
|
||||
|
|
@ -101,16 +184,45 @@ pub fn reveal(path: &Path) -> Result<(), String> {
|
|||
cmd.arg("-R").arg(path);
|
||||
spawn_detached(cmd)
|
||||
} else if cfg!(windows) {
|
||||
let mut arg = std::ffi::OsString::from("/select,");
|
||||
arg.push(path);
|
||||
let mut cmd = command("explorer");
|
||||
cmd.arg(arg);
|
||||
spawn_detached(cmd)
|
||||
reveal_in_explorer(path)
|
||||
} 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) {
|
||||
|
|
@ -120,7 +232,126 @@ 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.
|
||||
capture("pgrep", &["-f", "--", p], Duration::from_secs(5)).is_some()
|
||||
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""#);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,512 @@
|
|||
//! 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(()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -127,7 +127,7 @@ fn msi_install_dir() -> Option<PathBuf> {
|
|||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect(exe: &Path) -> Option<Install> {
|
||||
if std::env::var_os("FLATPAK_ID").is_some() {
|
||||
if process::in_sandbox() {
|
||||
return None;
|
||||
}
|
||||
if let Some(appimage) = std::env::var_os("APPIMAGE") {
|
||||
|
|
@ -152,7 +152,9 @@ 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.
|
||||
/// 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)]
|
||||
pub fn update(
|
||||
install: &Install,
|
||||
release: &Release,
|
||||
|
|
@ -161,12 +163,15 @@ 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)?;
|
||||
let file = install::download(release, &asset, &mut job)?;
|
||||
// The restart ends this process: nothing else may be under way.
|
||||
last()?;
|
||||
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}");
|
||||
|
|
@ -176,7 +181,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);
|
||||
let placed = install::macos::install_dmg(&LAUNCHER, &file, Some(&existing), paths, &install::macos::Expect::release(release));
|
||||
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()])
|
||||
|
|
@ -189,7 +194,7 @@ pub fn update(
|
|||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Install::Package(kind, exe) => {
|
||||
let result = install::linux::install_package(*kind, &file);
|
||||
let result = install::linux::install_package(*kind, &file, stop, phase);
|
||||
let _ = std::fs::remove_file(&file);
|
||||
result?;
|
||||
relaunch_after_exit(pid, &[exe.as_os_str()])
|
||||
|
|
@ -352,6 +357,7 @@ 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)
|
||||
|
|
@ -360,9 +366,12 @@ 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 };
|
||||
let placed = install::macos::install_dmg(&LAUNCHER, &dmg, Some(&existing), &Paths::under(dir.path())).unwrap();
|
||||
// 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();
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -1,54 +1,246 @@
|
|||
//! One launcher at a time: a second start asks the first to show its window, then exits.
|
||||
//!
|
||||
//! The first instance listens on a fixed loopback port. The only message is `show`, and the
|
||||
//! worst another local program can do with it is bring the window forward.
|
||||
//! 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.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use artcraft_launcher_core::version::{self, Version};
|
||||
|
||||
const PORT: u16 = 47_413;
|
||||
const MESSAGE: &[u8] = b"artcraft-launcher:show";
|
||||
/// 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,
|
||||
}
|
||||
|
||||
pub enum Instance {
|
||||
/// We're the only one. A message arrives on the receiver whenever another start asks to show
|
||||
/// the window; `wake` is called to repaint the (perhaps hidden) window.
|
||||
Primary(Receiver<()>),
|
||||
/// 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>),
|
||||
/// 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: std::sync::Arc<std::sync::OnceLock<Box<dyn Fn() + Send + Sync>>>) -> Instance {
|
||||
pub fn claim(wake: Wake) -> Instance {
|
||||
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, PORT));
|
||||
match TcpListener::bind(addr) {
|
||||
Ok(listener) => {
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let mut buf = [0u8; 64];
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
|
||||
let n = (&stream).read(&mut buf).unwrap_or(0);
|
||||
if buf.get(..n) == Some(MESSAGE) {
|
||||
if tx.send(()).is_err() {
|
||||
break;
|
||||
}
|
||||
if let Some(wake) = wake.get() {
|
||||
wake();
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
});
|
||||
Instance::Primary(rx)
|
||||
}
|
||||
Err(_) => match TcpStream::connect_timeout(&addr, Duration::from_secs(1)) {
|
||||
Ok(mut stream) => {
|
||||
let _ = stream.write_all(MESSAGE);
|
||||
Instance::Secondary
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
// The port is taken by something else: run anyway rather than refuse to start.
|
||||
Err(_) => Instance::Primary(channel().1),
|
||||
},
|
||||
// It stayed (busy installing) and showed its window instead.
|
||||
Instance::Secondary
|
||||
}
|
||||
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());
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,17 @@ 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"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
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"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@
|
|||
|
||||
use std::time::Duration;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, LatestRelease, PAUSED, Source};
|
||||
use artcraft_launcher_core::protocol::{
|
||||
Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, 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};
|
||||
|
||||
|
|
@ -49,6 +53,11 @@ 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) {}
|
||||
}
|
||||
|
|
@ -64,6 +73,20 @@ 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 {
|
||||
|
|
@ -87,16 +110,46 @@ 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
|
||||
|
|
@ -107,7 +160,9 @@ const RESCAN_ON_FOCUS_AFTER: i64 = 30;
|
|||
|
||||
impl LauncherUi {
|
||||
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
|
||||
theme::install_fonts(ctx);
|
||||
let lang = effective_lang(&settings.language);
|
||||
let cjk_font = lang.needs_cjk_font().then_some(lang);
|
||||
theme::install_fonts(ctx, cjk_font);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
let caps = backend.capabilities();
|
||||
let platform = backend.platform();
|
||||
|
|
@ -128,22 +183,61 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Installed apps with an update waiting (the tray shows the count).
|
||||
/// 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.
|
||||
pub fn update_count(&self) -> usize {
|
||||
self.entries.iter().filter(|e| e.status(self.platform, self.caps.manage_apps) == AppStatus::UpdateAvailable).count()
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
|
@ -171,6 +265,7 @@ 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()));
|
||||
|
|
@ -204,6 +299,9 @@ 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.
|
||||
|
|
@ -227,6 +325,10 @@ 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;
|
||||
|
|
@ -237,6 +339,9 @@ 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);
|
||||
}
|
||||
|
|
@ -257,25 +362,48 @@ impl LauncherUi {
|
|||
}
|
||||
false
|
||||
}
|
||||
Event::JobFinished { app, result } => {
|
||||
if app == artcraft_launcher_core::catalog::LAUNCHER_ID {
|
||||
self.launcher_job = None;
|
||||
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 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 => self.toast(ctx, "Download cancelled", false),
|
||||
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) => self.toast(ctx, e, true),
|
||||
}
|
||||
true
|
||||
}
|
||||
Event::LauncherUpdate(r) => {
|
||||
self.launcher_update = Some(r);
|
||||
false
|
||||
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
|
||||
}
|
||||
Event::CheckStarted => {
|
||||
self.checking = true;
|
||||
|
|
@ -295,7 +423,7 @@ impl LauncherUi {
|
|||
fn announce_updates(&mut self, host: &mut dyn Host) {
|
||||
let mut fresh = Vec::new();
|
||||
for e in &self.entries {
|
||||
if e.status(self.platform, self.caps.manage_apps) != AppStatus::UpdateAvailable {
|
||||
if !self.in_filter(e, Filter::Updates) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = e.latest_label() else { continue };
|
||||
|
|
@ -308,9 +436,13 @@ impl LauncherUi {
|
|||
}
|
||||
if self.settings.notify {
|
||||
let (title, body) = match fresh.as_slice() {
|
||||
[(_, name, v)] => (format!("{name} {v} is available"), "Open ArtCraft Launcher to update.".to_string()),
|
||||
[(_, 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)
|
||||
}
|
||||
many => {
|
||||
(format!("{} updates are available", many.len()), many.iter().map(|(_, n, v)| format!("{n} {v}")).collect::<Vec<_>>().join(", "))
|
||||
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)
|
||||
}
|
||||
};
|
||||
host.notify(&title, &body);
|
||||
|
|
@ -320,9 +452,51 @@ 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();
|
||||
|
|
@ -332,24 +506,33 @@ impl LauncherUi {
|
|||
theme::apply(&ctx, self.settings.theme);
|
||||
self.applied_theme = Some(self.settings.theme);
|
||||
}
|
||||
let t = Tokens::get(&ctx);
|
||||
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);
|
||||
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(t.bg))
|
||||
.frame(egui::Frame::new().fill(tokens.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(t.bg).inner_margin(egui::Margin::symmetric(20, 0)))
|
||||
.frame(egui::Frame::new().fill(tokens.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(t.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 }))
|
||||
.frame(egui::Frame::new().fill(tokens.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
self.toolbar(ui);
|
||||
ui.add_space(18.0);
|
||||
|
|
@ -363,7 +546,7 @@ impl LauncherUi {
|
|||
if ids.is_empty() {
|
||||
ui.add_space(40.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
crate::widgets::hud(ui, "No apps match", t.muted);
|
||||
crate::widgets::hud(ui, tl!("No apps match"), tokens.muted);
|
||||
});
|
||||
} else {
|
||||
match self.settings.view {
|
||||
|
|
@ -371,10 +554,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 {
|
||||
|
|
@ -390,17 +573,8 @@ impl LauncherUi {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| {
|
||||
let s = e.status(self.platform, self.caps.manage_apps);
|
||||
let keep = match self.settings.filter {
|
||||
Filter::All => true,
|
||||
Filter::Installed => s.is_installed(),
|
||||
Filter::Updates => s == AppStatus::UpdateAvailable,
|
||||
Filter::NotInstalled => !s.is_installed(),
|
||||
};
|
||||
keep && (needle.is_empty()
|
||||
|| e.app.name.to_lowercase().contains(&needle)
|
||||
|| e.app.tagline.to_lowercase().contains(&needle)
|
||||
|| e.app.like.to_lowercase().contains(&needle))
|
||||
self.in_filter(e, self.settings.filter)
|
||||
&& (needle.is_empty() || e.app.name.to_lowercase().contains(&needle) || e.app.tagline.to_lowercase().contains(&needle))
|
||||
})
|
||||
.collect();
|
||||
let platform = self.platform;
|
||||
|
|
@ -420,7 +594,6 @@ 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))))
|
||||
}
|
||||
|
|
@ -460,7 +633,7 @@ impl LauncherUi {
|
|||
}
|
||||
}
|
||||
match (api, github) {
|
||||
(true, true) => Some(format!("{} and {}", Origin::ArtCraftApi.label(), Origin::GitHub.label())),
|
||||
(true, true) => Some(crate::i18n::fmt(tl!("{a} and {b}"), &[("a", Origin::ArtCraftApi.label()), ("b", Origin::GitHub.label())])),
|
||||
(true, false) => Some(Origin::ArtCraftApi.label().to_string()),
|
||||
(false, true) => Some(Origin::GitHub.label().to_string()),
|
||||
(false, false) => None,
|
||||
|
|
@ -468,18 +641,20 @@ impl LauncherUi {
|
|||
}
|
||||
|
||||
pub(crate) fn count(&self, filter: Filter) -> usize {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
let s = e.status(self.platform, self.caps.manage_apps);
|
||||
match filter {
|
||||
Filter::All => true,
|
||||
Filter::Installed => s.is_installed(),
|
||||
Filter::Updates => s == AppStatus::UpdateAvailable,
|
||||
Filter::NotInstalled => !s.is_installed(),
|
||||
}
|
||||
})
|
||||
.count()
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_toasts(&mut self, ctx: &egui::Context) {
|
||||
|
|
@ -505,7 +680,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(&toast.text).color(t.ink)).wrap());
|
||||
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::msg(&toast.text)).color(t.ink)).wrap());
|
||||
});
|
||||
});
|
||||
if r.response.interact(egui::Sense::click()).clicked() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,354 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
//! 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(¤t) = 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()
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
//! Modal dialogs: release notes, uninstall confirmation, settings.
|
||||
|
||||
use crate::i18n::fmt;
|
||||
use artcraft_launcher_core::protocol::Command;
|
||||
use artcraft_launcher_core::{Os, PackageKind, time};
|
||||
use artcraft_launcher_core::{Os, PackageKind};
|
||||
use egui::{RichText, vec2};
|
||||
|
||||
use crate::app::{Dialog, Host, LauncherUi};
|
||||
use crate::settings::CheckEvery;
|
||||
use crate::settings::{CheckEvery, Settings};
|
||||
use crate::theme::{self, ThemeChoice, Tokens};
|
||||
use crate::widgets::{self, Cell, Kind};
|
||||
|
||||
|
|
@ -16,13 +17,16 @@ impl LauncherUi {
|
|||
return;
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let modal = egui::Modal::new(egui::Id::new("dialog"))
|
||||
// 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()))
|
||||
.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;
|
||||
|
|
@ -33,7 +37,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"))
|
||||
let modal = egui::Modal::new(egui::Id::new("feed_alert").with(&alert.id))
|
||||
.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| {
|
||||
|
|
@ -44,13 +48,16 @@ impl LauncherUi {
|
|||
ui.add(egui::Label::new(RichText::new(&alert.body).color(t.muted)).wrap());
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
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()));
|
||||
}
|
||||
// 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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Only OK acknowledges it: Escape or a click outside leaves it for later.
|
||||
|
|
@ -81,11 +88,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| format!("Released {}", time::format_date(d))).unwrap_or_default();
|
||||
let date = release.published_at.map(|d| fmt(tl!("Released {date}"), &[("date", &crate::views::date_text(d))])).unwrap_or_default();
|
||||
Self::title(ui, crate::icons::app(id), &format!("{} {}", entry.app.name, release.version_label()), &date);
|
||||
egui::ScrollArea::vertical().max_height(ui.ctx().content_rect().height() * 0.6).show(ui, |ui| {
|
||||
if release.notes.is_empty() {
|
||||
ui.label("These notes weren't fetched (GitHub's request limit was reached). They're on the release page.");
|
||||
ui.label(tl!("These notes weren't fetched (GitHub's request limit was reached). They're on the release page."));
|
||||
} else {
|
||||
crate::notes::show(ui, &release.notes);
|
||||
}
|
||||
|
|
@ -93,9 +100,43 @@ impl LauncherUi {
|
|||
ui.add_space(12.0);
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
widgets::hud_link(ui, "Open on GitHub", &release.html_url);
|
||||
widgets::hud_link(ui, tl!("Open on GitHub"), &release.html_url);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close = widgets::button(ui, "Close", Kind::Secondary, 90.0).clicked();
|
||||
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
|
||||
|
|
@ -105,54 +146,126 @@ 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), &format!("Uninstall {}?", entry.app.name), "");
|
||||
Self::title(ui, crate::icons::app(id), &fmt(tl!("Uninstall {name}?"), &[("name", entry.app.name)]), "");
|
||||
let how = match entry.installed.as_ref().map(|i| i.source) {
|
||||
Some(artcraft_launcher_core::InstallSource::MacApp) => "The app will be moved to the Trash. Your documents and settings stay.",
|
||||
Some(artcraft_launcher_core::InstallSource::MacApp) => tl!("The app will be moved to the Trash. Your documents and settings stay."),
|
||||
Some(artcraft_launcher_core::InstallSource::Msi) => {
|
||||
"Windows will remove it like any installed program. Your documents and settings stay."
|
||||
tl!("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,
|
||||
) => "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.",
|
||||
) => 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."),
|
||||
};
|
||||
ui.label(RichText::new(how).color(t.muted));
|
||||
ui.add_space(20.0);
|
||||
let mut close = false;
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
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, add: &mut dyn FnMut(&mut egui::Ui)| {
|
||||
ui.horizontal(|ui| {
|
||||
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() {
|
||||
ui.label(label);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
|
||||
});
|
||||
add(ui);
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
section(ui, "Updates");
|
||||
let mut 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"));
|
||||
let mut every = self.settings.check_every;
|
||||
row(ui, "Check for updates", &mut |ui| {
|
||||
row(ui, tl!("Check for updates"), 150.0, &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());
|
||||
|
|
@ -160,80 +273,97 @@ impl LauncherUi {
|
|||
});
|
||||
});
|
||||
self.settings.check_every = every;
|
||||
ui.checkbox(&mut self.settings.notify, "Notify me when an installed app has an update");
|
||||
ui.checkbox(&mut self.settings.prereleases, "Include pre-releases (betas and release candidates)");
|
||||
if self.caps.manage_apps
|
||||
&& 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)"));
|
||||
|
||||
if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Linux) {
|
||||
section(ui, "Installing on Linux");
|
||||
let mut choice = self.settings.preferred_kinds.first().copied();
|
||||
row(ui, "Install new apps as", &mut |ui| {
|
||||
let label = choice.map_or("System package (recommended)", PackageKind::label);
|
||||
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()));
|
||||
egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut choice, None, "System package (recommended)");
|
||||
if !is_flatpak && !is_appimage {
|
||||
ui.selectable_value(&mut choice, None, tl!("System package (recommended)"));
|
||||
}
|
||||
for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::AppImage] {
|
||||
ui.selectable_value(&mut choice, Some(k), k.label());
|
||||
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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
self.settings.preferred_kinds = choice.into_iter().collect();
|
||||
ui.label(RichText::new("Updates always use the format an app was installed with.").size(12.0).color(t.muted));
|
||||
ui.label(RichText::new(tl!("Updates always use the format an app was installed with.")).size(12.0).color(t.muted));
|
||||
}
|
||||
|
||||
if self.caps.manage_apps {
|
||||
section(ui, "Running in the background");
|
||||
section(ui, tl!("Running in the background"));
|
||||
if host.has_tray() {
|
||||
ui.checkbox(&mut self.settings.close_to_tray, "Keep running in the tray when the window is closed");
|
||||
ui.checkbox(&mut self.settings.close_to_tray, tl!("Keep running in the tray when the window is closed"));
|
||||
}
|
||||
if host.has_desktops() {
|
||||
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.");
|
||||
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."));
|
||||
}
|
||||
if let Some(mut on) = host.login_item()
|
||||
&& ui.checkbox(&mut on, "Start ArtCraft Launcher when I log in").changed()
|
||||
&& ui.checkbox(&mut on, tl!("Start ArtCraft Launcher when I log in")).changed()
|
||||
&& let Err(e) = host.set_login_item(on)
|
||||
{
|
||||
let ctx = ui.ctx().clone();
|
||||
self.toast(&ctx, format!("Couldn't change the login item: {e}"), true);
|
||||
self.toast(&ctx, fmt(tl!("Couldn't change the login item: {e}"), &[("e", &e)]), true);
|
||||
}
|
||||
}
|
||||
|
||||
section(ui, "Community");
|
||||
ui.label(RichText::new("Questions, ideas and news from the ArtCraft team").color(t.muted));
|
||||
section(ui, tl!("Community"));
|
||||
ui.label(RichText::new(tl!("Questions, ideas and news from the ArtCraft team")).color(t.muted));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
|
||||
if widgets::button(ui, "Join the Discord", Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() {
|
||||
if widgets::button(ui, tl!("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, "Website", Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() {
|
||||
if widgets::button(ui, tl!("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, "GitHub", Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
|
||||
if widgets::button(ui, tl!("GitHub"), Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// 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.spacing_mut().item_spacing.x = 4.0;
|
||||
widgets::social_icons(ui, 16.0);
|
||||
});
|
||||
});
|
||||
|
||||
section(ui, "Appearance");
|
||||
section(ui, tl!("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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
//! 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
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,346 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,70 @@
|
|||
//! 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
|
||||
}
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,344 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,347 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,349 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,344 @@
|
|||
//! 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
//! 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};
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,334 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,342 @@
|
|||
# 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.
|
|
|
@ -0,0 +1,525 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
//! 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
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
# 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.
|
|
|
@ -17,6 +17,7 @@ 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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,23 @@
|
|||
|
||||
#![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;
|
||||
|
|
@ -19,4 +32,8 @@ 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;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ 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,
|
||||
|
|
@ -18,11 +20,13 @@ 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 }
|
||||
Self { app, latest: None, installed: None, detected: false, job: None, origin: None, paused: None, ready: false }
|
||||
}
|
||||
|
||||
pub fn release(&self) -> Option<&Release> {
|
||||
|
|
@ -53,7 +57,7 @@ impl AppEntry {
|
|||
}
|
||||
|
||||
pub fn installed_label(&self) -> Option<String> {
|
||||
self.installed.as_ref().map(|i| i.version.as_ref().map_or_else(|| "installed".to_string(), ToString::to_string))
|
||||
self.installed.as_ref().map(|i| i.version.as_ref().map(ToString::to_string).unwrap_or_else(|| t("Installed").to_string()))
|
||||
}
|
||||
|
||||
pub fn latest_label(&self) -> Option<String> {
|
||||
|
|
@ -78,27 +82,27 @@ pub enum Tone {
|
|||
Faint,
|
||||
}
|
||||
|
||||
pub fn status_text(status: &AppStatus, manage_apps: bool) -> (&'static str, Tone) {
|
||||
pub fn status_text(status: &AppStatus, manage_apps: bool) -> (String, Tone) {
|
||||
match status {
|
||||
AppStatus::Checking => ("Checking…", Tone::Faint),
|
||||
AppStatus::Available if manage_apps => ("Available to install", Tone::Info),
|
||||
AppStatus::Available => ("Available", Tone::Info),
|
||||
AppStatus::UpToDate => ("Installed · up to date", Tone::Good),
|
||||
AppStatus::UpdateAvailable => ("Update available", Tone::Warn),
|
||||
AppStatus::Installed => ("Installed", Tone::Neutral),
|
||||
AppStatus::Unavailable => ("Not available here", Tone::Faint),
|
||||
AppStatus::NoRelease => ("Coming soon", Tone::Faint),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`status_text`], but saying so when a download is paused or reconnecting.
|
||||
pub fn entry_status_text(e: &AppEntry, status: &AppStatus, manage_apps: bool) -> (&'static str, Tone) {
|
||||
pub fn entry_status_text(e: &AppEntry, status: &AppStatus, manage_apps: bool) -> (String, Tone) {
|
||||
match (e.job, e.paused) {
|
||||
(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),
|
||||
(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),
|
||||
_ => status_text(status, manage_apps),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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("No notes were published with this release.").color(t.muted));
|
||||
ui.label(RichText::new(tl!("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(|| "link".to_string(), |n| format!("#{n}"));
|
||||
let short = url.rsplit('/').next().map_or_else(|| tl!("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("Full changelog", url.trim());
|
||||
ui.hyperlink_to(tl!("Full changelog"), url.trim());
|
||||
} else {
|
||||
ui.label(RichText::new(text).color(t.muted));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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)]
|
||||
|
|
@ -21,16 +22,17 @@ 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.
|
||||
/// Suite order: classic apps, then new. Settings saved with the removed "Inspired by" sort load as this.
|
||||
#[default]
|
||||
#[serde(alias = "InspiredBy")]
|
||||
Suite,
|
||||
Name,
|
||||
/// The product an app is inspired by.
|
||||
InspiredBy,
|
||||
Status,
|
||||
/// Installed version.
|
||||
Installed,
|
||||
|
|
@ -62,10 +64,10 @@ impl CheckEvery {
|
|||
/// mean "automatically" too.
|
||||
pub const ALL: [Self; 2] = [Self::Hour, Self::Never];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
Self::Hour | Self::FourHours | Self::Day => "Automatically",
|
||||
Self::Never => "Only when I ask",
|
||||
Self::Hour | Self::FourHours | Self::Day => t("Automatically").to_string(),
|
||||
Self::Never => t("Only when I ask").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +89,18 @@ 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,
|
||||
|
|
@ -104,6 +112,58 @@ 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 {
|
||||
|
|
@ -114,15 +174,18 @@ 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: Vec::new(),
|
||||
preferred_kinds: default_preferred_kinds(),
|
||||
dismissed_notices: std::collections::BTreeSet::new(),
|
||||
notified: BTreeMap::new(),
|
||||
last_check: None,
|
||||
hidden: std::collections::BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ 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.
|
||||
|
|
@ -20,11 +22,14 @@ 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 => "Match system",
|
||||
Self::Dark => "Dark",
|
||||
Self::Light => "Light",
|
||||
Self::System => crate::i18n::t("Match system"),
|
||||
Self::Dark => crate::i18n::t("Dark"),
|
||||
Self::Light => crate::i18n::t("Light"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -140,6 +145,27 @@ 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;
|
||||
|
|
@ -242,7 +268,9 @@ 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.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
/// `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>) {
|
||||
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");
|
||||
|
|
@ -259,11 +287,68 @@ pub fn install_fonts(ctx: &egui::Context) {
|
|||
("GeistMono-Bold", GEIST_MONO, &[(*b"wght", 700.0)], mono_bold()),
|
||||
];
|
||||
for (name, bytes, coords, family) in faces {
|
||||
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), ..Default::default() };
|
||||
// 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() };
|
||||
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
|
|
@ -51,7 +51,26 @@ pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {
|
|||
resp
|
||||
}
|
||||
|
||||
/// "01 / Classic apps": a section eyebrow, the index faint.
|
||||
/// 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.
|
||||
pub fn eyebrow(ui: &mut Ui, index: &str, label: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -178,10 +197,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 => "Minimize",
|
||||
WindowButton::Maximize => "Maximize",
|
||||
WindowButton::Restore => "Restore",
|
||||
WindowButton::Close => "Close",
|
||||
WindowButton::Minimize => tl!("Minimize"),
|
||||
WindowButton::Maximize => tl!("Maximize"),
|
||||
WindowButton::Restore => tl!("Restore"),
|
||||
WindowButton::Close => tl!("Close"),
|
||||
};
|
||||
// Half the glyph's size: 10 px, like Windows' own caption buttons.
|
||||
let s = 5.0;
|
||||
|
|
@ -292,6 +311,12 @@ 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());
|
||||
|
|
@ -392,14 +417,16 @@ 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 => {
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
|
@ -481,7 +508,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(format!("ArtCraft on {name}")).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let resp = resp.on_hover_text(crate::i18n::fmt(tl!("ArtCraft on {name}"), &[("name", name)])).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if resp.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(*url));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
//! 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
//! 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
//! 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
//! 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");
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
//! 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
//! 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
//! 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
//! 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
//! 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
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
{
|
||||
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";
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
and SoundCraft), installs the right build for your computer, and keeps them up to date.
|
||||
DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
|
||||
SoundCraft and FigCraft), 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>
|
||||
|
|
|
|||
|
|
@ -24,18 +24,27 @@ 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
|
||||
# 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.
|
||||
# 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.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=home
|
||||
modules:
|
||||
|
|
|
|||
|
|
@ -26,18 +26,27 @@ 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
|
||||
# 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.
|
||||
# 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.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=home
|
||||
build-options:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.
|
||||
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.
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build and package ArtCraft Launcher for Linux (<arch> is x86_64 or aarch64):
|
||||
# Build and package ArtCraft Launcher for Linux (<arch> is x86_64, aarch64 or riscv64):
|
||||
#
|
||||
# $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,12 +7,16 @@
|
|||
# $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"]
|
||||
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar" or 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"
|
||||
|
|
@ -26,13 +30,22 @@ while [ $# -gt 0 ]; do
|
|||
case "$1" in
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
--formats) FORMATS="$2"; shift 2 ;;
|
||||
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
|
||||
-h | --help) sed -n '2,19p' "$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="$(uname -m)"
|
||||
ARCH="${CROSS_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 ;;
|
||||
|
|
@ -43,16 +56,20 @@ BASENAME="$PKG-$VERSION-linux-$ARCH"
|
|||
echo "==> ArtCraft Launcher $VERSION for Linux $ARCH ($FORMATS)"
|
||||
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p "$PKG")
|
||||
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
|
||||
fi
|
||||
BIN="$CARGO_TARGET_DIR/release"
|
||||
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
|
||||
WORK="$CARGO_TARGET_DIR/linux-package"
|
||||
STAGE="$WORK/root"
|
||||
rm -rf "$WORK"
|
||||
|
||||
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
|
||||
install -Dm755 "$BIN/$PKG" "$STAGE/usr/bin/$PKG"
|
||||
strip "$STAGE/usr/bin/$PKG" 2>/dev/null || true
|
||||
"${CROSS_COMPILE:-}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" \
|
||||
|
|
@ -139,5 +156,10 @@ 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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
# 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
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,158 @@
|
|||
#!/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()
|
||||
|
|
@ -113,6 +113,10 @@ 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"
|
||||
|
|
@ -133,12 +137,31 @@ STAGE="$WORK/dmg"
|
|||
mkdir -p "$STAGE"
|
||||
ditto "$APP" "$STAGE/$APP_NAME.app"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
rm -f "$DMG" "$WORK/raw.dmg"
|
||||
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
|
||||
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
|
||||
hdiutil makehybrid -hfs -hfs-volume-name "$APP_NAME $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
||||
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
|
||||
rm -f "$WORK/raw.dmg"
|
||||
# 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"
|
||||
sign "$DMG"
|
||||
codesign --verify --strict --verbose=2 "$DMG"
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
|
|
|
|||
|
|
@ -112,10 +112,13 @@ if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Integrity first: this must hold even for ad-hoc builds. Not --strict here: files read back from
|
||||
# the DMG's HFS volume (hdiutil makehybrid) report a com.apple.FinderInfo attribute, which strict
|
||||
# mode calls "detritus". package.sh already ran the strict check on the bundle before imaging it.
|
||||
codesign --verify --deep --verbose=2 "$APP"
|
||||
# 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"
|
||||
details="$(codesign -d --verbose=2 "$APP" 2>&1)"
|
||||
printf '%s\n' "$details" | grep -E '^(Identifier|Format|CodeDirectory|Authority|Timestamp|TeamIdentifier)[= ]' || true
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft', 'figcraft'
|
||||
$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 40 px cells divided by hairlines.
|
||||
# The apps, three to a row, in 36 px cells divided by hairlines (five rows fit above 312).
|
||||
$pen = New-Object System.Drawing.Pen $line, 1
|
||||
$left = 22; $top = 126; $cell = 40
|
||||
$left = 22; $top = 118; $cell = 36
|
||||
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 + 6, $cy + 6, $cell - 12, $cell - 12)
|
||||
$g.DrawImage($icons[$i], $cx + 5, $cy + 5, $cell - 10, $cell - 10)
|
||||
}
|
||||
$pen.Dispose()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,751 @@
|
|||
//! `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), ¬es)?;
|
||||
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(¬es),
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
mod icns;
|
||||
mod ico;
|
||||
mod local;
|
||||
mod version;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -22,6 +23,9 @@ 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
|
||||
|
|
@ -37,6 +41,7 @@ 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(())
|
||||
|
|
|
|||
Loading…
Reference in New Issue