mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-12 00:46:45 -05:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cfc098ea0 | ||
|
|
84a6a7dcf5 | ||
|
|
4ecd4b7eea | ||
|
|
a787c371f6 | ||
|
|
e546fff742 | ||
|
|
1d79cdf52d | ||
|
|
55e6e5cabb | ||
|
|
7c8419fabd | ||
|
|
130bfd71d6 | ||
|
|
d6772fdd94 | ||
|
|
0c11395467 | ||
|
|
773e5925b9 | ||
|
|
8d6fc363b1 | ||
|
|
f9f4c99e4f | ||
|
|
71adfd4701 | ||
|
|
c9da5cac18 | ||
|
|
83aad5eb54 | ||
|
|
50afb36f9c | ||
|
|
c3fa0453a4 | ||
|
|
1b99c184ef | ||
|
|
1510abf563 | ||
|
|
1218293fe2 | ||
|
|
56b3090924 | ||
|
|
79d87b2b54 | ||
|
|
1ae54cabc0 | ||
|
|
4bf1b20a75 |
@@ -0,0 +1,215 @@
|
||||
name: ArtCraft Linux release
|
||||
on:
|
||||
push:
|
||||
branches: [release, artcraft-release, artcraft-linux]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: linux-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
packaging-check:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7
|
||||
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
|
||||
with:
|
||||
packages: rpm jq binutils zsync
|
||||
- run: packaging/linux/package-test.sh
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {arch: x86_64, runner: ubuntu-22.04, target: x86_64-unknown-linux-gnu, bundles: "deb,rpm,appimage"}
|
||||
- {arch: aarch64, runner: ubuntu-22.04-arm, target: aarch64-unknown-linux-gnu, bundles: "deb,rpm,appimage"}
|
||||
- {arch: riscv64, runner: ubuntu-24.04, target: riscv64gc-unknown-linux-gnu, bundles: "deb,rpm"}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
SQLX_OFFLINE: 'true'
|
||||
VITE_ENVIRONMENT_TYPE: production
|
||||
TAURI_FRONTEND_PATH: ${{ github.workspace }}/frontend
|
||||
TAURI_APP_PATH: ${{ github.workspace }}/crates/desktop/artcraft
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7
|
||||
- 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 xdg-utils
|
||||
- uses: storytold/craft-common-actions/actions/install-riscv64-cross@main
|
||||
if: matrix.arch == 'riscv64'
|
||||
- name: Remove conflicting host Pango development files
|
||||
if: matrix.arch == 'riscv64'
|
||||
# Ubuntu's Pango GIR differs by architecture despite being in a shared path.
|
||||
# Only the target GTK development packages are needed for this cross build.
|
||||
run: sudo apt-get remove -y libpango1.0-dev:amd64
|
||||
- name: Install RISC-V WebKitGTK dependencies
|
||||
if: matrix.arch == 'riscv64'
|
||||
uses: storytold/craft-common-actions/actions/install-apt-packages@main
|
||||
with:
|
||||
packages: libwebkit2gtk-4.1-dev:riscv64 libappindicator3-dev:riscv64 libssl-dev:riscv64 libxdo-dev:riscv64 libdbus-1-dev:riscv64 g++-riscv64-linux-gnu
|
||||
- name: Configure RISC-V cross compilation
|
||||
if: matrix.arch == 'riscv64'
|
||||
run: |
|
||||
cat >> "$GITHUB_ENV" <<'ENV'
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER=riscv64-linux-gnu-gcc
|
||||
CC_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-gcc
|
||||
CXX_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-g++
|
||||
AR_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-ar
|
||||
PKG_CONFIG_ALLOW_CROSS=1
|
||||
PKG_CONFIG_PATH_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
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
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2
|
||||
with:
|
||||
key: tauri-linux-${{ matrix.arch }}
|
||||
cache-on-failure: true
|
||||
- run: npm ci --ignore-scripts
|
||||
working-directory: frontend
|
||||
- run: ./node_modules/.bin/nx run artcraft:build
|
||||
working-directory: frontend
|
||||
- uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
|
||||
with:
|
||||
projectPath: crates/desktop/artcraft
|
||||
args: --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config ${{ github.workspace }}/packaging/linux/tauri.conf.json
|
||||
- name: Package and verify Linux outputs
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: packaging/linux/package.sh "$ARCH" "$TARGET"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
arch:
|
||||
if: always() && !cancelled()
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x86_64, aarch64]
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Find tarball
|
||||
id: tarball
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
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)
|
||||
with:
|
||||
tarball: ${{ steps.tarball.outputs.path }}
|
||||
arch: ${{ matrix.arch }}
|
||||
extra-depends: webkit2gtk-4.1 gtk3 openssl
|
||||
- name: Verify Arch runtime dependencies
|
||||
run: |
|
||||
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
|
||||
with:
|
||||
name: release-arch-${{ matrix.arch }}
|
||||
path: dist/release/*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
flatpak:
|
||||
if: always() && !cancelled()
|
||||
needs: build
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {arch: x86_64, runner: ubuntu-24.04}
|
||||
- {arch: aarch64, runner: ubuntu-24.04-arm}
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
|
||||
with:
|
||||
packages: flatpak flatpak-builder elfutils librsvg2-common skopeo
|
||||
- name: Allow Flatpak build sandboxes
|
||||
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- uses: storytold/craft-common-actions/actions/import-signing-key@main
|
||||
with:
|
||||
key: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
- name: Build Flatpak and publish OCI
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: packaging/linux/flatpak.sh
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: |
|
||||
dist/release/*.flatpak
|
||||
dist/release/*.oci-manifest.json
|
||||
if-no-files-found: error
|
||||
release:
|
||||
needs: [packaging-check, build, arch, flatpak]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7
|
||||
- name: Read version
|
||||
id: version
|
||||
run: echo "value=$(jq -r .version crates/desktop/artcraft/tauri.conf.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: Require signing key
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
run: test -n "$SIGNING_KEY"
|
||||
- uses: storytold/craft-common-actions/actions/draft-release@main
|
||||
with:
|
||||
version: ${{ steps.version.outputs.value }}
|
||||
app-title: ArtCraft
|
||||
tag-prefix: artcraft-v
|
||||
retain: "true"
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
signing-key: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
@@ -10,6 +10,13 @@ on:
|
||||
# This workflow creates Universal macOS builds with signing certificate support.
|
||||
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
|
||||
|
||||
# One release build per branch at a time. A run in progress is never cancelled, because it may
|
||||
# already have created or uploaded to the draft release. If more pushes arrive while one runs,
|
||||
# GitHub keeps only the newest as pending, so the next build is of the latest commit.
|
||||
concurrency:
|
||||
group: macos-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-artcraft:
|
||||
permissions:
|
||||
@@ -28,17 +35,24 @@ jobs:
|
||||
# args: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
# Successful builds took 10-22 min (2026-04 to 2026-11).
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
@@ -63,7 +77,7 @@ jobs:
|
||||
npm cache clean --force
|
||||
|
||||
echo "Installing all workspace dependencies..."
|
||||
npm ci
|
||||
npm ci --ignore-scripts
|
||||
|
||||
echo "=== Version Information ==="
|
||||
echo "Node version:"
|
||||
@@ -84,7 +98,7 @@ jobs:
|
||||
echo "Starting certificate import process..."
|
||||
|
||||
# Create certificate file
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
|
||||
|
||||
# Debug: Check if certificate file exists and has content
|
||||
echo "Certificate file info:"
|
||||
@@ -93,25 +107,25 @@ jobs:
|
||||
|
||||
# Debug: Check certificate contents without password (just structure)
|
||||
echo "Certificate structure (may show error if password protected):"
|
||||
openssl pkcs12 -info -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -noout || echo "Could not read certificate structure"
|
||||
openssl pkcs12 -info -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -noout || echo "Could not read certificate structure"
|
||||
|
||||
# Debug: Try to list what's in the p12 file
|
||||
echo "Attempting to list certificate contents:"
|
||||
security cms -D -i certificate.p12 || echo "Not a CMS file, trying PKCS12..."
|
||||
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
|
||||
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
|
||||
|
||||
# Debug: Extract certificate details to verify type and expiration
|
||||
echo "Extracting certificate details..."
|
||||
# Extract just the certificate portion and examine it
|
||||
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
|
||||
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
|
||||
|
||||
# Also try to see what type of certificate this is
|
||||
echo "Certificate type check:"
|
||||
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
|
||||
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
|
||||
|
||||
# Debug: Check if private key exists
|
||||
echo "Checking for private key..."
|
||||
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
|
||||
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
|
||||
|
||||
# Create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
@@ -139,6 +153,7 @@ jobs:
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Add the keychain to the search list
|
||||
# shellcheck disable=SC2046
|
||||
security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed 's/^[[:space:]]*"//;s/"$//')
|
||||
|
||||
# List all certificates to debug
|
||||
@@ -181,11 +196,11 @@ jobs:
|
||||
security find-certificate -c "Learning Machines" build.keychain -p | openssl x509 -text -noout | grep -E "(Subject:|CN=)" || echo "Could not parse certificate"
|
||||
|
||||
# Set the correct signing identity
|
||||
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> $GITHUB_ENV
|
||||
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> "$GITHUB_ENV"
|
||||
echo "Proceeding with identity: Developer ID Application: Learning Machines LLC (DJ6XS33FX8)"
|
||||
|
||||
# Also set the team ID
|
||||
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> $GITHUB_ENV
|
||||
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pre-build frontend to avoid Tauri build issues
|
||||
run: |
|
||||
@@ -195,11 +210,11 @@ jobs:
|
||||
# Increase Node.js memory limit for the build
|
||||
export NODE_OPTIONS="--max-old-space-size=8192"
|
||||
|
||||
# Use npx to ensure we use the local nx version
|
||||
echo "Building with npx nx..."
|
||||
npx nx run artcraft:build
|
||||
echo "Building with nx..."
|
||||
./node_modules/.bin/nx run artcraft:build
|
||||
|
||||
echo "Checking build output..."
|
||||
# shellcheck disable=SC2012
|
||||
ls -la apps/artcraft/dist/ | head -10
|
||||
|
||||
# Update Tauri config to skip the build (since we already built)
|
||||
@@ -215,7 +230,7 @@ jobs:
|
||||
|
||||
|
||||
- name: build and publish
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
|
||||
@@ -10,6 +10,13 @@ on:
|
||||
# This workflow creates Windows builds.
|
||||
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
|
||||
|
||||
# One release build per branch at a time. A run in progress is never cancelled, because it may
|
||||
# already have created or uploaded to the draft release. If more pushes arrive while one runs,
|
||||
# GitHub keeps only the newest as pending, so the next build is of the latest commit.
|
||||
concurrency:
|
||||
group: windows-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-artcraft-windows:
|
||||
permissions:
|
||||
@@ -23,11 +30,19 @@ jobs:
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
# Successful builds took 17-36 min (2026-04 to 2026-11); one hung run used the full 6-hour default.
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: install wreq dependencies
|
||||
run: |
|
||||
@@ -39,11 +54,12 @@ jobs:
|
||||
shell: cmd
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: install frontend dependencies
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Navigate to the frontend directory to install dependencies
|
||||
cd ./frontend
|
||||
@@ -51,7 +67,7 @@ jobs:
|
||||
npm cache clean --force
|
||||
|
||||
echo "Installing all workspace dependencies..."
|
||||
npm ci
|
||||
npm ci --ignore-scripts
|
||||
|
||||
echo "=== Version Information ==="
|
||||
echo "Node version:"
|
||||
@@ -63,6 +79,7 @@ jobs:
|
||||
npm list --depth=0 | findstr nx
|
||||
|
||||
- name: Pre-build frontend to avoid Tauri build issues
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Build the frontend app from the correct directory
|
||||
cd ./frontend
|
||||
@@ -90,7 +107,7 @@ jobs:
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
|
||||
|
||||
- name: build and publish
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
|
||||
env:
|
||||
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
|
||||
RUSTFLAGS: "-C target-feature=+crt-static"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners).
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on pull requests.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "27 4 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: packaging-lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
Generated
+175
-68
@@ -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"
|
||||
@@ -8075,6 +8092,7 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"tokens",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8702,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"
|
||||
@@ -9086,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]]
|
||||
@@ -9596,7 +9624,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9660,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"
|
||||
@@ -9689,7 +9723,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9841,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"
|
||||
@@ -9930,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"
|
||||
@@ -10045,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"
|
||||
@@ -11380,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"
|
||||
@@ -11942,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"
|
||||
@@ -12009,7 +12117,6 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.0",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_artcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/artcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
<p align="center"><strong>The IDE for artists.</strong></p>
|
||||
|
||||
@@ -130,13 +131,15 @@ Additional provider integrations are planned for Kling, Google, Runway, and Luma
|
||||
|
||||
## Downloads
|
||||
|
||||
- [Stable releases for Windows and macOS](https://getartcraft.com/)
|
||||
- [Latest builds on GitHub](https://github.com/storytold/artcraft/releases)
|
||||
- [Stable releases for Windows, macOS, and Linux](https://getartcraft.com/)
|
||||
- [Latest builds on GitHub](https://github.com/storytold/artcraft/releases) (including AppImage, DEB, RPM, Arch, and Flatpak packages)
|
||||
- [Linux packaging notes](./packaging/linux/README.md)
|
||||
- [Build from source](./_docs/dev_setup.md), including Linux
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Development setup](./_docs/dev_setup.md)
|
||||
- [Linux packaging](./packaging/linux/README.md)
|
||||
- [Developer documentation](./_docs)
|
||||
- [Desktop build and development scripts](./script/artcraft)
|
||||
- [Roadmap](./ROADMAP.md)
|
||||
|
||||
@@ -33,6 +33,7 @@ chrono.workspace = true
|
||||
cookie.workspace = true
|
||||
log.workspace = true
|
||||
reqwest = { workspace = true, features = ["multipart", "gzip"] }
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
url.workspace = true
|
||||
|
||||
# Serialization
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let file_bytes = std::fs::read(args.path.as_ref())
|
||||
let file_bytes = tokio::fs::read(args.path.as_ref()).await
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
let file_name = args.path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let file_bytes = std::fs::read(args.path.as_ref())
|
||||
let file_bytes = tokio::fs::read(args.path.as_ref()).await
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
let file_name = args.path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()?;
|
||||
|
||||
let file_bytes = std::fs::read(path.as_ref())?;
|
||||
let file_bytes = tokio::fs::read(path.as_ref()).await?;
|
||||
let file_name = path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
let mut form = Form::new()
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
|
||||
let client = storyteller_client_builder(args.api_host)
|
||||
.build()?;
|
||||
|
||||
let file_bytes = std::fs::read(args.path.as_ref())?;
|
||||
let file_bytes = tokio::fs::read(args.path.as_ref()).await?;
|
||||
let file_name = args.path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
let mut form = Form::new()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use log::{info, warn};
|
||||
@@ -68,13 +66,7 @@ pub async fn download_media_file<P: AsRef<Path>>(
|
||||
let bytes = download_bytes(cdn_url).await?;
|
||||
|
||||
// 4. Write to disk.
|
||||
let mut file = fs::File::create(&output_path)
|
||||
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
|
||||
|
||||
file.write_all(&bytes)
|
||||
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
|
||||
|
||||
file.flush()
|
||||
tokio::fs::write(&output_path, &bytes).await
|
||||
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
|
||||
|
||||
info!("Downloaded {} bytes to {:?}", bytes.len(), output_path);
|
||||
|
||||
@@ -31,7 +31,7 @@ reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
wreq.workspace = true
|
||||
serde_derive.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ pub(crate) async fn upload_to_kinovi_web(
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
|
||||
let file_bytes = match maybe_local_path {
|
||||
Some(local_path) => match std::fs::read(local_path) {
|
||||
Some(local_path) => match tokio::fs::read(local_path).await {
|
||||
Ok(bytes) => {
|
||||
info!("Reusing predownloaded file for kinovi upload: {}", source_url);
|
||||
bytes
|
||||
|
||||
@@ -93,7 +93,7 @@ impl MidjourneyCredentialManager {
|
||||
let _guard = self.mutation.lock().await;
|
||||
// Remove every format, so a later startup cannot migrate a logged-out account.
|
||||
for path in self.legacy_paths.iter().chain([&self.credential_path]) {
|
||||
match std::fs::remove_file(path) {
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => {},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {},
|
||||
Err(err) => return Err(err.into()),
|
||||
|
||||
@@ -21,10 +21,6 @@ impl TaskId {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for TaskId {
|
||||
|
||||
+4
-8
@@ -11,7 +11,7 @@ use errors::AnyhowResult;
|
||||
use log::{debug, error, warn};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::{fs::File, io::Write, path::Path};
|
||||
use std::path::Path;
|
||||
use url::Url;
|
||||
use wreq::Client;
|
||||
|
||||
@@ -271,9 +271,7 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
|
||||
|
||||
pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> {
|
||||
let dir = Path::new(dir);
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
for generation in generations {
|
||||
let response = wreq::get(generation.url.clone()).send().await?;
|
||||
let image_bytes = response.bytes().await?;
|
||||
@@ -282,15 +280,13 @@ pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> A
|
||||
let ext = url.path().split(".").last().unwrap_or("png");
|
||||
let path = Path::new(dir).join(format!("{}.{}", generation.id, ext));
|
||||
|
||||
let mut file = File::create(path)?;
|
||||
file.write_all(&image_bytes)?;
|
||||
tokio::fs::write(path, &image_bytes).await?;
|
||||
|
||||
let thumbnail_url = generation.encodings.thumbnail.path.clone();
|
||||
let thumbnail_response = wreq::get(thumbnail_url).send().await?;
|
||||
let thumbnail_bytes = thumbnail_response.bytes().await?;
|
||||
let thumbnail_path = Path::new(dir).join(format!("{}_thumbnail.{}", generation.id, ext));
|
||||
let mut thumbnail_file = File::create(thumbnail_path)?;
|
||||
thumbnail_file.write_all(&thumbnail_bytes)?;
|
||||
tokio::fs::write(thumbnail_path, &thumbnail_bytes).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+2
-2
@@ -141,8 +141,8 @@ mod tests {
|
||||
async fn test_requests() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Debug);
|
||||
|
||||
let refresh_token = "AMf-vByUgA0J9S93vULECe-sD50cbR85AFVYF60gKAmLFF1MBYwXlKRJCsv7z3oSpLtP0ApyOU5fAl52qHWM2U0yKgEu5gQ5UCUDt5SEG4UBRHWvHEmLLcu-Zl04Fq4ljocSEO3qJzuX1wyshmptZxlDPRkjdLYgyn-Kp03woI2yRPKLuQF5hQtABHabsNUh9Zhs129sgxuUlOuw4zsFI4L8UdE3bEuF3k6Mic7KlE3440YwyQ8Qtmk9zHtXIx2ob56N-1WTGSqwMUuQ1mX4OOvLve9bw2zDW3UzchomTWkh732886RPrP-DNRkPbO3FejUEMxuYQFWYX3EEeIEdfNGXFNGPL1dNniSXlSaKadpX699Ishjviv-x9o-pPQa3zoAj3NzWLyd8mn5rDBh9qAO_iDETaeo01Sjhb4mGYfAWZ-g4S6nW778";
|
||||
let refresh_token = WorldLabsRefreshToken::new(refresh_token.to_string());
|
||||
let refresh_token = std::env::var("WORLDLABS_REFRESH_TOKEN")?;
|
||||
let refresh_token = WorldLabsRefreshToken::new(refresh_token);
|
||||
|
||||
let result = google_refresh_token(GoogleRefreshTokenArgs {
|
||||
refresh_token: &refresh_token,
|
||||
|
||||
@@ -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;
|
||||
+1
-4
@@ -31,8 +31,6 @@ use sqlite_tasks::queries::task::Task;
|
||||
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{
|
||||
update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs,
|
||||
};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
@@ -255,8 +253,7 @@ async fn download_file(
|
||||
let filename = format!("fal_{}_{}.{}", generate_random_uuid(), index, extension);
|
||||
let download_path = tempdir.join(filename);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&bytes)?;
|
||||
tokio::fs::write(&download_path, bytes).await?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ async fn download_generated_results(
|
||||
}
|
||||
ensure!(!urls.is_empty(), "Completed task has no downloadable results");
|
||||
let path = checkpoint_path(root, task);
|
||||
let mut checkpoint: AutoDownloadCheckpoint = match std::fs::read(&path) {
|
||||
let mut checkpoint: AutoDownloadCheckpoint = match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)?,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
AutoDownloadCheckpoint { download_time: Local::now(), saved: BTreeMap::new() }
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use errors::AnyhowResult;
|
||||
use reqwest::Client;
|
||||
use reqwest::Url;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
const USER_AGENT: &str = "storyteller-client/1.0";
|
||||
@@ -22,13 +20,7 @@ pub async fn simple_http_download<P: AsRef<Path>>(url: &Url, download_path: P) -
|
||||
//let response : Response = reqwest::get(url).await?;
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true) // To create a new file
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(download_path)?;
|
||||
|
||||
file.write_all(&bytes)?;
|
||||
tokio::fs::write(download_path, bytes).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ pub(super) async fn upload_midjourney_batch(maybe_app_handle: Option<&AppHandle>
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = std::fs::remove_file(MidjourneyUploadCheckpoint::path(app_data_root, local_task)) {
|
||||
if let Err(err) = tokio::fs::remove_file(MidjourneyUploadCheckpoint::path(app_data_root, local_task)).await {
|
||||
log::warn!("Could not remove completed Midjourney upload checkpoint: {}", err);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -2,8 +2,6 @@ use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use errors::AnyhowResult;
|
||||
use midjourney_client::utils::get_image_url::get_image_url;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
use midjourney_client::utils::image_downloader_client::ImageDownloaderClient;
|
||||
@@ -28,8 +26,7 @@ pub async fn download_midjourney_image(
|
||||
let download_filename = format!("{}_{}.{}", job_id, index, ext);
|
||||
let download_path = tempdir.join(download_filename);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&image_bytes)?;
|
||||
tokio::fs::write(&download_path, &image_bytes).await?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ pub async fn initialize_sora_jwt_bearer_token(app_data_root: &AppDataRoot) -> An
|
||||
return Ok(()); // Bearer token already exists.
|
||||
}
|
||||
|
||||
let cookie = std::fs::read_to_string(app_data_root.get_sora_cookie_file_path())?
|
||||
let cookie = tokio::fs::read_to_string(app_data_root.get_sora_cookie_file_path()).await?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
@@ -22,7 +22,7 @@ pub async fn initialize_sora_jwt_bearer_token(app_data_root: &AppDataRoot) -> An
|
||||
|
||||
let bearer = generate_bearer_jwt_with_cookie(&cookie).await?;
|
||||
|
||||
std::fs::write(app_data_root.get_sora_bearer_token_file_path(), bearer)?;
|
||||
tokio::fs::write(app_data_root.get_sora_bearer_token_file_path(), bearer).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+8
-30
@@ -8,8 +8,7 @@ use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
|
||||
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
|
||||
use openai_sora_client::creds::sora_sentinel_token::SoraSentinelToken;
|
||||
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use std::fs::{read_to_string, OpenOptions};
|
||||
use std::io::Write;
|
||||
use tokio::fs::{read_to_string, write};
|
||||
|
||||
pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
|
||||
let cookie_file = app_data_root.get_sora_cookie_file_path();
|
||||
@@ -21,7 +20,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
|
||||
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
|
||||
}
|
||||
|
||||
let value = read_to_string(cookie_file)?
|
||||
let value = read_to_string(cookie_file).await?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
@@ -32,14 +31,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
|
||||
let mut sentinel_token = None;
|
||||
|
||||
if bearer_file.exists() {
|
||||
let value = read_to_string(&bearer_file)?
|
||||
let value = read_to_string(&bearer_file).await?
|
||||
.trim()
|
||||
.to_string();
|
||||
bearer = Some(SoraJwtBearerToken::new(value)?);
|
||||
}
|
||||
|
||||
if legacy_sentinel_file.exists() {
|
||||
let value = read_to_string(&legacy_sentinel_file)?
|
||||
let value = read_to_string(&legacy_sentinel_file).await?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
@@ -47,7 +46,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
|
||||
}
|
||||
|
||||
if sentinel_token_file.exists() {
|
||||
let value = read_to_string(&sentinel_token_file)?
|
||||
let value = read_to_string(&sentinel_token_file).await?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
@@ -73,14 +72,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
|
||||
info!("Persisting bearer token value...");
|
||||
let value = bearer.token_str();
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&bearer_file)?;
|
||||
|
||||
file.write_all(value.as_bytes())?;
|
||||
file.flush()?;
|
||||
write(&bearer_file, value.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,14 +81,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
|
||||
info!("Persisting sentinel value...");
|
||||
let value = sentinel.get_sentinel();
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&legacy_sentinel_file)?;
|
||||
|
||||
file.write_all(value.as_bytes())?;
|
||||
file.flush()?;
|
||||
write(&legacy_sentinel_file, value.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,14 +90,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
|
||||
info!("Persisting sentinel token...");
|
||||
let value = sentinel_token.to_persistent_storage_json()?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&sentinel_token_file)?;
|
||||
|
||||
file.write_all(value.as_bytes())?;
|
||||
file.flush()?;
|
||||
write(&sentinel_token_file, value.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,14 +162,7 @@ impl SoraCredentialManager {
|
||||
|
||||
self.holder.set_credentials(&creds)?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(self.app_data_root.get_sora_legacy_sentinel_file_path())?;
|
||||
|
||||
file.write_all(sentinel.as_bytes())?;
|
||||
file.flush()?;
|
||||
tokio::fs::write(self.app_data_root.get_sora_legacy_sentinel_file_path(), sentinel.as_bytes()).await?;
|
||||
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
+1
-4
@@ -33,8 +33,6 @@ use sqlite_tasks::queries::task::Task;
|
||||
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
@@ -213,8 +211,7 @@ async fn download_generation_item(
|
||||
|
||||
info!("Writing to path: {:?}", download_path);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&image_bytes)?;
|
||||
tokio::fs::write(&download_path, image_bytes).await?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
|
||||
+1
-4
@@ -39,8 +39,6 @@ use sqlite_tasks::queries::task::Task;
|
||||
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
@@ -378,8 +376,7 @@ async fn download_spz(
|
||||
|
||||
info!("Writing to path: {:?}", download_path);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&image_bytes)?;
|
||||
tokio::fs::write(&download_path, image_bytes).await?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
|
||||
@@ -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,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,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,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,3 @@
|
||||
|
||||
/// When downloading a remote file, it makes sense to try to match the same file extension (with reasonable safeguards).
|
||||
#[deprecated(note = "Use `Extension` in `url_utils::extension::extension` instead")]
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||
|
||||
@@ -12,9 +12,7 @@ pub fn extract_download_extension_from_url_str(url: &str) -> Option<DownloadExte
|
||||
#[deprecated(note = "Use `extract_extension_from_url` in `url_utils::extension::extract_extension_from_url` instead")]
|
||||
pub fn extract_download_extension_from_url(url: &Url) -> Option<DownloadExtension> {
|
||||
let path = url.path();
|
||||
let extension = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())?;
|
||||
let extension = std::path::Path::new(path).extension().and_then(|ext| ext.to_str())?;
|
||||
DownloadExtension::try_from_str(extension)
|
||||
}
|
||||
|
||||
@@ -26,14 +24,14 @@ mod tests {
|
||||
#[test]
|
||||
fn success_case() {
|
||||
let url = "https://storyteller.ai/example.jpg";
|
||||
let maybe_extension= extract_download_extension_from_url_str(url);
|
||||
let maybe_extension = extract_download_extension_from_url_str(url);
|
||||
assert_eq!(maybe_extension, Some(DownloadExtension::Jpg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn difficult_url() {
|
||||
let url = "https://videos.youtube.com/foo/bar/files/00000000-f19a-1275-a092-caa60b38f8d2%2Fraw?se=2025-11-21T22%3A26%3A53Z&sp=r&sv=2024-08-09&sr=b&skoid=8cfff87a-01fc-27c1-9091-32999dcd1389&sktid=aa8ccaf6-e6da-484e-ab11-9c7496c21c53&skt=2025-11-17T21%3A09%3A08Z&ske=2025-10-23T21%3A14%3A08Z&sks=b&skv=2024-08-04&sig=T2U6%2Brw9WJdJagBkCL4/GlFr72KhZGP3QPlobIO7JlU%3D&ac=oaisdsorprnorthcentralus";
|
||||
let maybe_extension= extract_download_extension_from_url_str(url);
|
||||
let maybe_extension = extract_download_extension_from_url_str(url);
|
||||
assert!(maybe_extension.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#[expect(clippy::module_inception, reason = "Preserve the existing public module path")]
|
||||
mod download_extension;
|
||||
pub mod extract_download_extension_from_url;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// A file extension that can be either a compile-time constant or a runtime-parsed value.
|
||||
///
|
||||
@@ -16,7 +17,7 @@ use std::fmt;
|
||||
/// assert_eq!(ext.without_period(), "mp4");
|
||||
/// assert_eq!(ext.with_period(), ".mp4");
|
||||
/// ```
|
||||
#[derive(Clone, Eq, Hash)]
|
||||
#[derive(Clone, Eq)]
|
||||
pub struct Extension {
|
||||
/// The extension without the leading period, e.g. "png", "mp4".
|
||||
value: Cow<'static, str>,
|
||||
@@ -34,20 +35,14 @@ impl Extension {
|
||||
/// const MP4: Extension = Extension::from_static("mp4", ".mp4");
|
||||
/// ```
|
||||
pub const fn from_static(ext: &'static str, dotted: &'static str) -> Self {
|
||||
Self {
|
||||
value: Cow::Borrowed(ext),
|
||||
dotted: Cow::Borrowed(dotted),
|
||||
}
|
||||
Self { value: Cow::Borrowed(ext), dotted: Cow::Borrowed(dotted) }
|
||||
}
|
||||
|
||||
/// Creates an `Extension` from a `&str` at runtime.
|
||||
/// The input must NOT include a leading period.
|
||||
pub fn new(ext: &str) -> Self {
|
||||
let dotted = format!(".{}", ext);
|
||||
Self {
|
||||
value: Cow::Owned(ext.to_string()),
|
||||
dotted: Cow::Owned(dotted),
|
||||
}
|
||||
let dotted = format!(".{ext}");
|
||||
Self { value: Cow::Owned(ext.to_string()), dotted: Cow::Owned(dotted) }
|
||||
}
|
||||
|
||||
/// Returns the extension without the leading period, e.g. `"png"`.
|
||||
@@ -67,6 +62,12 @@ impl PartialEq for Extension {
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Extension {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.value.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Extension {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Extension(\"{}\")", self.value)
|
||||
@@ -135,12 +136,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn display() {
|
||||
assert_eq!(format!("{}", PNG), "png");
|
||||
assert_eq!(format!("{PNG}"), "png");
|
||||
assert_eq!(format!("{}", Extension::new("xyz")), "xyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug() {
|
||||
assert_eq!(format!("{:?}", PNG), "Extension(\"png\")");
|
||||
assert_eq!(format!("{PNG:?}"), "Extension(\"png\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_matches_equality() {
|
||||
let plain = Extension::from_static("png", ".png");
|
||||
let inconsistent = Extension::from_static("png", ".not-a-suffix");
|
||||
assert_eq!(plain, inconsistent);
|
||||
assert_eq!(hash(&plain), hash(&inconsistent));
|
||||
}
|
||||
|
||||
fn hash(extension: &Extension) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
extension.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,13 @@ use url::Url;
|
||||
// TODO: Lookups against these as lists will be slow
|
||||
|
||||
/// Known image extensions.
|
||||
const KNOWN_IMAGE_EXTENSIONS: &[Extension] = &[
|
||||
Extension::from_static("png", ".png"),
|
||||
Extension::from_static("jpg", ".jpg"),
|
||||
Extension::from_static("jpeg", ".jpeg"),
|
||||
Extension::from_static("webp", ".webp"),
|
||||
];
|
||||
const KNOWN_IMAGE_EXTENSIONS: &[Extension] = &[Extension::from_static("png", ".png"), Extension::from_static("jpg", ".jpg"), Extension::from_static("jpeg", ".jpeg"), Extension::from_static("webp", ".webp")];
|
||||
|
||||
/// Known audio extensions.
|
||||
const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[
|
||||
Extension::from_static("wav", ".wav"),
|
||||
Extension::from_static("mp3", ".mp3"),
|
||||
];
|
||||
const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[Extension::from_static("wav", ".wav"), Extension::from_static("mp3", ".mp3")];
|
||||
|
||||
/// Known video extensions.
|
||||
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[
|
||||
Extension::from_static("mp4", ".mp4"),
|
||||
Extension::from_static("webm", ".webm"),
|
||||
];
|
||||
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[Extension::from_static("mp4", ".mp4"), Extension::from_static("webm", ".webm")];
|
||||
|
||||
/// Controls which extensions are accepted when extracting from a URL.
|
||||
pub enum ExtractExtensions {
|
||||
@@ -62,9 +51,7 @@ pub fn extract_extension_from_url_str(url: &str, accept: &ExtractExtensions) ->
|
||||
/// Extracts a file extension from a parsed `Url`, filtered by `accept`.
|
||||
pub fn extract_extension_from_url(url: &Url, accept: &ExtractExtensions) -> Option<Extension> {
|
||||
let path = url.path();
|
||||
let raw_ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())?;
|
||||
let raw_ext = std::path::Path::new(path).extension().and_then(|ext| ext.to_str())?;
|
||||
|
||||
let lower = raw_ext.to_lowercase();
|
||||
let candidate = Extension::new(&lower);
|
||||
@@ -72,27 +59,40 @@ pub fn extract_extension_from_url(url: &Url, accept: &ExtractExtensions) -> Opti
|
||||
match accept {
|
||||
ExtractExtensions::All => Some(candidate),
|
||||
ExtractExtensions::Set(set) => {
|
||||
if set.contains(&candidate) { Some(candidate) } else { None }
|
||||
}
|
||||
ExtractExtensions::KnownImage => {
|
||||
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) { Some(candidate) } else { None }
|
||||
}
|
||||
ExtractExtensions::KnownAudio => {
|
||||
if slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) { Some(candidate) } else { None }
|
||||
}
|
||||
ExtractExtensions::KnownVideo => {
|
||||
if slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) { Some(candidate) } else { None }
|
||||
}
|
||||
ExtractExtensions::KnownMedia => {
|
||||
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate)
|
||||
|| slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate)
|
||||
|| slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate)
|
||||
{
|
||||
if set.contains(&candidate) {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
ExtractExtensions::KnownImage => {
|
||||
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
ExtractExtensions::KnownAudio => {
|
||||
if slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
ExtractExtensions::KnownVideo => {
|
||||
if slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
ExtractExtensions::KnownMedia => {
|
||||
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) || slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) || slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,19 +190,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_matches_custom_list() {
|
||||
let accept = ExtractExtensions::from_vec(vec![
|
||||
Extension::from_static("csv", ".csv"),
|
||||
Extension::from_static("tsv", ".tsv"),
|
||||
]);
|
||||
let accept = ExtractExtensions::from_vec(vec![Extension::from_static("csv", ".csv"), Extension::from_static("tsv", ".tsv")]);
|
||||
let ext = extract_extension_from_url_str("https://example.com/data.csv", &accept);
|
||||
assert_eq!(ext.unwrap().without_period(), "csv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rejects_non_member() {
|
||||
let accept = ExtractExtensions::from_vec(vec![
|
||||
Extension::from_static("csv", ".csv"),
|
||||
]);
|
||||
let accept = ExtractExtensions::from_vec(vec![Extension::from_static("csv", ".csv")]);
|
||||
let ext = extract_extension_from_url_str("https://example.com/data.png", &accept);
|
||||
assert!(ext.is_none());
|
||||
}
|
||||
@@ -215,10 +210,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn query_params_ignored() {
|
||||
let ext = extract_extension_from_url_str(
|
||||
"https://example.com/file.mp4?token=abc&format=hd",
|
||||
&ExtractExtensions::KnownVideo,
|
||||
);
|
||||
let ext = extract_extension_from_url_str("https://example.com/file.mp4?token=abc&format=hd", &ExtractExtensions::KnownVideo);
|
||||
assert_eq!(ext.unwrap().without_period(), "mp4");
|
||||
}
|
||||
|
||||
@@ -230,8 +222,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn extract_extension_all(url: &str) -> Option<String> {
|
||||
extract_extension_from_url_str(url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
extract_extension_from_url_str(url, &ExtractExtensions::All).map(|ext| ext.without_period().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
#[expect(clippy::module_inception, reason = "Preserve the existing public module path")]
|
||||
pub mod extension;
|
||||
pub mod extract_extension_from_url;
|
||||
pub mod extract_extension_from_url;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod extension;
|
||||
pub mod download_extension;
|
||||
#[expect(deprecated, reason = "Keep the public compatibility module while downstream users migrate to Extension")]
|
||||
pub mod download_extension;
|
||||
|
||||
@@ -19,6 +19,7 @@ tokens.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
|
||||
# TODO(bt,2025-07-09): Update sqlx ASAP. We're stuck on 0.7 because of the complex use of internal types and macros in the database crate.
|
||||
# We're going to embed in-memory SQLite
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn main() {
|
||||
println!("cargo:rerun-if-env-changed=DATABASE_URL");
|
||||
println!("cargo:rerun-if-env-changed=SQLX_OFFLINE");
|
||||
|
||||
if let Ok(url) = env::var("DATABASE_URL") {
|
||||
println!("cargo:rustc-env=DATABASE_URL={}", url);
|
||||
return;
|
||||
}
|
||||
|
||||
if env::var("SQLX_OFFLINE").as_deref() == Ok("true") || env::var("SQLX_OFFLINE").as_deref() == Ok("1") {
|
||||
println!("cargo:rustc-env=SQLX_OFFLINE=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// NB: This should help JetBrains' RustRover from highlighting failing query macros,
|
||||
// but we do not want to interfere with the following:
|
||||
//
|
||||
@@ -14,27 +27,29 @@ pub fn main() {
|
||||
Some("unix") => unix_temp_database_pathing(),
|
||||
Some("windows") => windows_temp_database_pathing(),
|
||||
_ => {
|
||||
println!("cargo:warning=Unsupported target family, using Unix temp database pathing.");
|
||||
unix_temp_database_pathing();
|
||||
println!("cargo:rustc-env=SQLX_OFFLINE=true");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_temp_database_pathing() {
|
||||
println!("cargo:rustc-env=DATABASE_URL=sqlite:/tmp/tasks.sqlite");
|
||||
println!("cargo:rerun-if-changed=/tmp/tasks.sqlite");
|
||||
if Path::new("/tmp/tasks.sqlite").exists() {
|
||||
println!("cargo:rustc-env=DATABASE_URL=sqlite:/tmp/tasks.sqlite");
|
||||
} else {
|
||||
println!("cargo:rustc-env=SQLX_OFFLINE=true");
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_temp_database_pathing() {
|
||||
if let Ok(local_app_data) = env::var("LOCALAPPDATA") {
|
||||
let path = PathBuf::from(local_app_data);
|
||||
let path = path.join("Temp\\tasks.sqlite");
|
||||
let path = path
|
||||
.to_str()
|
||||
.expect("path should be valid")
|
||||
.to_string();
|
||||
println!("cargo:warning=LocalAppData path: {}", path);
|
||||
println!("cargo:rustc-env=DATABASE_URL=sqlite:{}", path);
|
||||
} else {
|
||||
panic!("LOCALAPPDATA environment variable not set");
|
||||
let path = PathBuf::from(local_app_data).join("Temp\\tasks.sqlite");
|
||||
if path.exists() {
|
||||
let path_str = path.to_str().expect("path should be valid");
|
||||
println!("cargo:warning=LocalAppData path: {}", path_str);
|
||||
println!("cargo:rustc-env=DATABASE_URL=sqlite:{}", path_str);
|
||||
return;
|
||||
}
|
||||
}
|
||||
println!("cargo:rustc-env=SQLX_OFFLINE=true");
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ impl TaskDbConnection {
|
||||
|
||||
info!("Deleting and recreating SQLite database at {:?}", database_file.as_ref());
|
||||
|
||||
if let Err(err) = std::fs::remove_file(&database_file) {
|
||||
if let Err(err) = tokio::fs::remove_file(&database_file).await {
|
||||
error!("Error deleting SQLite database file: {:?}", err);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="SwaggerUI" />
|
||||
<title>Storyteller API</title>
|
||||
<link rel="stylesheet" href="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" integrity="sha384-3nuX7df3EaAoiqLBeyS1Ola0Gpg9ryJKVtarubwfnA1cOH8AWHUdbPSIvEqPZ9VH" crossorigin="anonymous" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" crossorigin></script>
|
||||
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" integrity="sha384-7xcoc6ZKDFF7Ek627QTC3Bg/K+5Y36NJ8MWAE43D2m6+3Sh9XO3tdsfHhrS8gNIQ" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" integrity="sha384-FjFI+0PRyd5aAEoduVmUYk65iDC5+onNzam4WJtbAKKBim/kWvGUMk/2+9qRaYZb" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ export function GlobalFileDropHandler() {
|
||||
}
|
||||
};
|
||||
|
||||
setup();
|
||||
void setup();
|
||||
return () => { unlisteners.forEach((fn) => fn()); };
|
||||
|
||||
} else {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
type Mode = "image" | "text";
|
||||
type Variant = "object" | "world";
|
||||
@@ -69,10 +70,7 @@ const formatTime = (timestamp: number) => {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const generateId = () =>
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2, 10);
|
||||
const generateId = () => uuidv4();
|
||||
|
||||
const WORLD_MODEL_PAGE = ModelPage.ImageTo3DWorld;
|
||||
const OBJECT_MODEL_PAGE = ModelPage.ImageTo3DObject;
|
||||
@@ -256,7 +254,7 @@ export const ImageTo3DExperience = ({
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFiles(event.target.files);
|
||||
void handleFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -264,7 +262,7 @@ export const ImageTo3DExperience = ({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragActive(false);
|
||||
handleFiles(event.dataTransfer?.files);
|
||||
void handleFiles(event.dataTransfer?.files);
|
||||
};
|
||||
|
||||
const handlePickFromLibrary = () => {
|
||||
@@ -599,6 +597,9 @@ export const ImageTo3DExperience = ({
|
||||
<div className="flex justify-center">
|
||||
{uploadedPreview ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
className={twMerge(
|
||||
"group relative cursor-pointer overflow-hidden border border-white/30 bg-black/30 transition-all hover:border-white/60",
|
||||
hasResults ? "aspect-square w-24" : "aspect-square w-48",
|
||||
@@ -716,7 +717,7 @@ export const ImageTo3DExperience = ({
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer?.files)
|
||||
handleWorldFiles(
|
||||
void handleWorldFiles(
|
||||
Array.from(e.dataTransfer.files).filter((f) =>
|
||||
f.type.startsWith("image/"),
|
||||
),
|
||||
@@ -754,6 +755,9 @@ export const ImageTo3DExperience = ({
|
||||
|
||||
const worldImageThumbnail = (img: WorldImage) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
key={img.id}
|
||||
className={twMerge(
|
||||
"group relative aspect-square overflow-hidden rounded-[3px] border border-white/30 bg-black/30 transition-all",
|
||||
@@ -944,7 +948,7 @@ export const ImageTo3DExperience = ({
|
||||
<div
|
||||
className={twMerge(
|
||||
"mx-auto grid h-full w-full grid-cols-[1fr_300px] gap-4 overflow-hidden pb-10",
|
||||
variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]",
|
||||
"max-w-[1600px]",
|
||||
)}
|
||||
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
|
||||
>
|
||||
@@ -1111,7 +1115,7 @@ export const ImageTo3DExperience = ({
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-between gap-2.5",
|
||||
hasResults ? "mt-3" : "mt-3",
|
||||
"mt-3",
|
||||
)}
|
||||
>
|
||||
<ClassyModelSelector
|
||||
@@ -1177,7 +1181,7 @@ export const ImageTo3DExperience = ({
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
handleWorldFiles(Array.from(e.target.files));
|
||||
void handleWorldFiles(Array.from(e.target.files));
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
@@ -1202,6 +1206,7 @@ export const ImageTo3DExperience = ({
|
||||
|
||||
{previewImage && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ function ItemActions({
|
||||
e.stopPropagation();
|
||||
const url = item.fullImage || item.thumbnail;
|
||||
if (!url) return;
|
||||
applyMakeVideoFromImage(url, item.id);
|
||||
void applyMakeVideoFromImage(url, item.id);
|
||||
},
|
||||
[item.fullImage, item.thumbnail, item.id],
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type GalleryItem,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useTabStore } from "~/pages/Stores/TabState";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
|
||||
// the create pages' generation feed (rows + cards). All of them operate on
|
||||
@@ -130,7 +131,7 @@ export async function downloadMediaFilesToFolder(
|
||||
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
|
||||
try {
|
||||
const referenceImage: RefImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
id: uuidv4(),
|
||||
url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: mediaToken || "",
|
||||
@@ -178,7 +179,7 @@ export function applyRecreateFromPromptData(data: {
|
||||
|
||||
for (const ci of contextImages) {
|
||||
const base = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
id: uuidv4(),
|
||||
url: ci.media_links.cdn_url,
|
||||
mediaToken: ci.media_token,
|
||||
};
|
||||
|
||||
+2
-2
@@ -295,8 +295,8 @@ export function useDesktopGenerationFeed(options: {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
window.removeEventListener("task-queue-update", handleTaskUpdate);
|
||||
unlistenComplete.then((f) => f());
|
||||
unlistenFailed.then((f) => f());
|
||||
void unlistenComplete.then((f) => f());
|
||||
void unlistenFailed.then((f) => f());
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export function useDesktopUsername(): string | null {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const session = await new UsersApi().GetSession();
|
||||
if (!cancelled && session.success && session.data?.user) {
|
||||
|
||||
@@ -66,9 +66,9 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
|
||||
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
|
||||
Currency
|
||||
</label>
|
||||
</span>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
value={currency}
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ export const UploadFilesMedia = ({
|
||||
return;
|
||||
}
|
||||
|
||||
uploadPlane({
|
||||
void uploadPlane({
|
||||
title: uploadTitle.value,
|
||||
assetFile: assetFile.value,
|
||||
progressCallback: onUploadProgress,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { PauseIcon, PlayIcon, SkipBackIcon, SkipForwardIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
|
||||
interface VideoTransportControlsProps {
|
||||
isPlaying: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (to: "first" | "last") => void;
|
||||
onTogglePlay: () => void;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
}
|
||||
|
||||
/** The row under a video page's player: first / play / last frame, mute and volume, and the time. */
|
||||
export const VideoTransportControls = ({
|
||||
isPlaying,
|
||||
isMuted,
|
||||
volume,
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
onTogglePlay,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
formatTime,
|
||||
}: VideoTransportControlsProps) => (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSeek("first")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="First Frame"
|
||||
>
|
||||
<SkipBackIcon className="text-sm text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
|
||||
>
|
||||
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} className="text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSeek("last")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="Last Frame"
|
||||
>
|
||||
<SkipForwardIcon className="text-sm text-white" />
|
||||
</button>
|
||||
<div className="mx-1 h-6 w-px bg-white/20" />
|
||||
<button
|
||||
onClick={onToggleMute}
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
>
|
||||
<DynamicIcon icon={isMuted ? VolumeXIcon : Volume2Icon} className="text-sm text-white" />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(Number.parseFloat(e.target.value))}
|
||||
className="w-20 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-sm tabular-nums text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -26,7 +26,7 @@ export default function ProfileDropdown() {
|
||||
label: "Logout",
|
||||
icon: LogOutIcon,
|
||||
onClick: () => {
|
||||
logout();
|
||||
void logout();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -67,7 +67,7 @@ export const SceneTitleInput = ({ pageName }: Props) => {
|
||||
e.currentTarget.focus();
|
||||
} else if (scene.value.token) {
|
||||
setIsSaving(true);
|
||||
renameScene(
|
||||
void renameScene(
|
||||
scene.value.title!, //guarunteed by input
|
||||
scene.value.token,
|
||||
);
|
||||
|
||||
@@ -164,7 +164,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
|
||||
aria-label="Copy prompt"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(prompt);
|
||||
void navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
}}
|
||||
@@ -513,7 +513,7 @@ export const TaskQueue = () => {
|
||||
const [failed, setFailed] = useState<FailedTask[]>([]);
|
||||
const [lastReadAt, setLastReadAt] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("taskQueueLastReadAt");
|
||||
return stored ? parseInt(stored, 10) : 0;
|
||||
return stored ? Number.parseInt(stored, 10) : 0;
|
||||
});
|
||||
|
||||
// remove unread state; unread tracking handled via IDs below
|
||||
@@ -869,7 +869,7 @@ export const TaskQueue = () => {
|
||||
};
|
||||
|
||||
cleanupOldEntries();
|
||||
load();
|
||||
void load();
|
||||
const id = setInterval(load, 5000);
|
||||
|
||||
// Listen for cover image uploads to refresh and show new thumbnails
|
||||
@@ -883,17 +883,17 @@ export const TaskQueue = () => {
|
||||
|
||||
let unlistenComplete: Promise<UnlistenFn> | null = null;
|
||||
let unlistenFailed: Promise<UnlistenFn> | null = null;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
// Update immediately when Tauri signals a generation completion
|
||||
unlistenComplete = listen("generation-complete-event", () => {
|
||||
if (!cancelled) {
|
||||
load();
|
||||
void load();
|
||||
}
|
||||
});
|
||||
// Also update immediately when a generation fails
|
||||
unlistenFailed = listen("generation-failed-event", () => {
|
||||
if (!cancelled) {
|
||||
load();
|
||||
void load();
|
||||
// Refunded credits may take a moment to settle in the database
|
||||
setTimeout(() => {
|
||||
useCreditsState.getState().fetchFromServer();
|
||||
@@ -906,10 +906,10 @@ export const TaskQueue = () => {
|
||||
clearInterval(id);
|
||||
window.removeEventListener("cover-image-uploaded", handleCoverUploaded);
|
||||
if (unlistenComplete) {
|
||||
unlistenComplete.then((f) => f());
|
||||
void unlistenComplete.then((f) => f());
|
||||
}
|
||||
if (unlistenFailed) {
|
||||
unlistenFailed.then((f) => f());
|
||||
void unlistenFailed.then((f) => f());
|
||||
}
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -125,7 +125,7 @@ export function storeEnqueueMeta(meta: EnqueueMeta) {
|
||||
|
||||
// Then convert blob URLs to base64 in the background for persistence
|
||||
if (meta.refImageUrls && meta.refImageUrls.length > 0) {
|
||||
Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
|
||||
void Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
|
||||
const current = readEntries();
|
||||
const found = current.find((e) => e.id === entryId);
|
||||
if (found) {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const withProtectionRoute = <P extends object>(
|
||||
function ProtectionRoute(rest: P) {
|
||||
useSignals();
|
||||
useSignalEffect(() => {
|
||||
persistLogin();
|
||||
void persistLogin();
|
||||
});
|
||||
|
||||
// const { status, userInfo } = authentication;
|
||||
|
||||
@@ -38,7 +38,7 @@ export const useActiveJobs = () => {
|
||||
// then set new one with new interval
|
||||
clearInterval(curr.timer);
|
||||
const newTimer = setInterval(() => {
|
||||
PollRecentJobs();
|
||||
void PollRecentJobs();
|
||||
}, newTimeInterval);
|
||||
return {
|
||||
timer: newTimer,
|
||||
@@ -72,7 +72,7 @@ export const useActiveJobs = () => {
|
||||
useSignalEffect(() => {
|
||||
// if active jobs is not initiated, do do a first pull;
|
||||
if (!activeJobs.value) {
|
||||
PollRecentJobs();
|
||||
void PollRecentJobs();
|
||||
return;
|
||||
}
|
||||
// if there are no active jobs, stop making polls
|
||||
@@ -115,7 +115,7 @@ export const useActiveJobs = () => {
|
||||
|
||||
//else make and set timer
|
||||
const newTimer = setInterval(() => {
|
||||
PollRecentJobs();
|
||||
void PollRecentJobs();
|
||||
}, curr.interval);
|
||||
return {
|
||||
...curr,
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@ export const useBackgroundLoadingMedia = () => {
|
||||
//CASE 1: first load
|
||||
// if myMovies undefined, poll for the first time
|
||||
if (!userMovies.value) {
|
||||
PollUserGeneratedMovies();
|
||||
void PollUserGeneratedMovies();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const useBackgroundLoadingMedia = () => {
|
||||
|
||||
//there are videos newly completed, set and poll
|
||||
lastCompletedWorkflow.current = completedWorkflowJobs.value;
|
||||
PollUserGeneratedMovies().then((ret: boolean) => {
|
||||
void PollUserGeneratedMovies().then((ret: boolean) => {
|
||||
if (ret) {
|
||||
addToast(
|
||||
ToastTypes.SUCCESS,
|
||||
@@ -60,7 +60,7 @@ export const useBackgroundLoadingMedia = () => {
|
||||
//CASE 1: first load
|
||||
// if audioItems undefined, poll for the first time
|
||||
if (!userAudioItems.value) {
|
||||
PollUserAudioItems();
|
||||
void PollUserAudioItems();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const useBackgroundLoadingMedia = () => {
|
||||
|
||||
//there are audio jobs newly completed, set and poll
|
||||
lastCompletedAudioJobs.current = completedAudioJobs.value;
|
||||
PollUserAudioItems().then((ret: boolean) => {
|
||||
void PollUserAudioItems().then((ret: boolean) => {
|
||||
if (ret) {
|
||||
addToast(
|
||||
ToastTypes.SUCCESS,
|
||||
|
||||
@@ -26,12 +26,12 @@ const ENV = {
|
||||
const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
|
||||
console.log("GlobalSettingsManager()");
|
||||
|
||||
SyncStorytellerApiConfig();
|
||||
void SyncStorytellerApiConfig();
|
||||
|
||||
useSignals();
|
||||
|
||||
useSignalEffect(() => {
|
||||
persistLogin();
|
||||
void persistLogin();
|
||||
});
|
||||
|
||||
/// Initizations that depends on ENV vars ///
|
||||
|
||||
@@ -152,7 +152,7 @@ export const MainApp = ({ sceneToken }: Props) => {
|
||||
// Session probe (runs once per shell mount).
|
||||
useEffect(() => {
|
||||
const usersApi = new UsersApi();
|
||||
usersApi.GetSession().then((result) => {
|
||||
void usersApi.GetSession().then((result) => {
|
||||
console.log(
|
||||
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
|
||||
import { ModelPage } from "@storyteller/ui-model-selector";
|
||||
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
|
||||
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const MAX_GALLERY_SELECTIONS = 1;
|
||||
|
||||
@@ -64,7 +65,7 @@ export const BaseImageSelector = ({
|
||||
setIsBlankCanvasModalOpen(false);
|
||||
const blankImage: BaseSelectorImage = {
|
||||
url: "",
|
||||
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
mediaToken: `blank_canvas_${width}x${height}_${uuidv4()}`,
|
||||
isBlankCanvas: true,
|
||||
blankCanvasWidth: width,
|
||||
blankCanvasHeight: height,
|
||||
@@ -79,12 +80,12 @@ export const BaseImageSelector = ({
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
uploadImage({
|
||||
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
|
||||
title: `reference-image-${uuidv4()}`,
|
||||
assetFile: file,
|
||||
progressCallback: (newState) => {
|
||||
if (newState.status === UploaderStates.success && newState.data) {
|
||||
const mediaToken = newState.data || "";
|
||||
(async () => {
|
||||
void (async () => {
|
||||
let finalUrl = reader.result as string;
|
||||
let thumbnailUrlTemplate = undefined;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { GeneratedImage } from "@storyteller/tauri-events";
|
||||
import type { ModelCreator } from "@storyteller/model-list";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export type TextToImageBatch = {
|
||||
id: string;
|
||||
@@ -43,11 +44,7 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
|
||||
subscriberId?: string,
|
||||
modelCreator?: ModelCreator,
|
||||
) => {
|
||||
const id = subscriberId
|
||||
? subscriberId
|
||||
: crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
const id = subscriberId || uuidv4();
|
||||
const batch: TextToImageBatch = {
|
||||
id,
|
||||
prompt,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { captureModelThumbnail } from "./captureModelThumbnail";
|
||||
import { completeGenerationAction } from "../Stores/completeGenerationResult";
|
||||
|
||||
export interface PendingExternalImage {
|
||||
url: string;
|
||||
@@ -72,11 +73,7 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
|
||||
meshOnly: boolean,
|
||||
subscriberId?: string,
|
||||
) => {
|
||||
const id = subscriberId
|
||||
? subscriberId
|
||||
: crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
const id = subscriberId || uuidv4();
|
||||
const result: ImageTo3DResult = {
|
||||
id,
|
||||
mode,
|
||||
@@ -90,52 +87,7 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
|
||||
set((s) => ({ results: [result, ...s.results] }));
|
||||
return id;
|
||||
},
|
||||
completeGeneration: (
|
||||
modelUrl: string,
|
||||
mediaToken: string,
|
||||
maybeSubscriberId?: string,
|
||||
) => {
|
||||
console.log("[ImageTo3DStore] completeGeneration", {
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
maybeSubscriberId,
|
||||
});
|
||||
const pending = maybeSubscriberId
|
||||
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
|
||||
: get().results.find((r) => r.status === "pending");
|
||||
|
||||
set((s) => {
|
||||
const results = [...s.results];
|
||||
const targetIdx = pending
|
||||
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
|
||||
: -1;
|
||||
|
||||
if (targetIdx === -1) {
|
||||
const generatedId =
|
||||
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
||||
const result: ImageTo3DResult = {
|
||||
id: generatedId,
|
||||
subscriberId: generatedId,
|
||||
mode: "image",
|
||||
timestamp: Date.now(),
|
||||
note: "Generated Model",
|
||||
status: "completed",
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
};
|
||||
return { results: [result, ...results] };
|
||||
}
|
||||
|
||||
results[targetIdx] = {
|
||||
...results[targetIdx],
|
||||
status: "completed",
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
};
|
||||
|
||||
return { results };
|
||||
});
|
||||
},
|
||||
completeGeneration: completeGenerationAction(set, "ImageTo3DStore", "Generated Model"),
|
||||
uploadCoverImage: async (mediaToken: string, thumbnailDataUrl: string) => {
|
||||
try {
|
||||
// Check if already uploaded
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { completeGenerationAction } from "../Stores/completeGenerationResult";
|
||||
|
||||
export type ImageTo3DWorldResult = {
|
||||
id: string;
|
||||
@@ -54,11 +55,7 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
|
||||
previewUrl: string | undefined,
|
||||
subscriberId?: string,
|
||||
) => {
|
||||
const id = subscriberId
|
||||
? subscriberId
|
||||
: crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
const id = subscriberId || uuidv4();
|
||||
const result: ImageTo3DWorldResult = {
|
||||
id,
|
||||
mode,
|
||||
@@ -71,52 +68,7 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
|
||||
set((s) => ({ results: [result, ...s.results] }));
|
||||
return id;
|
||||
},
|
||||
completeGeneration: (
|
||||
modelUrl: string,
|
||||
mediaToken: string,
|
||||
maybeSubscriberId?: string,
|
||||
) => {
|
||||
console.log("[ImageTo3DWorldStore] completeGeneration", {
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
maybeSubscriberId,
|
||||
});
|
||||
const pending = maybeSubscriberId
|
||||
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
|
||||
: get().results.find((r) => r.status === "pending");
|
||||
|
||||
set((s) => {
|
||||
const results = [...s.results];
|
||||
const targetIdx = pending
|
||||
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
|
||||
: -1;
|
||||
|
||||
if (targetIdx === -1) {
|
||||
const generatedId =
|
||||
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
||||
const result: ImageTo3DWorldResult = {
|
||||
id: generatedId,
|
||||
subscriberId: generatedId,
|
||||
mode: "image",
|
||||
timestamp: Date.now(),
|
||||
note: "Generated World",
|
||||
status: "completed",
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
};
|
||||
return { results: [result, ...results] };
|
||||
}
|
||||
|
||||
results[targetIdx] = {
|
||||
...results[targetIdx],
|
||||
status: "completed",
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
};
|
||||
|
||||
return { results };
|
||||
});
|
||||
},
|
||||
completeGeneration: completeGenerationAction(set, "ImageTo3DWorldStore", "Generated World"),
|
||||
uploadCoverFromPreview: async (mediaToken: string) => {
|
||||
try {
|
||||
const result = get().results.find((r) => r.mediaToken === mediaToken);
|
||||
|
||||
@@ -148,7 +148,7 @@ export const RemoveBackground = () => {
|
||||
}
|
||||
};
|
||||
|
||||
processExternalUrl();
|
||||
void processExternalUrl();
|
||||
}, [
|
||||
pendingExternalUrl,
|
||||
setPendingExternalUrl,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DynamicIcon } from "@storyteller/icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useStoryboardStore, type Board } from "./StoryboardStore";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
const PIXELS_PER_SECOND = 80;
|
||||
const MIN_BLOCK_WIDTH = 40;
|
||||
@@ -73,8 +74,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
|
||||
<label htmlFor="storyboard-title" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
|
||||
<input
|
||||
id="storyboard-title"
|
||||
type="text"
|
||||
value={board.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
@@ -84,8 +86,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
|
||||
<label htmlFor="storyboard-dialogue" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
|
||||
<textarea
|
||||
id="storyboard-dialogue"
|
||||
value={board.dialogue}
|
||||
onChange={(e) => onUpdate({ dialogue: e.target.value })}
|
||||
placeholder="Character dialogue..."
|
||||
@@ -95,8 +98,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
|
||||
<label htmlFor="storyboard-action" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
|
||||
<textarea
|
||||
id="storyboard-action"
|
||||
value={board.action}
|
||||
onChange={(e) => onUpdate({ action: e.target.value })}
|
||||
placeholder="On-screen action..."
|
||||
@@ -106,8 +110,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
|
||||
<label htmlFor="storyboard-notes" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
|
||||
<textarea
|
||||
id="storyboard-notes"
|
||||
value={board.notes}
|
||||
onChange={(e) => onUpdate({ notes: e.target.value })}
|
||||
placeholder="Director notes..."
|
||||
@@ -117,13 +122,14 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
|
||||
<label htmlFor="storyboard-duration" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
|
||||
<input
|
||||
id="storyboard-duration"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={board.duration}
|
||||
onChange={(e) => onUpdate({ duration: parseFloat(e.target.value) || 0 })}
|
||||
onChange={(e) => onUpdate({ duration: Number.parseFloat(e.target.value) || 0 })}
|
||||
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 font-mono text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -353,6 +359,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
|
||||
|
||||
{/* Color picker */}
|
||||
<label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-[3px] border border-white/15 hover:border-white/30" title="Stroke color">
|
||||
<span className="sr-only">Stroke color</span>
|
||||
<span
|
||||
className="h-4 w-4 rounded-sm border border-white/20"
|
||||
style={{ background: color }}
|
||||
@@ -457,6 +464,9 @@ const ThumbnailItem = ({
|
||||
onDragEnd,
|
||||
}: ThumbnailItemProps) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragOver={(e) => {
|
||||
@@ -594,7 +604,7 @@ const Timeline = ({
|
||||
const delta = ev.clientX - resizeRef.current.startX;
|
||||
const newDuration = Math.max(
|
||||
MIN_DURATION,
|
||||
parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
|
||||
Number.parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
|
||||
);
|
||||
onUpdateDuration(resizeRef.current.id, newDuration);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export type VideoItem = {
|
||||
media_token: string;
|
||||
@@ -33,11 +34,7 @@ type ImageToVideoState = {
|
||||
export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
|
||||
batches: [],
|
||||
startBatch: (prompt: string, modelLabel: string, subscriberId?: string) => {
|
||||
const id = subscriberId
|
||||
? subscriberId
|
||||
: crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
const id = subscriberId || uuidv4();
|
||||
const batch: ImageToVideoBatch = {
|
||||
id,
|
||||
prompt,
|
||||
@@ -61,7 +58,7 @@ export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
|
||||
? s.batches.findIndex((b) => b.id === pending.id)
|
||||
: -1;
|
||||
if (idx === -1) {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const id = uuidv4();
|
||||
const batch: ImageToVideoBatch = {
|
||||
id,
|
||||
prompt,
|
||||
|
||||
+19
-60
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { CheckIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SaveIcon, SkipBackIcon, SkipForwardIcon, SparklesIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { CheckIcon, ImagesIcon, RotateCwIcon, SaveIcon, SparklesIcon, VideoIcon } from "lucide-react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
@@ -13,6 +12,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
|
||||
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
|
||||
import { useTabStore } from "~/pages/Stores/TabState";
|
||||
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
|
||||
|
||||
export const VideoFrameExtractor = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
@@ -420,7 +420,7 @@ export const VideoFrameExtractor = () => {
|
||||
const file = new File([blob], fileName, { type: "image/png" });
|
||||
|
||||
const referenceImage: RefImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
id: uuidv4(),
|
||||
url: frameUrl,
|
||||
file: file,
|
||||
mediaToken: uploadResponse.data,
|
||||
@@ -515,6 +515,7 @@ export const VideoFrameExtractor = () => {
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
|
||||
<div
|
||||
role="presentation"
|
||||
ref={progressBarRef}
|
||||
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
|
||||
onClick={handleProgressBarClick}
|
||||
@@ -526,6 +527,7 @@ export const VideoFrameExtractor = () => {
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="presentation"
|
||||
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize bg-yellow-400"
|
||||
style={{
|
||||
left: `${duration ? (startTime / duration) * 100 : 0}%`,
|
||||
@@ -566,61 +568,18 @@ export const VideoFrameExtractor = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => seekToFrame("first")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="First Frame"
|
||||
>
|
||||
<SkipBackIcon
|
||||
|
||||
className="text-sm text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
|
||||
>
|
||||
<DynamicIcon
|
||||
icon={isPlaying ? PauseIcon : PlayIcon}
|
||||
className="text-white"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => seekToFrame("last")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="Last Frame"
|
||||
>
|
||||
<SkipForwardIcon
|
||||
|
||||
className="text-sm text-white" />
|
||||
</button>
|
||||
<div className="mx-1 h-6 w-px bg-white/20" />
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
>
|
||||
<DynamicIcon
|
||||
icon={isMuted ? VolumeXIcon : Volume2Icon}
|
||||
className="text-sm text-white"
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) =>
|
||||
setVolume(parseFloat(e.target.value))
|
||||
}
|
||||
className="w-20 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-sm tabular-nums text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
<VideoTransportControls
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={seekToFrame}
|
||||
onTogglePlay={togglePlayPause}
|
||||
onToggleMute={toggleMute}
|
||||
onVolumeChange={setVolume}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -712,7 +671,7 @@ export const VideoFrameExtractor = () => {
|
||||
setNumFrames(
|
||||
Math.max(
|
||||
1,
|
||||
Math.min(50, parseInt(e.target.value) || 1),
|
||||
Math.min(50, Number.parseInt(e.target.value) || 1),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -735,7 +694,7 @@ export const VideoFrameExtractor = () => {
|
||||
value={frameDistance}
|
||||
onChange={(e) =>
|
||||
setFrameDistance(
|
||||
Math.max(1, parseInt(e.target.value) || 1),
|
||||
Math.max(1, Number.parseInt(e.target.value) || 1),
|
||||
)
|
||||
}
|
||||
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
|
||||
|
||||
+15
-57
@@ -1,11 +1,11 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { DropletIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SkipBackIcon, SkipForwardIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { DropletIcon, ImagesIcon, RotateCwIcon, VideoIcon } from "lucide-react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
|
||||
import toast from "react-hot-toast";
|
||||
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
|
||||
|
||||
export const VideoWatermarkRemover = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
@@ -255,6 +255,7 @@ export const VideoWatermarkRemover = () => {
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
|
||||
<div
|
||||
role="presentation"
|
||||
ref={progressBarRef}
|
||||
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
|
||||
onClick={handleProgressBarClick}
|
||||
@@ -266,61 +267,18 @@ export const VideoWatermarkRemover = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => seekToFrame("first")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="First Frame"
|
||||
>
|
||||
<SkipBackIcon
|
||||
|
||||
className="text-sm text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
|
||||
>
|
||||
<DynamicIcon
|
||||
icon={isPlaying ? PauseIcon : PlayIcon}
|
||||
className="text-white"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => seekToFrame("last")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="Last Frame"
|
||||
>
|
||||
<SkipForwardIcon
|
||||
|
||||
className="text-sm text-white" />
|
||||
</button>
|
||||
<div className="mx-1 h-6 w-px bg-white/20" />
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
>
|
||||
<DynamicIcon
|
||||
icon={isMuted ? VolumeXIcon : Volume2Icon}
|
||||
className="text-sm text-white"
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) =>
|
||||
setVolume(parseFloat(e.target.value))
|
||||
}
|
||||
className="w-20 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-sm tabular-nums text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
<VideoTransportControls
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={seekToFrame}
|
||||
onTogglePlay={togglePlayPause}
|
||||
onToggleMute={toggleMute}
|
||||
onVolumeChange={setVolume}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ interface TabState {
|
||||
[K in TabId]?: string;
|
||||
};
|
||||
// Actions
|
||||
setActiveTab: (tabId: TabId) => Promise<boolean>;
|
||||
setActiveTab: (tabId: TabId) => Promise<void>;
|
||||
updateTabData: (tabId: TabId, data: unknown) => void;
|
||||
getTabData: <T>(tabId: TabId) => T | null;
|
||||
clearTabData: (tabId: TabId) => void;
|
||||
@@ -42,13 +42,12 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
|
||||
setActiveTab: async (newTabId) => {
|
||||
// Don't do anything if we're already on this tab
|
||||
if (get().activeTabId === newTabId) return true;
|
||||
if (get().activeTabId === newTabId) return;
|
||||
|
||||
// The drawing store outlives its React page. Keep its live image objects,
|
||||
// selection, and undo history when switching tabs. Export/import is for
|
||||
// files; round-tripping here reread every image and rebuilt the scene.
|
||||
set({ activeTabId: newTabId });
|
||||
return true;
|
||||
},
|
||||
|
||||
updateTabData: (tabId, data) => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface GenerationResult {
|
||||
id: string;
|
||||
mode: "image" | "text";
|
||||
timestamp: number;
|
||||
note?: string;
|
||||
status: "pending" | "completed";
|
||||
subscriberId: string;
|
||||
modelUrl?: string;
|
||||
mediaToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The results after a 3D job finishes: the one with that subscriber id (else the first still
|
||||
* pending) becomes completed with the model; with none to update, a completed one is added first.
|
||||
*/
|
||||
export function completeGenerationResult<T extends GenerationResult>(
|
||||
results: readonly T[],
|
||||
subscriberId: string | undefined,
|
||||
completed: { modelUrl: string; mediaToken: string; note: string },
|
||||
): T[] {
|
||||
const { modelUrl, mediaToken, note } = completed;
|
||||
const index = subscriberId
|
||||
? results.findIndex((r) => r.subscriberId === subscriberId)
|
||||
: results.findIndex((r) => r.status === "pending");
|
||||
if (index === -1) {
|
||||
const id = uuidv4();
|
||||
// The other fields of T are optional, as the 3D result types declare them.
|
||||
const added = { id, subscriberId: id, mode: "image", timestamp: Date.now(), note, status: "completed", modelUrl, mediaToken } as T;
|
||||
return [added, ...results];
|
||||
}
|
||||
const next = [...results];
|
||||
next[index] = { ...next[index], status: "completed", modelUrl, mediaToken };
|
||||
return next;
|
||||
}
|
||||
|
||||
/** A store's `completeGeneration`: logs the finished job under `store` and applies completeGenerationResult. */
|
||||
export function completeGenerationAction<T extends GenerationResult>(
|
||||
set: (update: (state: { results: T[] }) => { results: T[] }) => void,
|
||||
store: string,
|
||||
note: string,
|
||||
) {
|
||||
return (modelUrl: string, mediaToken: string, maybeSubscriberId?: string) => {
|
||||
console.log(`[${store}] completeGeneration`, { modelUrl, mediaToken, maybeSubscriberId });
|
||||
set((s) => ({
|
||||
results: completeGenerationResult(s.results, maybeSubscriberId, { modelUrl, mediaToken, note }),
|
||||
}));
|
||||
};
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export const completedAudioJobs = computed(() => {
|
||||
export const shouldPollActiveJobs = signal<boolean>(false);
|
||||
|
||||
export const startPollingActiveJobs = () => {
|
||||
PollRecentJobs(); //poll once regardless of polling intervals
|
||||
void PollRecentJobs(); //poll once regardless of polling intervals
|
||||
shouldPollActiveJobs.value = true;
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const login = async ({
|
||||
// technically user is login with the system now, HOWEVER,
|
||||
// in storyteller studio, only having a sesison is not enough,
|
||||
// we need session info and active subscription info as well
|
||||
getUserInfoAndSubcriptions();
|
||||
void getUserInfoAndSubcriptions();
|
||||
|
||||
window.location.href = "/"; // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
|
||||
};
|
||||
@@ -86,7 +86,7 @@ export const signUp = async ({
|
||||
// technically user is login with the system now, HOWEVER,
|
||||
// in storyteller studio, only having a sesison is not enough,
|
||||
// we need session info and active subscription info as well
|
||||
getUserInfoAndSubcriptions();
|
||||
void getUserInfoAndSubcriptions();
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -95,12 +95,12 @@ export const persistLogin = async () => {
|
||||
if (authentication.status.value !== AUTH_STATUS.INIT) {
|
||||
return;
|
||||
}
|
||||
getUserInfoAndSubcriptions();
|
||||
void getUserInfoAndSubcriptions();
|
||||
};
|
||||
|
||||
// NB: Only for SyncStorytellerApiConfig.
|
||||
export const forceGetUserInfoAndSubcriptions = async () => {
|
||||
getUserInfoAndSubcriptions();
|
||||
void getUserInfoAndSubcriptions();
|
||||
};
|
||||
|
||||
async function getUserInfoAndSubcriptions() {
|
||||
|
||||
@@ -40,7 +40,7 @@ export const isJobStatusTerminal = (curr: JobStatus) => {
|
||||
};
|
||||
|
||||
export const isNumberString = (value: string) => {
|
||||
return !isNaN(Number(value));
|
||||
return !Number.isNaN(Number(value));
|
||||
};
|
||||
|
||||
export const isJobStatusError = (curr: JobStatus) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ set -euxo pipefail
|
||||
echo 'TEMPORARY HACK: Build internal dependencies of artcraft since nx is not finding them...'
|
||||
|
||||
# A 'clean' will remove nx
|
||||
npm install
|
||||
npm install --ignore-scripts
|
||||
|
||||
# TODO: Temp commented out
|
||||
nx build "@storyteller/api"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./thumbnail-utils";
|
||||
export * from "./media-format";
|
||||
export * from "./referral";
|
||||
export * from "./keyboard";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* For an element with `role="button"`: Enter or Space clicks it, as they would a native button.
|
||||
* Keys pressed on a control inside it are left to that control.
|
||||
*/
|
||||
export function clickOnEnterOrSpace(event: {
|
||||
key: string;
|
||||
preventDefault(): void;
|
||||
target: unknown;
|
||||
currentTarget: { click(): void };
|
||||
}) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.click();
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,7 @@ export function WaveformAudioPlayer({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
ref={rootRef}
|
||||
className={`flex items-center gap-2.5 ${compact ? "px-2.5 py-2" : "px-3 py-2.5"} ${className}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -273,6 +274,7 @@ export function WaveformAudioPlayer({
|
||||
)}
|
||||
{(waveState === "pending" || waveState === "failed") && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="absolute inset-0 flex cursor-pointer items-center gap-[1.5px]"
|
||||
onClick={handleBarSeek}
|
||||
>
|
||||
|
||||
@@ -135,7 +135,7 @@ export const ButtonDropdown = ({
|
||||
)}
|
||||
<div className="grow text-start">{option.label}</div>
|
||||
<div className="ml-6 font-mono text-[11px] font-normal text-white/45">
|
||||
{option.description && option.description}
|
||||
{option.description}
|
||||
</div>
|
||||
{showSelected && (
|
||||
<>
|
||||
|
||||
@@ -52,6 +52,7 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
|
||||
"conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)",
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Custom color</span>
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
|
||||
@@ -64,7 +64,6 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
className="w-full rounded-[3px] border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isActionReminderOpen,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
type ModalMode = "select" | "view";
|
||||
|
||||
@@ -336,6 +337,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
{/* dropdown menu */}
|
||||
{mode !== "select" && (
|
||||
<div
|
||||
role="presentation"
|
||||
// Tile chrome stays under sticky page headers (e.g. the library
|
||||
// filter bar at z-10): z-[2]/z-[1] keep the in-tile ordering
|
||||
// (menu/checkbox above badges) without escaping the page layers.
|
||||
@@ -376,6 +378,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
createPortal(
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[9998]"
|
||||
onClick={() => setCtxMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
@@ -408,6 +411,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
{/* Bulk selection checkbox — top-left (only when a bulk handler is wired) */}
|
||||
{mode !== "select" && onBulkSelectToggle && (
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={bulkSelected}
|
||||
aria-label={`Select ${item.label}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
className={twMerge(
|
||||
"absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded-[3px] border-2 cursor-pointer transition-all duration-100",
|
||||
bulkSelected
|
||||
@@ -415,7 +423,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
: "border-white/60 bg-black/40 hover:border-white",
|
||||
bulkSelectionMode
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100",
|
||||
: "opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(pointer:coarse)]:opacity-100",
|
||||
)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -403,7 +403,7 @@ const SIDEBAR_TAG_LIMIT = 5;
|
||||
|
||||
const hashString = (s: string): number => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
for (let i = 0; i < s.length; i++) h = Math.imul(Math.imul(h, 31) + s.charCodeAt(i), 1); // int32 wrap
|
||||
return h;
|
||||
};
|
||||
|
||||
@@ -948,7 +948,7 @@ export const GalleryModal = React.memo(
|
||||
// couldn't see it — making the library button appear broken.
|
||||
galleryModalDraggingUnder.value = false;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
setUsernameError(false);
|
||||
const name = await fetchCurrentUsername();
|
||||
if (cancelled) return;
|
||||
@@ -2710,7 +2710,6 @@ export const GalleryModal = React.memo(
|
||||
}}
|
||||
onBlur={handleConfirmRename}
|
||||
className="text-lg font-semibold bg-transparent px-1.5 py-0 text-base-fg outline-none min-w-[6rem] caret-primary"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
@@ -2739,6 +2738,7 @@ export const GalleryModal = React.memo(
|
||||
{folderMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[58]"
|
||||
onClick={() => setFolderMenuOpen(false)}
|
||||
/>
|
||||
@@ -3573,6 +3573,7 @@ export const GalleryModal = React.memo(
|
||||
<>
|
||||
{/* Backdrop to close */}
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[59]"
|
||||
onClick={() => setBulkFolderPopoverOpen(false)}
|
||||
/>
|
||||
@@ -3716,6 +3717,7 @@ export const GalleryModal = React.memo(
|
||||
createPortal(
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[9998]"
|
||||
onClick={() => setContextMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
|
||||
@@ -87,10 +87,10 @@ export function useMediaPromptTokens(
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list, order-independent.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
if (key.length > 0) void fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, string>();
|
||||
|
||||
@@ -60,10 +60,10 @@ export function usePrompts(
|
||||
}
|
||||
// Stable cache key for the effect's dep list. Sorting keeps it stable when
|
||||
// a poll returns the same set in a different order.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
if (key.length > 0) void fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, Prompts>();
|
||||
|
||||
@@ -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";
|
||||
@@ -8,6 +8,9 @@ import { useTutorialModalStore } from "./help-menu-store";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
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[];
|
||||
@@ -42,7 +45,7 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
|
||||
console.warn("Failed to fetch news feed, using bundled content.", e);
|
||||
}
|
||||
};
|
||||
fetchNews();
|
||||
void fetchNews();
|
||||
}, []);
|
||||
|
||||
const selectedPost = useMemo(
|
||||
@@ -109,6 +112,9 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
|
||||
<div className="flex flex-col gap-4 mt-2 h-[500px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
key={item.slug}
|
||||
onClick={() => setSelectedSlug(item.slug)}
|
||||
className="bg-white/5 hover:bg-white/[0.07] border border-white/15 hover:border-white/25 p-4 rounded-none cursor-pointer transition-colors group"
|
||||
@@ -146,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);
|
||||
@@ -176,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,
|
||||
@@ -210,6 +218,9 @@ export function HelpMenuButton({
|
||||
|
||||
const handleMenuSelect = (item: PopoverItem) => {
|
||||
switch (item.action) {
|
||||
case "updates":
|
||||
setUpdatesOpen(true);
|
||||
break;
|
||||
case "tutorials":
|
||||
handleOpenTutorials();
|
||||
break;
|
||||
@@ -312,7 +323,7 @@ export function HelpMenuButton({
|
||||
});
|
||||
};
|
||||
|
||||
run();
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
if (progressTimerRef.current) {
|
||||
@@ -336,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>
|
||||
);
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export const InputVector = ({
|
||||
} as Record<string, string>;
|
||||
|
||||
const newValue =
|
||||
parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
|
||||
Number.parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
|
||||
|
||||
newVector[ref.current?.name ?? ""] = (
|
||||
Math.round(newValue * 1000) / 1000
|
||||
|
||||
@@ -22,10 +22,10 @@ export const NumberInput: React.FC<NumberInputProps> = ({
|
||||
const newValue = event.target.value;
|
||||
if (
|
||||
newValue === "" ||
|
||||
(!isNaN(Number(newValue)) && Number(newValue) <= 100)
|
||||
(!Number.isNaN(Number(newValue)) && Number(newValue) <= 100)
|
||||
) {
|
||||
setInputValue(newValue);
|
||||
const numericValue = parseFloat(newValue);
|
||||
const numericValue = Number.parseFloat(newValue);
|
||||
if (newValue !== "") {
|
||||
onChange(Math.round(numericValue * 100) / 100);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user