Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 07471d404d ci: verify the actions lockfile in CI
Fails the build when .github/workflows/actions.lock stops matching the
workflows, or when a locked pin no longer verifies upstream, so a stale or
tampered lockfile cannot go unnoticed between dependency updates.
2026-11-12 07:22:42 -05:00
Dylan M. Taylor 78401059b6 ci: lock workflow actions through an actions.lock lockfile
Mutable action tags are a supply-chain risk: a moved tag runs different code
without a diff. Every workflow action is now pinned through
.github/workflows/actions.lock, and CI verifies the lockfile so an out-of-sync
or tampered one fails instead of running.

arch-package was pinned to 9d67905, a commit no longer on any branch or tag of
craft-common-actions, so it is back on main like the other craft actions here.

S7637 wants an inline SHA per action and does not read actions.lock, so the
rule is scoped off for workflow files until Sonar reads lockfiles.
2026-11-12 07:22:16 -05:00
Dylan M. Taylor 84a6a7dcf5 Report 0.42.1 in the macOS build config (#2836)
The 0.42.1 bump in #2835 updated `tauri.conf.json` and `version.rs` but
left `tauri-mac.conf.json` at 0.42.0, so the macOS release build would
report the wrong version.

- Sets `tauri-mac.conf.json` to 0.42.1.
- Extends
`package_identity_and_display_version_match_the_release_configuration`
to also check the macOS config. It fails before this change and passes
after; all 19 updater tests pass.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
  * Updated the macOS app version to 0.42.1.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 21:57:00 -05:00
Dylan M. Taylor 4ecd4b7eea Add manual verified updates and bump ArtCraft to 0.42.1 (#2835)
ArtCraft’s Help menu has news but no application update check, and its
Flatpak cannot invoke the host updater. Add **Help → Check for
Updates…** with background checks, current/available versions, explicit
Update and restart, progress and failure states, and Releases fallback.
Browser users are offered a reload and the desktop releases.

Use the same verified `craft-update` core as the native Craft apps,
pinned to the immutable reviewed commit in
https://github.com/storytold/craft-libs/pull/970. Keep ArtCraft’s
existing `ai.artcraft.app` identity and release naming. Honor launcher
pins, reject active generation tasks before download and again before
replacement, preserve the native window during installation, defer the
helper until accepted exit, and report helper outcomes on startup.
Registered NSIS copies use Releases; they are not overwritten as
portable folders.

Grant the host update interface and read-only launcher choices in the
existing Flatpak manifest. No frontend network-policy expansion. Add a
separate version commit changing the canonical Tauri configuration and
display version to **0.42.1**, with a regression keeping them
synchronized. Once this and the shared-library prerequisite are green
and merged, merge main into the release branch, finish all platform
drafts, verify signed checksums/assets, and publish.

Validation: native all-target cargo check inside GNOME SDK 51; all six
focused Rust updater tests; all ten Help-menu/UI tests; TypeScript
project build; production frontend build; Flatpak permission regression
red before and green after. All six native SDK tests also pass at the
final 0.42.1 version. The existing lockfile needs compatible chrono and
crc32fast updates for the shared updater; other dependency revisions are
preserved.
2026-11-11 21:36:49 -05:00
27 changed files with 1208 additions and 107 deletions
+93
View File
@@ -0,0 +1,93 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/artcraft-linux-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/download-artifact@v8.0.2'
- 'actions/setup-node@v7.1.0'
- 'actions/upload-artifact@v7.0.2'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/rust-cache@v2.9.2'
- 'step-security/tauri-action@v1.0.0'
- 'storytold/craft-common-actions@main'
'.github/workflows/artcraft-macos-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/tauri-action@v1.0.0'
'.github/workflows/artcraft-windows-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/tauri-action@v1.0.0'
'.github/workflows/freebsd.yml': []
'.github/workflows/house-rules.yml': []
'.github/workflows/lockfile.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/packaging-lint.yml': []
'.github/workflows/sonarcloud.yml': []
dependencies:
'actions/cache@caa296126883cff596d87d8935842f9db880ef25':
ref: 'v5.1.0'
commit: 'sha1-caa296126883cff596d87d8935842f9db880ef25'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/download-artifact@v8.0.2':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/setup-node@v7.1.0':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/upload-artifact@v7.0.2':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'step-security/dtolnay-rust-toolchain@v1.0.0':
ref: 'v1.0.0'
commit: 'sha1-8dd607977d06adf4ed49b190381e74c17a9dd25f'
owner_id: 88700172
repo_id: 1249865941
'step-security/harden-runner@v2.22.4':
ref: 'v2.22.4'
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
owner_id: 88700172
repo_id: 422287306
'step-security/rust-cache@v2.9.2':
ref: 'v2.9.2'
commit: 'sha1-8871c8ba063416da7c32246932ea6ae08700453e'
owner_id: 88700172
repo_id: 822636713
'step-security/tauri-action@v1.0.0':
ref: 'v1.0.0'
commit: 'sha1-dc662d498edbd35ec8ed564b45d44e955444d1bc'
owner_id: 88700172
repo_id: 1283860856
'storytold/craft-common-actions@main':
ref: 'main'
commit: 'sha1-293de72429d3ed5d9fa92aded5e500544c0fa28f'
owner_id: 76897702
repo_id: 1414429108
uses:
- 'actions/cache@caa296126883cff596d87d8935842f9db880ef25'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
+20 -20
View File
@@ -14,10 +14,10 @@ jobs:
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: rpm jq binutils zsync
@@ -39,10 +39,10 @@ jobs:
TAURI_APP_PATH: ${{ github.workspace }}/crates/desktop/artcraft
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libclang-dev libasound2-dev libxdo-dev libdbus-1-dev build-essential clang file jq perl cmake nasm rpm zsync gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav
@@ -71,16 +71,16 @@ jobs:
PKG_CONFIG_LIBDIR_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
ENV
echo "CMAKE_TOOLCHAIN_FILE_riscv64gc_unknown_linux_gnu=$GITHUB_WORKSPACE/packaging/linux/riscv64.cmake" >> "$GITHUB_ENV"
- uses: actions/setup-node@v7
- uses: actions/setup-node@v7.1.0
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
- uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2
- uses: step-security/rust-cache@v2.9.2
with:
key: tauri-linux-${{ matrix.arch }}
cache-on-failure: true
@@ -88,7 +88,7 @@ jobs:
working-directory: frontend
- run: ./node_modules/.bin/nx run artcraft:build
working-directory: frontend
- uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
- uses: step-security/tauri-action@v1.0.0
with:
projectPath: crates/desktop/artcraft
args: --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config ${{ github.workspace }}/packaging/linux/tauri.conf.json
@@ -97,7 +97,7 @@ jobs:
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: packaging/linux/package.sh "$ARCH" "$TARGET"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -111,11 +111,11 @@ jobs:
arch: [x86_64, aarch64]
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
- uses: actions/checkout@v7.0.1
- uses: actions/download-artifact@v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -128,7 +128,7 @@ jobs:
files=(dist/linux/artcraft-*-linux-"$ARCH".tar.gz)
test "${#files[@]}" -eq 1
echo "path=${files[0]}" >> "$GITHUB_OUTPUT"
- uses: storytold/craft-common-actions/actions/arch-package@9d6790582b4997aa2120f4413cdb55c0b6f77fae # extra-depends support (#161)
- uses: storytold/craft-common-actions/actions/arch-package@main # extra-depends support (#161)
with:
tarball: ${{ steps.tarball.outputs.path }}
arch: ${{ matrix.arch }}
@@ -138,7 +138,7 @@ jobs:
for package in dist/release/*.pkg.tar.zst; do
tar --zstd -xOf "$package" .PKGINFO | grep -Fx 'depend = webkit2gtk-4.1'
done
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
with:
name: release-arch-${{ matrix.arch }}
path: dist/release/*.pkg.tar.zst
@@ -158,11 +158,11 @@ jobs:
- {arch: aarch64, runner: ubuntu-24.04-arm}
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
- uses: actions/checkout@v7.0.1
- uses: actions/download-artifact@v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -180,7 +180,7 @@ jobs:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: packaging/linux/flatpak.sh
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: |
@@ -194,10 +194,10 @@ jobs:
contents: write
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
- name: Read version
id: version
run: echo "value=$(jq -r .version crates/desktop/artcraft/tauri.conf.json)" >> "$GITHUB_OUTPUT"
+5 -5
View File
@@ -39,18 +39,18 @@ jobs:
timeout-minutes: 60
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
- name: setup node
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: lts/*
- name: install Rust stable
uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
@@ -230,7 +230,7 @@ jobs:
- name: build and publish
uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
uses: step-security/tauri-action@v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
@@ -34,13 +34,13 @@ jobs:
timeout-minutes: 90
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
- name: install Rust stable
uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
@@ -54,7 +54,7 @@ jobs:
shell: cmd
- name: setup node
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: lts/*
@@ -107,7 +107,7 @@ jobs:
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
- name: build and publish
uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
uses: step-security/tauri-action@v1.0.0
env:
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
RUSTFLAGS: "-C target-feature=+crt-static"
+34
View File
@@ -0,0 +1,34 @@
name: Lockfile
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
# Docs: https://gh.io/actions-lockfile
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
jobs:
lockfile:
name: Lockfile is in sync
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
run: gh extension install github/gh-actions-lock --pin v0.4.1
- name: Verify the lockfile
run: gh actions-lock --verify
Generated
+174 -68
View File
@@ -304,7 +304,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -383,6 +383,7 @@ dependencies = [
"chrono",
"cookie_store 0.0.1",
"cookie_store_wrapper",
"craft-update",
"directories",
"dirs",
"discord-rich-presence",
@@ -553,28 +554,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "assertor"
version = "0.0.3"
@@ -638,17 +617,6 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -678,17 +646,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -882,6 +839,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.6.0"
@@ -1468,9 +1431,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -1812,6 +1775,31 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2a24c10817173d26d0ea4689acbbade36f91b5dc#2a24c10817173d26d0ea4689acbbade36f91b5dc"
dependencies = [
"chrono",
"dunce",
"flate2",
"getrandom 0.3.1",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
"sha2 0.10.8",
"tar",
"tempfile",
"ureq",
"verifysign",
"winreg 0.55.0",
"winsafe",
"zip",
]
[[package]]
name = "crc"
version = "3.0.1"
@@ -1829,9 +1817,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.4.2"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64"
dependencies = [
"cfg-if",
]
@@ -2136,7 +2124,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2508,7 +2496,7 @@ dependencies = [
"rustc_version",
"toml 0.8.23",
"vswhom",
"winreg",
"winreg 0.52.0",
]
[[package]]
@@ -2698,7 +2686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2862,6 +2850,16 @@ dependencies = [
"walkdir",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -4768,7 +4766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.52.6",
"windows-targets 0.48.5",
]
[[package]]
@@ -5117,7 +5115,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6272,12 +6270,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "polycool"
version = "0.4.0"
@@ -6988,7 +6980,6 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"glib-sys",
@@ -7000,9 +6991,7 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -7172,7 +7161,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7192,6 +7181,7 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -7242,6 +7232,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 0.22.4",
"log",
"once_cell",
"rustls 0.23.36",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.9",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.60.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.101.7"
@@ -8703,6 +8720,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
]
[[package]]
name = "target-lexicon"
version = "0.12.14"
@@ -9087,7 +9114,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9597,7 +9624,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9661,6 +9688,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "typed-path"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
[[package]]
name = "typeid"
version = "1.0.2"
@@ -9690,7 +9723,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9842,6 +9875,35 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls 0.23.36",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -9931,6 +9993,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -10046,6 +10114,16 @@ dependencies = [
"vello_common",
]
[[package]]
name = "verifysign"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6efd25aadc22c756a9012267fae0b130278d335d6ceb512ec02ce82029b5ca9"
dependencies = [
"core-foundation 0.10.1",
"windows-sys 0.61.2",
]
[[package]]
name = "version-compare"
version = "0.2.0"
@@ -11381,6 +11459,22 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
]
[[package]]
name = "winsafe"
version = "0.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -11943,6 +12037,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711e8c9646791c49b7eb799a19c4ae70a9b8372ddc6a1fa4778defd1e2aa5d5e"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.13.0",
"memchr",
"typed-path",
]
[[package]]
name = "zmij"
version = "1.0.21"
@@ -12010,7 +12117,6 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.0",
"zcheapstr",
"zvariant_derive",
+1
View File
@@ -22,6 +22,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "2a24c10817173d26d0ea4689acbbade36f91b5dc" }
toml.workspace = true
browser_emulation.workspace = true
cookie_store_wrapper = { workspace = true, features = ["wreq"] }
@@ -12,4 +12,5 @@ pub mod platform_info_command;
pub mod providers;
pub mod response;
pub mod task_queue;
pub mod deprecated;
pub mod deprecated;
pub mod updates;
@@ -0,0 +1,8 @@
use crate::updates::{UpdateInfo, Updates};
use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn check_for_updates_command(app: AppHandle, updates: State<'_, Arc<Updates>>) -> Result<UpdateInfo, String> {
updates.inner().run("The update check stopped unexpectedly", move |state| state.check(&app)).await
}
@@ -0,0 +1,9 @@
use crate::updates::{require_confirmation, Updates};
use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn install_update_command(app: AppHandle, updates: State<'_, Arc<Updates>>, confirmed: bool) -> Result<(), String> {
require_confirmation(confirmed)?;
updates.inner().run("The update installer stopped unexpectedly", move |state| state.install(&app)).await
}
@@ -0,0 +1,2 @@
pub mod check_for_updates_command;
pub mod install_update_command;
+26 -2
View File
@@ -6,6 +6,11 @@ use crate::core::commands::generate::generate_mesh_command::generate_mesh_comman
pub mod core;
pub mod services;
pub mod version;
pub mod updates;
use std::sync::Arc;
use crate::core::commands::updates::check_for_updates_command::check_for_updates_command;
use crate::core::commands::updates::install_update_command::install_update_command;
use tauri::Manager;
@@ -155,11 +160,20 @@ pub fn run() {
println!("Initializing backend runtime...");
let builder = tauri::Builder::default()
.manage(Arc::new(updates::Updates::default()))
.on_window_event(|window, event| {
if window.try_state::<Arc<updates::Updates>>().is_some_and(|updates| updates.prevents_exit()) {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
}
}
})
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_upload::init())
.setup(move |app| {
updates::show_previous_outcome(app.handle());
// TODO(bt): This is broken on windows
// log_environment_details();
@@ -225,6 +239,8 @@ pub fn run() {
// TODO: Break this out into another module, because RustRover/IntelliJ lags with these macros.
// My first attempt at naively doing this didn't work because the macros can't find their codegen'd targets.
let builder = builder.invoke_handler(tauri::generate_handler![
check_for_updates_command,
install_update_command,
check_sora_session_command,
download_directory_reveal_command,
download_media_file_command,
@@ -290,6 +306,14 @@ pub fn run() {
worldlabs_receive_bearer_command,
]);
builder.run(tauri::generate_context!("tauri.conf.json"))
.expect("error while running tauri application");
let app = builder.build(tauri::generate_context!("tauri.conf.json"))
.expect("error while building tauri application");
app.run(|handle, event| {
let updates = handle.state::<Arc<updates::Updates>>();
match event {
tauri::RunEvent::ExitRequested { api, .. } if updates.prevents_exit() => api.prevent_exit(),
tauri::RunEvent::Exit => updates.finish(),
_ => {}
}
});
}
+5
View File
@@ -2,5 +2,10 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
if let Some(result) = artcraft_app_lib::updates::finish_helper() {
let failed = result.is_err();
if let Err(error) = result { log::error!("Update failed: {error}"); }
std::process::exit(i32::from(failed));
}
artcraft_app_lib::run();
}
+567
View File
@@ -0,0 +1,567 @@
//! Manual update checks and verified installation shared with the native Craft apps.
use craft_update::pins::{Choice, Pins};
use craft_update::self_update::Install;
use craft_update::{self_update, pick_asset, App, Http, Os, PackageKind, Paths, Platform, Release, Stop};
use enums::tauri::tasks::task_status::TaskStatus;
use serde_derive::Serialize;
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::DialogExt;
use crate::core::state::task_database::TaskDatabase;
use crate::version::ARTCRAFT_VERSION;
const APP: App = App { bundle_id_override: Some("ai.artcraft.app"), ..App::new("artcraft", "ArtCraft", "Create images and video", "storytold/artcraft") };
#[derive(Default)]
pub struct Updates {
busy: AtomicBool,
installing: AtomicBool,
maybe_release: Mutex<Option<Release>>,
maybe_restart: Mutex<Option<Restart>>,
}
#[derive(Debug, Serialize)]
pub struct UpdateInfo {
pub current_version: String,
pub maybe_available_version: Option<String>,
pub can_install: bool,
pub tasks_running: bool,
pub releases_url: String,
}
type Restart = Box<dyn FnOnce() -> Result<(), String> + Send>;
/// Only effects cross this boundary. Tests exercise the same state transitions without
/// networking, changing an installed copy, reading personal preferences, or exiting a process.
trait Backend {
fn stable_choice(&self) -> Result<(), String>;
fn check(&self) -> Result<Option<Release>, String>;
fn idle(&self) -> Result<(), String>;
fn installation(&self) -> Option<(Install, Platform)>;
fn can_replace(&self) -> bool;
fn prepare(&self, install: &Install, release: &Release, platform: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String>;
fn request_exit(&self);
}
struct NativeBackend<'a>(&'a AppHandle);
struct Installing<'a>(&'a AtomicBool);
pub(crate) struct Operation {
updates: Arc<Updates>,
}
impl Updates {
pub(crate) fn begin(self: &Arc<Self>) -> Result<Operation, String> {
self.busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).map_err(|_| "An update operation is already running. Please wait.".to_owned())?;
Ok(Operation { updates: Arc::clone(self) })
}
/// Runs `work` off the async runtime while it holds the one-operation guard.
pub(crate) async fn run<T: Send + 'static>(self: &Arc<Self>, stopped: &str, work: impl FnOnce(&Updates) -> Result<T, String> + Send + 'static) -> Result<T, String> {
let operation = self.begin()?;
let state = Arc::clone(self);
tauri::async_runtime::spawn_blocking(move || {
let _operation = operation;
work(&state)
})
.await
.map_err(|error| format!("{stopped}: {error}"))?
}
pub(crate) fn check(&self, app: &AppHandle) -> Result<UpdateInfo, String> {
self.check_with(&NativeBackend(app))
}
fn check_with(&self, backend: &impl Backend) -> Result<UpdateInfo, String> {
backend.stable_choice()?;
let maybe_release = backend.check()?;
let tasks_running = backend.idle().is_err();
let self_updating = maybe_release.as_ref().is_some_and(|release| backend.can_replace() && backend.installation().is_some_and(|(install, platform)| release_fits_copy(release, &install, platform)));
let info = update_info(maybe_release.as_ref(), self_updating, tasks_running);
*self.maybe_release.lock().map_err(|_| "The update state is unavailable. Restart ArtCraft and try again.")? = maybe_release;
Ok(info)
}
pub(crate) fn install(&self, app: &AppHandle) -> Result<(), String> {
self.install_with(&NativeBackend(app))
}
fn install_with(&self, backend: &impl Backend) -> Result<(), String> {
self.installing.store(true, Ordering::Release);
let _installing = Installing(&self.installing);
backend.idle()?;
backend.stable_choice()?;
let release = self.maybe_release.lock().map_err(|_| "The update state is unavailable. Restart ArtCraft and try again.")?.clone().ok_or("Check for updates before installing one.")?;
let (install, platform) = backend.installation().ok_or("This installation cannot update itself. Open Releases or use its package manager.")?;
if !backend.can_replace() || !release_fits_copy(&release, &install, platform) {
return Err("Use Releases or your package manager to update this installation.".into());
}
// Recheck tasks and launcher choices immediately before replacing any application files.
let before_replace = || backend.idle().and_then(|()| backend.stable_choice());
let restart = backend.prepare(&install, &release, platform, &before_replace)?;
*self.maybe_restart.lock().map_err(|_| "The restart state is unavailable. Restart ArtCraft manually.")? = Some(restart);
// The helper starts in RunEvent::Exit, after Tauri accepts the normal exit request.
backend.request_exit();
Ok(())
}
/// Keep the native process and window alive until replacement finishes. A prepared helper
/// may request the normal exit itself, so its restart must not be cancelled here.
pub fn prevents_exit(&self) -> bool {
self.installing.load(Ordering::Acquire) && self.maybe_restart.lock().map_or(true, |pending| pending.is_none())
}
pub fn finish(&self) {
match self.maybe_restart.lock() {
Ok(mut pending) => {
if let Some(restart) = pending.take() {
if let Err(error) = restart() {
log::error!("Could not start the update helper: {error}");
}
}
},
Err(_) => log::error!("Could not read the prepared update helper"),
}
}
}
impl Drop for Installing<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
impl Drop for Operation {
fn drop(&mut self) {
self.updates.busy.store(false, Ordering::Release);
}
}
pub fn finish_helper() -> Option<Result<(), String>> {
self_update::finish(&APP, &std::env::args_os().skip(1).collect::<Vec<_>>())
}
pub fn show_previous_outcome(app: &AppHandle) {
let Ok(paths) = Paths::for_app(&APP) else {
return;
};
if let Some(message) = outcome_message(self_update::take_outcome(&paths, ARTCRAFT_VERSION)) {
app.dialog().message(message).title("ArtCraft update").show(|_| {});
}
}
fn outcome_message(outcome: Option<self_update::Outcome>) -> Option<String> {
match outcome? {
self_update::Outcome::Installed(version) => Some(format!("ArtCraft was updated to {version}.")),
self_update::Outcome::Failed(error) => Some(format!("The ArtCraft update failed: {error}")),
}
}
pub(crate) fn require_confirmation(confirmed: bool) -> Result<(), String> {
if confirmed {
Ok(())
} else {
Err("Confirm that you saved your work before installing the update.".into())
}
}
fn stable_choice(pins: &Pins) -> Result<(), String> {
match pins.choice(APP.id) {
Choice::Stable => Ok(()),
_ => Err("ArtCraft Launcher manages this app's version. Change its version there.".into()),
}
}
fn ensure_idle(app: &AppHandle) -> Result<(), String> {
let db = app.try_state::<TaskDatabase>().ok_or("ArtCraft is still starting. Try checking again in a moment.")?;
let tasks = tauri::async_runtime::block_on(list_tasks_for_frontend(db.get_connection())).map_err(|error| format!("Could not check running tasks: {error}"))?;
no_active_tasks(tasks.tasks.iter().map(|task| task.status))
}
fn no_active_tasks(statuses: impl IntoIterator<Item = TaskStatus>) -> Result<(), String> {
if statuses.into_iter().any(active_task) {
Err("Finish or cancel running tasks before installing an update.".into())
} else {
Ok(())
}
}
fn active_task(status: TaskStatus) -> bool {
matches!(status, TaskStatus::Pending | TaskStatus::Started | TaskStatus::AttemptFailed)
}
impl Backend for NativeBackend<'_> {
fn stable_choice(&self) -> Result<(), String> {
stable_choice(&Pins::load_default())
}
fn check(&self) -> Result<Option<Release>, String> {
let http = Http::new(&format!("ArtCraft/{ARTCRAFT_VERSION}"))?;
craft_update::check_for_update(&APP, &http, ARTCRAFT_VERSION)
}
fn idle(&self) -> Result<(), String> {
ensure_idle(self.0)
}
fn installation(&self) -> Option<(Install, Platform)> {
self_update::current(&APP).zip(Platform::current())
}
fn can_replace(&self) -> bool {
self_update::is_self_updating(&APP)
}
fn prepare(&self, install: &Install, release: &Release, platform: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String> {
let http = Http::new(&format!("ArtCraft/{ARTCRAFT_VERSION}"))?;
let paths = Paths::for_app(&APP)?;
let restart = self_update::prepare_update(&APP, install, release, platform, &http, &paths, &Stop::default(), &mut |_| {}, before_replace, Some(ARTCRAFT_VERSION))?;
Ok(Box::new(move || restart.start()))
}
fn request_exit(&self) {
self.0.exit(0);
}
}
fn release_fits_copy(release: &Release, install: &Install, platform: Platform) -> bool {
let kind = match install {
Install::MacApp(_) => PackageKind::Dmg,
Install::Msi(_) => PackageKind::Msi,
Install::Portable(_) => PackageKind::PortableZip,
Install::AppImage(_) => PackageKind::AppImage,
Install::Package(kind, _) => *kind,
Install::Flatpak => PackageKind::Flatpak,
// ArtCraft also ships NSIS. It must not be unpacked over as a loose portable copy;
// its registered installer remains its update path until the shared helper supports it.
Install::Standalone(_) if platform.os == Os::Windows => return false,
Install::Standalone(_) => PackageKind::TarGz,
};
pick_asset(release, &[APP.id], platform, &[kind]).is_some()
}
fn update_info(maybe_release: Option<&Release>, self_updating: bool, tasks_running: bool) -> UpdateInfo {
UpdateInfo { current_version: ARTCRAFT_VERSION.into(), maybe_available_version: maybe_release.map(Release::version_label), can_install: !tasks_running && self_updating && maybe_release.is_some(), tasks_running, releases_url: APP.releases_url() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_identity_and_display_version_match_the_release_configuration() {
let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).expect("release configuration");
assert_eq!(config["version"].as_str(), Some(ARTCRAFT_VERSION));
assert_eq!(config["identifier"].as_str(), Some(APP.bundle_id().as_str()));
let mac: serde_json::Value = serde_json::from_str(include_str!("../tauri-mac.conf.json")).expect("macOS release configuration");
assert_eq!(mac["version"].as_str(), Some(ARTCRAFT_VERSION), "the macOS build must report the same version");
}
#[test]
fn tauri_installers_and_flatpaks_are_offered_only_for_the_matching_copy() {
use craft_update::{Arch, Asset};
let release = Release { assets: ["ArtCraft_0.42.1_x64_en-US.msi", "ArtCraft_0.42.1_universal.dmg", "artcraft-0.42.1-linux-x86_64.flatpak"].into_iter().map(|name| Asset { name: name.into(), ..Asset::default() }).collect(), ..Release::default() };
assert!(release_fits_copy(&release, &Install::Msi("unused.exe".into()), Platform { os: Os::Windows, arch: Arch::X64 }));
assert!(release_fits_copy(&release, &Install::MacApp("unused.app".into()), Platform { os: Os::MacOs, arch: Arch::Arm64 }));
assert!(release_fits_copy(&release, &Install::Flatpak, Platform { os: Os::Linux, arch: Arch::X64 }));
assert!(!release_fits_copy(&release, &Install::Flatpak, Platform { os: Os::Linux, arch: Arch::Arm64 }));
assert!(!release_fits_copy(&release, &Install::Standalone("unused.exe".into()), Platform { os: Os::Windows, arch: Arch::X64 }));
}
#[test]
fn checks_allow_closing_but_an_unfinished_install_keeps_the_window_alive() {
let state = Arc::new(Updates::default());
let _check = state.begin().expect("checking");
assert!(!state.prevents_exit());
state.installing.store(true, Ordering::Release);
let installing = Installing(&state.installing);
assert!(state.prevents_exit());
drop(installing);
assert!(!state.prevents_exit());
}
#[test]
fn running_and_retrying_tasks_block_an_update_but_completed_tasks_do_not() {
for status in [TaskStatus::Pending, TaskStatus::Started, TaskStatus::AttemptFailed] {
assert!(active_task(status));
}
for status in [TaskStatus::CompleteSuccess, TaskStatus::CompleteFailure, TaskStatus::Dead, TaskStatus::CancelledByUser, TaskStatus::CancelledByProvider, TaskStatus::CancelledByUs] {
assert!(!active_task(status));
}
assert!(no_active_tasks([TaskStatus::CompleteSuccess, TaskStatus::Dead]).is_ok());
assert!(no_active_tasks([]).is_ok());
assert_eq!(no_active_tasks([TaskStatus::CompleteSuccess, TaskStatus::Started]), Err("Finish or cancel running tasks before installing an update.".into()));
}
#[test]
fn only_the_stable_track_lets_artcraft_update_itself() {
use craft_update::pins::VersionTrack;
let mut pins = Pins::default();
assert!(stable_choice(&pins).is_ok());
pins.set(APP.id, &VersionTrack::Pinned("0.41.0".into()));
assert!(stable_choice(&pins).unwrap_err().contains("ArtCraft Launcher manages this app's version"));
pins.set(APP.id, &VersionTrack::Nightly);
assert!(stable_choice(&pins).is_err());
pins.set(APP.id, &VersionTrack::Stable);
assert!(stable_choice(&pins).is_ok());
}
#[test]
fn a_previous_update_outcome_is_reported_once_with_its_version_or_error() {
let installed = craft_update::parse_version("0.42.1").expect("version");
assert_eq!(outcome_message(Some(self_update::Outcome::Installed(installed))).as_deref(), Some("ArtCraft was updated to 0.42.1."));
assert_eq!(outcome_message(Some(self_update::Outcome::Failed("disk full".into()))).as_deref(), Some("The ArtCraft update failed: disk full"));
assert_eq!(outcome_message(None), None);
}
#[test]
fn installing_requires_confirmation_and_an_ordinary_start_is_not_the_helper() {
assert!(require_confirmation(false).unwrap_err().contains("saved your work"));
assert!(require_confirmation(true).is_ok());
// The test harness's own arguments never carry the helper flag.
assert!(finish_helper().is_none());
}
#[test]
fn commands_run_one_operation_at_a_time_and_release_it_after_failures() {
let state = Arc::new(Updates::default());
let value = tauri::async_runtime::block_on(state.run("stopped", |_| Ok(7)));
assert_eq!(value, Ok(7));
let failed: Result<(), String> = tauri::async_runtime::block_on(state.run("stopped", |_| Err("offline".into())));
assert_eq!(failed, Err("offline".into()));
let held = state.begin().expect("guard released after a failure");
let busy = tauri::async_runtime::block_on(state.run("stopped", |_| Ok(())));
assert!(busy.unwrap_err().contains("already running"));
drop(held);
let panicked: Result<(), String> = tauri::async_runtime::block_on(state.run("The update check stopped unexpectedly", |_| panic!("worker crashed")));
assert!(panicked.unwrap_err().starts_with("The update check stopped unexpectedly:"));
assert!(state.begin().is_ok(), "a crashed worker still releases the guard");
}
#[test]
fn only_a_replaceable_idle_copy_can_offer_installation() {
let release = Release { tag: "artcraft-v99.0.0".into(), ..Release::default() };
assert!(!update_info(Some(&release), false, false).can_install);
assert!(!update_info(Some(&release), true, true).can_install);
assert!(!update_info(None, true, false).can_install);
assert_eq!(update_info(Some(&release), false, false).maybe_available_version.as_deref(), Some("artcraft-v99.0.0"));
assert_eq!(APP.bundle_id(), "ai.artcraft.app");
}
#[test]
fn operations_are_serialized_and_errors_release_the_guard() {
let state = Arc::new(Updates::default());
let first = state.begin().expect("first check");
assert!(state.begin().is_err());
drop(first);
assert!(state.begin().is_ok());
assert!(state.maybe_release.lock().expect("state").is_none());
}
#[test]
fn a_manual_check_offers_a_matching_idle_install_and_defers_its_helper_until_exit() {
let state = Updates::default();
let backend = FakeBackend::default();
let info = state.check_with(&backend).expect("checked");
assert!(info.can_install);
state.install_with(&backend).expect("prepared");
assert!(backend.exit_requested.load(Ordering::Acquire));
assert!(!backend.helper_started.load(Ordering::Acquire));
assert!(!state.prevents_exit(), "the prepared update may exit normally");
state.finish();
assert!(backend.helper_started.load(Ordering::Acquire));
state.finish();
assert_eq!(backend.helper_runs.load(Ordering::Acquire), 1, "exit events cannot run a helper twice");
}
#[test]
fn a_pin_stops_network_checks_and_replacement() {
let state = Updates::default();
let backend = FakeBackend::default();
backend.pinned.store(true, Ordering::Release);
assert!(state.check_with(&backend).unwrap_err().contains("pinned"));
assert!(!backend.checked.load(Ordering::Acquire));
assert!(state.install_with(&backend).unwrap_err().contains("pinned"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn running_tasks_allow_a_check_but_block_the_install() {
let state = Updates::default();
let backend = FakeBackend::default();
backend.busy.store(true, Ordering::Release);
let info = state.check_with(&backend).expect("checking while busy");
assert!(info.tasks_running);
assert!(!info.can_install);
assert!(state.install_with(&backend).unwrap_err().contains("busy"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn a_pin_or_task_started_during_download_stops_replacement_and_exit() {
for pin in [false, true] {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("checked");
backend.pin_during_download.store(pin, Ordering::Release);
backend.task_during_download.store(!pin, Ordering::Release);
assert!(state.install_with(&backend).is_err());
assert!(!state.prevents_exit(), "failed installation releases the close guard");
assert!(!backend.exit_requested.load(Ordering::Acquire));
assert!(state.maybe_restart.lock().expect("restart state").is_none());
}
}
#[test]
fn an_install_requires_a_previous_check_and_a_replaceable_matching_copy() {
let state = Updates::default();
let backend = FakeBackend::default();
assert!(state.install_with(&backend).unwrap_err().contains("Check for updates"));
state.check_with(&backend).expect("checked");
backend.managed.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("managed check").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("package manager"));
backend.managed.store(false, Ordering::Release);
backend.missing_install.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("unknown install").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("cannot update"));
backend.missing_install.store(false, Ordering::Release);
backend.wrong_arch.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("no matching asset").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("package manager"));
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn no_new_release_clears_a_previous_offer_and_a_failed_check_is_reported() {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("first check");
backend.no_release.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("current version").can_install);
assert!(state.maybe_release.lock().expect("release").is_none());
backend.network_error.store(true, Ordering::Release);
assert!(state.check_with(&backend).unwrap_err().contains("offline"));
}
#[test]
fn a_failed_download_never_requests_exit_or_keeps_the_close_guard() {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("check");
backend.download_error.store(true, Ordering::Release);
assert!(state.install_with(&backend).unwrap_err().contains("download"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn poisoned_state_returns_an_error_and_refuses_an_unsafe_exit() {
let state = Arc::new(Updates::default());
let other = Arc::clone(&state);
let _ = std::thread::spawn(move || {
let _guard = other.maybe_release.lock().unwrap();
panic!("simulate a previous worker failure");
})
.join();
let backend = FakeBackend::default();
assert!(state.check_with(&backend).is_err());
assert!(state.install_with(&backend).is_err());
let other = Arc::clone(&state);
let _ = std::thread::spawn(move || {
let _guard = other.maybe_restart.lock().unwrap();
panic!("simulate a previous helper-state failure");
})
.join();
state.installing.store(true, Ordering::Release);
assert!(state.prevents_exit());
state.finish();
}
#[test]
fn every_supported_install_kind_requires_its_own_asset() {
use craft_update::{Arch, Asset};
let linux = Platform { os: Os::Linux, arch: Arch::X64 };
let windows = Platform { os: Os::Windows, arch: Arch::X64 };
let release = Release { assets: ["artcraft-99.0.0-linux-x86_64.AppImage", "artcraft-99.0.0-linux-x86_64.tar.gz", "artcraft-99.0.0-linux-x86_64.deb", "artcraft-99.0.0-windows-x64-portable.zip"].into_iter().map(|name| Asset { name: name.into(), ..Asset::default() }).collect(), ..Release::default() };
for install in [Install::AppImage("unused".into()), Install::Standalone("unused".into()), Install::Package(PackageKind::Deb, "unused".into())] {
assert!(release_fits_copy(&release, &install, linux));
}
assert!(release_fits_copy(&release, &Install::Portable("unused".into()), windows));
assert!(!release_fits_copy(&release, &Install::Package(PackageKind::Rpm, "unused".into()), linux));
}
#[derive(Default)]
struct FakeBackend {
pinned: AtomicBool,
busy: AtomicBool,
managed: AtomicBool,
missing_install: AtomicBool,
wrong_arch: AtomicBool,
no_release: AtomicBool,
network_error: AtomicBool,
download_error: AtomicBool,
pin_during_download: AtomicBool,
task_during_download: AtomicBool,
checked: AtomicBool,
exit_requested: AtomicBool,
helper_started: Arc<AtomicBool>,
helper_runs: Arc<std::sync::atomic::AtomicUsize>,
}
impl Backend for FakeBackend {
fn stable_choice(&self) -> Result<(), String> {
if self.pinned.load(Ordering::Acquire) {
Err("pinned".into())
} else {
Ok(())
}
}
fn check(&self) -> Result<Option<Release>, String> {
self.checked.store(true, Ordering::Release);
if self.network_error.load(Ordering::Acquire) {
return Err("offline".into());
}
Ok((!self.no_release.load(Ordering::Acquire)).then(|| Release { tag: "artcraft-v99.0.0".into(), assets: vec![craft_update::Asset { name: "artcraft-99.0.0-linux-x86_64.flatpak".into(), ..Default::default() }], ..Default::default() }))
}
fn idle(&self) -> Result<(), String> {
if self.busy.load(Ordering::Acquire) {
Err("busy".into())
} else {
Ok(())
}
}
fn installation(&self) -> Option<(Install, Platform)> {
(!self.missing_install.load(Ordering::Acquire)).then(|| (Install::Flatpak, Platform { os: Os::Linux, arch: if self.wrong_arch.load(Ordering::Acquire) { craft_update::Arch::Arm64 } else { craft_update::Arch::X64 } }))
}
fn can_replace(&self) -> bool {
!self.managed.load(Ordering::Acquire)
}
fn prepare(&self, _: &Install, _: &Release, _: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String> {
if self.download_error.load(Ordering::Acquire) {
return Err("download failed".into());
}
if self.pin_during_download.load(Ordering::Acquire) {
self.pinned.store(true, Ordering::Release);
}
if self.task_during_download.load(Ordering::Acquire) {
self.busy.store(true, Ordering::Release);
}
before_replace()?;
let started = Arc::clone(&self.helper_started);
let runs = Arc::clone(&self.helper_runs);
Ok(Box::new(move || {
started.store(true, Ordering::Release);
runs.fetch_add(1, Ordering::AcqRel);
Ok(())
}))
}
fn request_exit(&self) {
self.exit_requested.store(true, Ordering::Release);
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.42.0";
pub const ARTCRAFT_VERSION: &str = "0.42.1";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.42.0",
"version": "0.42.1",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.42.0",
"version": "0.42.1",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -1,4 +1,4 @@
import { render } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import HelpMenuButton from './help-menu';
@@ -9,4 +9,11 @@ describe('HelpMenuButton', () => {
expect(baseElement).toBeTruthy();
});
it('opens the update dialog from Help', async () => {
render(<HelpMenuButton />);
fireEvent.click(screen.getByRole('button', { name: 'Help' }));
fireEvent.click(await screen.findByText('Check for Updates…'));
expect(await screen.findByRole('dialog', { name: 'Check for Updates' }, { timeout: 5000 })).toBeTruthy();
expect(screen.getByText('The web app updates when you reload. Open Releases to get the desktop app.')).toBeTruthy();
});
});
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRightIcon, BookIcon, ChevronLeftIcon, CircleHelpIcon, NewspaperIcon } from "lucide-react";
import { ArrowRightIcon, BookIcon, ChevronLeftIcon, CircleHelpIcon, NewspaperIcon, RefreshCwIcon } from "lucide-react";
import { DiscordIcon, GithubIcon, YoutubeIcon } from "@storyteller/icons";
import { OpenUrl } from "@storyteller/tauri-api";
import { Modal } from "@storyteller/ui-modal";
@@ -10,6 +10,8 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { getNewsPosts, markdownToHtml } from "@storyteller/markdown-content";
import { clickOnEnterOrSpace } from "@storyteller/common";
import { UpdateDialog } from "./update-dialog";
export type HelpMenuButtonProps = {
items?: TutorialItem[];
panelTitle?: string;
@@ -150,6 +152,7 @@ export function HelpMenuButton({
onOpenChange,
}: HelpMenuButtonProps) {
const [open, setOpen] = useState(false);
const [updatesOpen, setUpdatesOpen] = useState(false);
const tutorials = useMemo(() => items ?? defaultTutorials, [items]);
const view = useTutorialModalStore((s) => s.view);
const selected = useTutorialModalStore((s) => s.selected);
@@ -180,6 +183,7 @@ export function HelpMenuButton({
// Menu items for the popover
const menuItems: PopoverItem[] = [
{ label: "Check for Updates…", selected: false, icon: <RefreshCwIcon className="text-base" />, action: "updates" },
{
label: "Tutorials",
selected: false,
@@ -214,6 +218,9 @@ export function HelpMenuButton({
const handleMenuSelect = (item: PopoverItem) => {
switch (item.action) {
case "updates":
setUpdatesOpen(true);
break;
case "tutorials":
handleOpenTutorials();
break;
@@ -340,6 +347,7 @@ export function HelpMenuButton({
return (
<div className={className}>
<UpdateDialog isOpen={updatesOpen} onClose={() => setUpdatesOpen(false)} />
<PopoverMenu
items={menuItems}
onSelect={handleMenuSelect}
@@ -0,0 +1,106 @@
import { StrictMode } from "react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isTauri } from "@tauri-apps/api/core";
import { CheckForUpdates, InstallUpdate, OpenUrl, type UpdateInfo } from "@storyteller/tauri-api";
import { UpdateDialog } from "./update-dialog";
vi.mock("@tauri-apps/api/core", () => ({ isTauri: vi.fn() }));
vi.mock("@storyteller/tauri-api", () => ({ CheckForUpdates: vi.fn(), InstallUpdate: vi.fn(), OpenUrl: vi.fn() }));
vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, variant: _variant, ...props }: any) => <button {...props}>{children}</button> }));
vi.mock("@storyteller/ui-modal", () => ({ Modal: ({ isOpen, title, onClose, showClose, children }: any) => isOpen ? (
<section role="dialog" aria-label={title}><button aria-label="Close" disabled={!showClose} onClick={onClose}>×</button>{children}</section>
) : null }));
const INFO: UpdateInfo = { current_version: "0.42.0", maybe_available_version: "0.42.1", can_install: true,
tasks_running: false, releases_url: "https://github.com/storytold/artcraft/releases" };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(CheckForUpdates).mockResolvedValue(INFO);
vi.mocked(InstallUpdate).mockResolvedValue(undefined);
});
afterEach(cleanup);
describe("manual updates", () => {
it("does not check while closed and cancelling never installs", async () => {
const close = vi.fn();
const view = render(<UpdateDialog isOpen={false} onClose={close} />);
expect(CheckForUpdates).not.toHaveBeenCalled();
view.rerender(<UpdateDialog isOpen onClose={close} />);
await screen.findByText("0.42.1 is available.");
expect(screen.getByText("ArtCraft 0.42.0")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(close).toHaveBeenCalledOnce();
expect(InstallUpdate).not.toHaveBeenCalled();
});
it("shares StrictMode checks while progress remains visible", async () => {
let complete!: (info: UpdateInfo) => void;
vi.mocked(CheckForUpdates).mockReturnValue(new Promise((resolve) => { complete = resolve; }));
render(<StrictMode><UpdateDialog isOpen onClose={vi.fn()} /></StrictMode>);
expect(CheckForUpdates).toHaveBeenCalledOnce();
expect(screen.getByRole("status").textContent).toContain("Checking for updates");
expect((screen.getByRole("button", { name: "Check again" }) as HTMLButtonElement).disabled).toBe(true);
await act(async () => { complete(INFO); });
expect(screen.getByText("0.42.1 is available.")).toBeTruthy();
});
it("requires an install click and prevents dismissal during installation", async () => {
vi.mocked(InstallUpdate).mockReturnValue(new Promise(() => {}));
const close = vi.fn();
render(<UpdateDialog isOpen onClose={close} />);
await screen.findByText("0.42.1 is available.");
expect(InstallUpdate).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Update and restart" }));
expect(InstallUpdate).toHaveBeenCalledOnce();
expect(screen.getByRole("status").textContent).toContain("Downloading and verifying");
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
expect((screen.getByRole("button", { name: "Close" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement).disabled).toBe(true);
expect(close).not.toHaveBeenCalled();
});
it("reports a check failure and allows another manual check", async () => {
vi.mocked(CheckForUpdates).mockRejectedValueOnce("Network unavailable");
render(<UpdateDialog isOpen onClose={vi.fn()} />);
expect((await screen.findByRole("alert")).textContent).toBe("Network unavailable");
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
await screen.findByText("0.42.1 is available.");
expect(CheckForUpdates).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("alert")).toBeNull();
});
it("restores controls after verification fails without closing the app", async () => {
vi.mocked(InstallUpdate).mockRejectedValue("Signature verification failed");
const close = vi.fn();
render(<UpdateDialog isOpen onClose={close} />);
await screen.findByText("0.42.1 is available.");
fireEvent.click(screen.getByRole("button", { name: "Update and restart" }));
expect((await screen.findByRole("alert")).textContent).toBe("Signature verification failed");
expect((screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
expect(close).not.toHaveBeenCalled();
});
it.each([false, true])("cannot install a managed or busy copy (tasks running: %s)", async (running) => {
vi.mocked(CheckForUpdates).mockResolvedValue({ ...INFO, can_install: false, tasks_running: running });
render(<UpdateDialog isOpen onClose={vi.fn()} />);
await screen.findByText("0.42.1 is available.");
const button = screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(InstallUpdate).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Releases" }));
expect(OpenUrl).toHaveBeenCalledWith(INFO.releases_url);
});
it("offers browser users a reload without invoking native commands", () => {
vi.mocked(isTauri).mockReturnValue(false);
render(<UpdateDialog isOpen onClose={vi.fn()} />);
expect(screen.getByRole("status").textContent).toContain("updates when you reload");
expect(CheckForUpdates).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Update and restart" })).toBeNull();
});
});
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
import { isTauri } from "@tauri-apps/api/core";
import { CheckForUpdates, InstallUpdate, OpenUrl, type UpdateInfo } from "@storyteller/tauri-api";
import { Button } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
const RELEASES = "https://github.com/storytold/artcraft/releases";
export function UpdateDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const pendingCheck = useRef<Promise<UpdateInfo> | null>(null);
const desktop = isTauri();
const check = async () => {
setChecking(true);
setError(null);
setInfo(null);
try {
// StrictMode or a repeated click must share one native background check.
const pending = pendingCheck.current ?? CheckForUpdates();
pendingCheck.current = pending;
setInfo(await pending);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
pendingCheck.current = null;
setChecking(false);
}
};
useEffect(() => {
if (isOpen && desktop) void check();
// A check is manual: reopening this dialog is the only automatic trigger.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, desktop]);
const install = async () => {
if (!info?.can_install || installing) return;
setInstalling(true);
setError(null);
try {
await InstallUpdate();
// Success closes the application through its normal native exit path.
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setInstalling(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={() => { if (!installing) onClose(); }}
title="Check for Updates"
accessibleTitle="Check for Updates"
className="max-w-lg"
showClose={!installing}
closeOnEsc={!installing}
closeOnOutsideClick={!installing}
>
<div className="flex flex-col gap-4 text-base-fg">
<p>ArtCraft{info ? ` ${info.current_version}` : ""}</p>
<div role="status" aria-live="polite">
{!desktop ? "The web app updates when you reload. Open Releases to get the desktop app."
: installing ? "Downloading and verifying the update… ArtCraft will restart when it is ready."
: checking ? "Checking for updates…"
: info?.maybe_available_version ? `${info.maybe_available_version} is available.`
: info ? "You're up to date." : null}
</div>
{error && <p role="alert">{error}</p>}
{info?.maybe_available_version && (
<p className="text-sm text-base-fg/70">
{info.tasks_running ? "Finish or cancel running tasks, then check again before updating."
: info.can_install ? "Save your work before updating. ArtCraft will close and restart."
: "Use Releases or your package manager to update this installation."}
</p>
)}
<div className="flex flex-wrap justify-end gap-2">
<Button variant="action" disabled={installing} onClick={() => OpenUrl(info?.releases_url ?? RELEASES)}>Releases</Button>
{desktop && <Button variant="action" disabled={checking || installing} onClick={() => void check()}>Check again</Button>}
{info?.maybe_available_version && <Button variant="primary" disabled={!info.can_install || checking || installing} onClick={() => void install()}>Update and restart</Button>}
{!installing && <Button variant="action" onClick={onClose}>Cancel</Button>}
</div>
</div>
</Modal>
);
}
+2
View File
@@ -54,3 +54,5 @@ export * from "./lib/models/modelsStore.js";
export * from "./lib/common/commandErrorMessage.js";
export * from "./lib/generate/GenerateOmni.js";
export * from "./lib/system/Updates.js";
@@ -0,0 +1,15 @@
import { invoke } from "@tauri-apps/api/core";
export interface UpdateInfo {
current_version: string;
maybe_available_version: string | null;
can_install: boolean;
tasks_running: boolean;
releases_url: string;
}
export const CheckForUpdates = (): Promise<UpdateInfo> =>
invoke("check_for_updates_command");
export const InstallUpdate = (): Promise<void> =>
invoke("install_update_command", { confirmed: true });
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# A bundle must reach releases, honor launcher choices, and invoke the verified host updater.
set -euo pipefail
manifest="$(dirname "$0")/flatpak.yml"
for permission in --share=network --talk-name=org.freedesktop.Flatpak --filesystem=xdg-data/ArtCraft/versions:ro; do
if ! grep -Fxq -- " - $permission" "$manifest"; then
echo "Missing Flatpak update permission: $permission" >&2
exit 1
fi
done
+4
View File
@@ -4,6 +4,10 @@ runtime-version: '51'
sdk: org.gnome.Sdk
command: artcraft
finish-args:
# The shared updater inspects and replaces this signed release bundle on the host.
- --talk-name=org.freedesktop.Flatpak
# Honor launcher-pinned versions without access to other host application data.
- --filesystem=xdg-data/ArtCraft/versions:ro
- --socket=wayland
- --socket=fallback-x11
- --socket=pulseaudio
+1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Integration fixture: real DEB/RPM archives, with no Rust compilation or signing key.
set -euo pipefail
bash "$(dirname "$0")/flatpak-update-test.sh"
script="$(realpath "$(dirname "$0")/package.sh")"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
+9 -1
View File
@@ -22,10 +22,18 @@ sonar.exclusions=crates/vendor/**,crates/api_clients/grok_consumer_client/src/cr
# test/app/Classes/ApiManager/utils/, which call `it` themselves.
# S2819 (message origin): a dedicated worker's `message` events come only from the page that
# started it; they carry no origin to check.
sonar.issue.ignore.multicriteria=deterministic_rng,api_test_helpers,worker_messages
sonar.issue.ignore.multicriteria=deterministic_rng,api_test_helpers,worker_messages,gha-lockfile
sonar.issue.ignore.multicriteria.deterministic_rng.ruleKey=rust:S4347
sonar.issue.ignore.multicriteria.deterministic_rng.resourceKey=crates/schema/public/tokens/src/deterministic_rng.rs
sonar.issue.ignore.multicriteria.api_test_helpers.ruleKey=typescript:S2187
sonar.issue.ignore.multicriteria.api_test_helpers.resourceKey=frontend/apps/artcraft/test/app/Classes/ApiManager/**
sonar.issue.ignore.multicriteria.worker_messages.ruleKey=typescript:S2819
sonar.issue.ignore.multicriteria.worker_messages.resourceKey=**/*.worker.ts
# githubactions:S7637 asks for every action to be pinned to a commit SHA. A workflow covered by
# .github/workflows/actions.lock runs the commit the lockfile records, so its tag refs are pinned
# and the lockfile covers the actions those actions use too. Sonar does not read lockfiles yet, so
# the rule flags the tag refs anyway:
# https://community.sonarsource.com/t/githubactions-s7637-false-positives-for-actions-pinned-through-native-actions-lock/190814
sonar.issue.ignore.multicriteria.gha-lockfile.ruleKey=githubactions:S7637
sonar.issue.ignore.multicriteria.gha-lockfile.resourceKey=.github/workflows/**