mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-07 14:50:10 -05:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87448cf858 | ||
|
|
b4026c4f23 |
@@ -0,0 +1,194 @@
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- 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:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
- 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@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
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: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4 # v0
|
||||
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@v4
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
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@v4
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
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 }}
|
||||
run: packaging/linux/flatpak.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- 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"
|
||||
allow-published: "true"
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
signing-key: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
@@ -84,7 +84,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 +93,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 +139,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 +182,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: |
|
||||
@@ -200,6 +201,7 @@ jobs:
|
||||
npx 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)
|
||||
|
||||
@@ -44,6 +44,7 @@ jobs:
|
||||
node-version: lts/*
|
||||
|
||||
- name: install frontend dependencies
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Navigate to the frontend directory to install dependencies
|
||||
cd ./frontend
|
||||
@@ -63,6 +64,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
|
||||
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# Desktop frontend performance experiments
|
||||
|
||||
Measured September 26, 2026 on an Apple M4 Pro, macOS arm64, Chrome
|
||||
153.0.8010.53, Node 24.13.0. These are production frontend measurements in
|
||||
headless Chrome with mocked Tauri services. They do **not** measure native
|
||||
process launch, WKWebView/WebView2, Rust, disk startup, real account loading,
|
||||
or generation latency.
|
||||
|
||||
## Changes and results
|
||||
|
||||
Seven samples per version; table values are medians. Each sample uses a fresh
|
||||
browser context at 1440 × 1000 with device scale 1 and browser HTTP cache
|
||||
disabled. The browser process stays alive between samples. A reload in the
|
||||
same context is measured separately. There is no CPU/network throttling.
|
||||
External requests, including analytics, fonts, and media, are blocked in both
|
||||
versions. The login fixture is signed in; the bundled model overlay is used.
|
||||
|
||||
| Measurement | Before | After | Reduction |
|
||||
|-----------------------------|-----------|-----------|-----------|
|
||||
| Startup to frame | 566.40 ms | 350.60 ms | 38.1% |
|
||||
| Refresh to frame | 248.60 ms | 198.10 ms | 20.3% |
|
||||
| Startup JavaScript | 9.05 MB | 6.47 MB | 28.6% |
|
||||
| Startup long tasks (sum) | 336.00 ms | 184.00 ms | 45.2% |
|
||||
| First drawing page to frame | 120.90 ms | 118.20 ms | 2.2% |
|
||||
| Drawing to home: dispatch | 23.40 ms | 9.00 ms | 61.5% |
|
||||
| Drawing to home: frame | 29.50 ms | 23.00 ms | 22.0% |
|
||||
| Home to drawing: dispatch | 42.10 ms | 18.80 ms | 55.3% |
|
||||
| Home to drawing: frame | 105.70 ms | 86.50 ms | 18.2% |
|
||||
|
||||
The 2.2% first-open difference is small enough to treat as unchanged. These
|
||||
local measurements are evidence of reduced frontend work, not predictions
|
||||
of identical percentage gains on every user's machine. Raw samples, ranges,
|
||||
environment, fixture sizes, browser errors, and build provenance are in
|
||||
[before.json](performance/2026-09-26-before.json) and
|
||||
[after.json](performance/2026-09-26-after.json).
|
||||
|
||||
Three changes produced these results:
|
||||
|
||||
1. `MainApp.tsx` imported every editor at launch. Fifteen pages now use
|
||||
`React.lazy` behind a Suspense boundary below the persistent shell. Their
|
||||
code loads when selected; the top bar stays available during loading.
|
||||
The default Apps page remains eager. Drawing also remains eager: its
|
||||
shared code is already part of the shell, and experimentally deferring
|
||||
its small host adapter made first-open latency worse for little saving.
|
||||
First-open latency for the other deferred editors is a remaining tradeoff
|
||||
to measure, especially on slower disks/CPUs. All 15 deferred modules were
|
||||
imported successfully in the browser, but this is not an end-to-end test
|
||||
of every editor's features.
|
||||
2. `TabState.ts` exported the drawing scene to JSON when leaving and imported
|
||||
it when returning. The Zustand scene store already survives page unmount.
|
||||
With four imported images, the old path performed four FileReader reads
|
||||
on exit and four more on return, allocated base64 copies, rebuilt image
|
||||
nodes, cleared the selection, and appended an undo-history entry. Tab
|
||||
changes now keep the live store. The measured round trip performs **zero
|
||||
image-file reads**, preserves the selected node, and keeps history length
|
||||
at one instead of growing to two. Explicit file export/import remains
|
||||
available. Tests cover image identity, model metadata, selection, redo,
|
||||
gallery reset, rapid navigation, and portable file export.
|
||||
3. The shell ran `getGPUTier()` at startup and stored its result in an unread
|
||||
React state value. Removing it eliminates **one startup WebGL context
|
||||
request** and its associated detection work. The real 3D renderer still
|
||||
creates its context when used. This experiment reports combined timing
|
||||
gains, not an isolated timing attribution for each individual change.
|
||||
|
||||
## Repeat the experiment
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run perf:desktop -- --output /tmp/artcraft-perf-current --smoke-imports
|
||||
```
|
||||
|
||||
The default browser is an installed Google Chrome (`--channel chrome`). The
|
||||
runner requires permission to launch a browser and bind a loopback server.
|
||||
It never uses your regular browser profile, account, or native app data.
|
||||
`@playwright/test` is pinned as a development dependency. Artifacts are written
|
||||
outside the repository by default. Use a dedicated output directory: its
|
||||
`dist` subdirectory is rebuilt by Vite.
|
||||
|
||||
For this particular two-module change, reproduce the original implementation
|
||||
without replacing local source files:
|
||||
|
||||
```sh
|
||||
npm run perf:desktop -- --output /tmp/artcraft-perf-before \
|
||||
--baseline-ref 780e4494e995163fed1d677b88c3cf03b3fca63c
|
||||
npm run perf:desktop -- --output /tmp/artcraft-perf-after --smoke-imports
|
||||
node tools/performance/compare.mjs \
|
||||
/tmp/artcraft-perf-before/results.json \
|
||||
/tmp/artcraft-perf-after/results.json
|
||||
```
|
||||
|
||||
`--baseline-ref` overlays **only** `MainApp.tsx` and `pages/Stores/TabState.ts`
|
||||
from that Git revision in the benchmark build. All other source files and
|
||||
dependencies remain current. It reproduces the two production files changed
|
||||
in this experiment; it is not a general historical-checkout benchmark. For
|
||||
future changes, capture a normal baseline **before** editing, use a new output
|
||||
directory for the changed version, and keep the runner, fixture, dependencies,
|
||||
browser, and machine identical. Run sequentially while the machine is idle;
|
||||
do not build or run other CPU-heavy tasks during samples. Repeat in reverse
|
||||
order if results are close or noisy.
|
||||
|
||||
Useful options:
|
||||
|
||||
```sh
|
||||
# Build in a restricted shell, then run where browser/server use is allowed.
|
||||
npm run perf:desktop -- --output /tmp/artcraft-perf-current --build-only
|
||||
npm run perf:desktop -- --output /tmp/artcraft-perf-current --skip-build
|
||||
|
||||
# More samples; seven is the default.
|
||||
npm run perf:desktop -- --output /tmp/artcraft-perf-current --runs 15
|
||||
|
||||
# Correctness checks for the navigation change.
|
||||
npx vitest run --config apps/artcraft/vitest.config.ts
|
||||
```
|
||||
|
||||
Do not use `--skip-build` after changing app code; it intentionally measures
|
||||
the existing artifact. `build-info.json` records the revision, changed-file
|
||||
names, baseline overlay, and build time. The benchmark plugin alone exposes
|
||||
the stores needed to seed the scene. It is not part of the normal Vite/Tauri
|
||||
build, and no mock IPC or benchmark global is added to the shipped app.
|
||||
|
||||
The runner builds from the app's working directory, matching its normal
|
||||
build command. This matters because Tailwind resolves its config and content
|
||||
relative to that directory. It rejects a run without working Tailwind styles
|
||||
or with uncaught browser errors. `drawing.png` provides a visual check after
|
||||
the measured operations. The initial unstyled exploratory runs were discarded.
|
||||
|
||||
## What the measurements mean
|
||||
|
||||
The fixture creates four deterministic 1024 × 1024 PNG image nodes and 80
|
||||
strokes of 200 coordinates each, with one selected image. On the recorded
|
||||
browser each PNG is 2,356,706 bytes. Fixture construction, image decoding,
|
||||
and a one-second settling period are outside the tab-transition timings.
|
||||
Each sample follows the same sequence:
|
||||
|
||||
1. Navigate to the production app, wait for the Apps heading and for the
|
||||
signed-in login overlay to disappear, then wait two animation frames.
|
||||
2. Reload that page and repeat the same readiness checks.
|
||||
3. Seed the scene while on Apps, open drawing, and wait for its canvas and
|
||||
two animation frames. Allow a second for its background bake to settle.
|
||||
4. Switch to Apps and then back to drawing, measuring each separately.
|
||||
5. After a settling period, inspect node count, selection, history, and IPC
|
||||
counters. Save a screenshot on the first run. Optionally evaluate all
|
||||
deferred page modules after the final timed operation.
|
||||
|
||||
`readyMs` is `performance.now()` after readiness checks and two animation
|
||||
frames; its origin is navigation start. It includes browser loading and
|
||||
automation readiness-observation overhead. `jsBytes` sums decoded resource
|
||||
sizes of loaded JavaScript; it is uncompressed bytes, not all packaged files.
|
||||
`longTaskMs` sums observed startup tasks longer than 50 ms, not total CPU time
|
||||
or total blocking time. Long Tasks support is browser-dependent.
|
||||
|
||||
`stateReadyMs` measures the awaited `setActiveTab()` action, including React
|
||||
work synchronously flushed by its store subscribers. `paintMs` continues
|
||||
until the destination heading/canvas exists and two animation frames have
|
||||
passed. This is a **rendering-opportunity proxy**, not proof of GPU presentation
|
||||
or of every asynchronous image decode completing. In particular, the old
|
||||
return path could still be decoding images after the canvas first appeared.
|
||||
Tab measurements also create `artcraft:tab:APPS` and `artcraft:tab:2D` User
|
||||
Timing entries in the benchmark page.
|
||||
|
||||
The runner calls the same store action used by navigation UI. It excludes
|
||||
pointer-event dispatch, menu animations, and physical click-to-screen latency.
|
||||
It does not automate paid generations, sign in to a real account, or measure
|
||||
remote services. Save the raw JSON as well as the summary; a single minimum
|
||||
time is not a reliable comparison.
|
||||
|
||||
`dist/performance-bundle.json` ranks the largest retained modules per chunk
|
||||
and records chunk imports. Module `renderedBytes` is Rollup's intermediate
|
||||
module size; it is useful for attribution but should not be summed as final
|
||||
minified/gzipped size. Use actual resource bytes for the startup total.
|
||||
|
||||
## Remaining investigation
|
||||
|
||||
- The shell still loads about 6.47 MB of JavaScript. Shared imports through
|
||||
drawing, scene, gallery, and settings entry points need a deeper dependency
|
||||
audit. Separate lightweight store/type imports from editor code, then use
|
||||
the bundle report and the same startup experiment to verify what actually
|
||||
leaves the initial graph. The report identifies Spark, Three.js core and
|
||||
renderer, the inline FBX-to-GLB conversion worker, PostHog, and Mediabunny
|
||||
among the largest retained modules. Their presence is measured; the runtime
|
||||
benefit of deferring each individually has not yet been measured.
|
||||
- Returning to the populated canvas still takes about 86 ms. The editor
|
||||
remount, Konva drawing, image/transformer setup, and composite bake need a
|
||||
timeline profile before choosing the next change. Keep scene restoration,
|
||||
undo, selection, and background generation behavior in correctness checks.
|
||||
- `TaskQueue.tsx` polls every five seconds and rebuilds task arrays even when
|
||||
the popover is closed. The benchmark uses an empty queue, so it does not
|
||||
establish the cost for a large real queue. A fixed, representative task
|
||||
fixture plus idle CPU/render counts is the next useful experiment there.
|
||||
- `useStageSnapshot` looks expensive in isolation, but its only call is
|
||||
commented out in `PaintSurface.tsx`. It was excluded as a current bottleneck.
|
||||
|
||||
Validation completed: normal production Vite build, all 10 app tests and 20
|
||||
login tests, all 15 deferred page-module imports, visual canvas inspection,
|
||||
and a check that the normal build contains no benchmark globals or fixtures.
|
||||
The broad TypeScript check remains blocked by existing workspace declaration/
|
||||
project-reference errors and Remix/Deno types. Comparing diagnostics with the
|
||||
two original modules showed the same diagnostic messages (some import traces
|
||||
and reporting locations changed); this is not a clean typecheck claim.
|
||||
|
||||
## Validate in the native app
|
||||
|
||||
Before claiming native launch or interaction gains, run matching release
|
||||
Tauri builds on the same machine and WebView version. Keep account, window
|
||||
size, network conditions, and canvas artwork fixed. Record the Web Inspector
|
||||
timeline for launch/reload, opening a tool for the first time, drawing → Apps,
|
||||
and Apps → drawing. Repeat at least seven times and retain the traces with
|
||||
build IDs and medians. Separate native process launch from page reload; do
|
||||
not mix dev/HMR traces with production measurements.
|
||||
|
||||
For canvas tests, create a blank 1024-square canvas, import the same four
|
||||
images, add the same strokes, select an image, and wait for background work
|
||||
before switching. Check image content, selection, undo/redo, and opening a new
|
||||
gallery image afterward. Record actual click-to-visible-frame latency in
|
||||
the inspector as well as JavaScript tasks. The browser results above justify
|
||||
the code changes but do not replace that native validation.
|
||||
@@ -1,527 +0,0 @@
|
||||
{
|
||||
"timestamp": "2026-09-27T00:32:54.524Z",
|
||||
"build": {
|
||||
"builtAt": "2026-09-27T00:30:35.760Z",
|
||||
"revision": "780e4494e995163fed1d677b88c3cf03b3fca63c",
|
||||
"baselineOverlay": null,
|
||||
"changedFiles": [
|
||||
"frontend/apps/artcraft/app/src/pages/MainApp.tsx",
|
||||
"frontend/apps/artcraft/app/src/pages/Stores/TabState.ts",
|
||||
"frontend/package-lock.json",
|
||||
"frontend/package.json"
|
||||
]
|
||||
},
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"browser": "153.0.8010.53",
|
||||
"node": "v24.13.0",
|
||||
"runs": 7,
|
||||
"viewport": "1440x1000@1",
|
||||
"ipc": "mocked",
|
||||
"network": "local assets only, browser cache disabled"
|
||||
},
|
||||
"summary": {
|
||||
"startup": {
|
||||
"readyMs": {
|
||||
"median": 350.60000002384186,
|
||||
"min": 348.7000000476837,
|
||||
"max": 457.39999997615814
|
||||
},
|
||||
"jsBytes": {
|
||||
"median": 6466606,
|
||||
"min": 6466606,
|
||||
"max": 6466606
|
||||
},
|
||||
"longTaskMs": {
|
||||
"median": 184,
|
||||
"min": 179,
|
||||
"max": 284
|
||||
},
|
||||
"webglContexts": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0
|
||||
}
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": {
|
||||
"median": 198.10000002384186,
|
||||
"min": 197.10000002384186,
|
||||
"max": 198.80000007152557
|
||||
},
|
||||
"jsBytes": {
|
||||
"median": 6466606,
|
||||
"min": 6466606,
|
||||
"max": 6466606
|
||||
},
|
||||
"longTaskMs": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0
|
||||
},
|
||||
"webglContexts": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0
|
||||
}
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": {
|
||||
"median": 38.300000071525574,
|
||||
"min": 37.60000002384186,
|
||||
"max": 40.299999952316284
|
||||
},
|
||||
"paintMs": {
|
||||
"median": 118.19999992847443,
|
||||
"min": 115.30000007152557,
|
||||
"max": 120
|
||||
},
|
||||
"fileReads": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0
|
||||
}
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": {
|
||||
"median": 9,
|
||||
"min": 7.5,
|
||||
"max": 11
|
||||
},
|
||||
"paintMs": {
|
||||
"median": 23,
|
||||
"min": 19.600000023841858,
|
||||
"max": 26.600000023841858
|
||||
},
|
||||
"fileReads": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0
|
||||
}
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": {
|
||||
"median": 18.800000071525574,
|
||||
"min": 15.799999952316284,
|
||||
"max": 20.700000047683716
|
||||
},
|
||||
"paintMs": {
|
||||
"median": 86.5,
|
||||
"min": 78.79999995231628,
|
||||
"max": 90.10000002384186
|
||||
},
|
||||
"fileReads": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 457.39999997615814,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 284,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 197.80000007152557,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 38.800000071525574,
|
||||
"paintMs": 119.5,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 7.600000023841858,
|
||||
"paintMs": 20.399999976158142,
|
||||
"fileReads": 0
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 17.799999952316284,
|
||||
"paintMs": 87.79999995231628,
|
||||
"fileReads": 0
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 1,
|
||||
"historyLength": 1,
|
||||
"commands": {
|
||||
"plugin:event|listen": 25,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 365.7999999523163,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 185,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 197.10000002384186,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 37.60000002384186,
|
||||
"paintMs": 116,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 7.5,
|
||||
"paintMs": 26.600000023841858,
|
||||
"fileReads": 0
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 15.799999952316284,
|
||||
"paintMs": 78.79999995231628,
|
||||
"fileReads": 0
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 1,
|
||||
"historyLength": 1,
|
||||
"commands": {
|
||||
"plugin:event|listen": 25,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 366.59999990463257,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 193,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 198.39999997615814,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 40.299999952316284,
|
||||
"paintMs": 118.19999992847443,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 9,
|
||||
"paintMs": 23,
|
||||
"fileReads": 0
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 19.699999928474426,
|
||||
"paintMs": 86.5,
|
||||
"fileReads": 0
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 1,
|
||||
"historyLength": 1,
|
||||
"commands": {
|
||||
"plugin:event|listen": 25,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 348.7000000476837,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 184,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 197.89999997615814,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 37.700000047683716,
|
||||
"paintMs": 115.60000002384186,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 9.300000071525574,
|
||||
"paintMs": 26.40000009536743,
|
||||
"fileReads": 0
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 18.700000047683716,
|
||||
"paintMs": 84.90000009536743,
|
||||
"fileReads": 0
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 1,
|
||||
"historyLength": 1,
|
||||
"commands": {
|
||||
"plugin:event|listen": 25,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 349.60000002384186,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 179,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 198.10000002384186,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 38.300000071525574,
|
||||
"paintMs": 118.5,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 11,
|
||||
"paintMs": 19.600000023841858,
|
||||
"fileReads": 0
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 20.700000047683716,
|
||||
"paintMs": 90.10000002384186,
|
||||
"fileReads": 0
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 1,
|
||||
"historyLength": 1,
|
||||
"commands": {
|
||||
"plugin:event|listen": 25,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 350.60000002384186,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 179,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 198.20000004768372,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 39.60000002384186,
|
||||
"paintMs": 120,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 11,
|
||||
"paintMs": 22.199999928474426,
|
||||
"fileReads": 0
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 20,
|
||||
"paintMs": 87,
|
||||
"fileReads": 0
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 1,
|
||||
"historyLength": 1,
|
||||
"commands": {
|
||||
"plugin:event|listen": 25,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 349.89999997615814,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 180,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 198.80000007152557,
|
||||
"jsBytes": 6466606,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 0
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 38,
|
||||
"paintMs": 115.30000007152557,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 8.200000047683716,
|
||||
"paintMs": 24.899999976158142,
|
||||
"fileReads": 0
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 18.800000071525574,
|
||||
"paintMs": 84.5,
|
||||
"fileReads": 0
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 1,
|
||||
"historyLength": 1,
|
||||
"commands": {
|
||||
"plugin:event|listen": 25,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"importedPages": [
|
||||
"src/pages/PageAngles/index.ts",
|
||||
"src/pages/PageAudio/CreateAudio.tsx",
|
||||
"src/pages/PageBackgroundChange/index.ts",
|
||||
"src/pages/PageImage/TextToImage.tsx",
|
||||
"src/pages/PageImageTo3DObject/index.ts",
|
||||
"src/pages/PageImageTo3DWorld/index.ts",
|
||||
"src/pages/PageImageWatermarkRemover/index.ts",
|
||||
"src/pages/PageMoodboard/index.ts",
|
||||
"src/pages/PageRemoveBackground/index.ts",
|
||||
"src/pages/PageScene/index.tsx",
|
||||
"src/pages/PageStoryboard/index.ts",
|
||||
"src/pages/PageVideo/ImageToVideo.tsx",
|
||||
"src/pages/PageVideoEditor/index.tsx",
|
||||
"src/pages/PageVideoFrameExtractor/index.ts",
|
||||
"src/pages/PageVideoWatermarkRemover/index.ts"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
{
|
||||
"timestamp": "2026-09-27T00:30:21.217Z",
|
||||
"build": {
|
||||
"builtAt": "2026-09-27T00:28:39.209Z",
|
||||
"revision": "780e4494e995163fed1d677b88c3cf03b3fca63c",
|
||||
"baselineOverlay": "HEAD",
|
||||
"changedFiles": [
|
||||
"frontend/apps/artcraft/app/src/pages/MainApp.tsx",
|
||||
"frontend/apps/artcraft/app/src/pages/Stores/TabState.ts",
|
||||
"frontend/package-lock.json",
|
||||
"frontend/package.json"
|
||||
]
|
||||
},
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"browser": "153.0.8010.53",
|
||||
"node": "v24.13.0",
|
||||
"runs": 7,
|
||||
"viewport": "1440x1000@1",
|
||||
"ipc": "mocked",
|
||||
"network": "local assets only, browser cache disabled"
|
||||
},
|
||||
"summary": {
|
||||
"startup": {
|
||||
"readyMs": {
|
||||
"median": 566.3999999761581,
|
||||
"min": 532.3999999761581,
|
||||
"max": 847.7999999523163
|
||||
},
|
||||
"jsBytes": {
|
||||
"median": 9052991,
|
||||
"min": 9052991,
|
||||
"max": 9052991
|
||||
},
|
||||
"longTaskMs": {
|
||||
"median": 336,
|
||||
"min": 314,
|
||||
"max": 407
|
||||
},
|
||||
"webglContexts": {
|
||||
"median": 1,
|
||||
"min": 1,
|
||||
"max": 1
|
||||
}
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": {
|
||||
"median": 248.60000002384186,
|
||||
"min": 247.59999990463257,
|
||||
"max": 351.1999999284744
|
||||
},
|
||||
"jsBytes": {
|
||||
"median": 9052991,
|
||||
"min": 9052991,
|
||||
"max": 9052991
|
||||
},
|
||||
"longTaskMs": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0
|
||||
},
|
||||
"webglContexts": {
|
||||
"median": 1,
|
||||
"min": 1,
|
||||
"max": 1
|
||||
}
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": {
|
||||
"median": 40.40000009536743,
|
||||
"min": 37.299999952316284,
|
||||
"max": 43.299999952316284
|
||||
},
|
||||
"paintMs": {
|
||||
"median": 120.90000009536743,
|
||||
"min": 117.89999997615814,
|
||||
"max": 126.79999995231628
|
||||
},
|
||||
"fileReads": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0
|
||||
}
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": {
|
||||
"median": 23.399999976158142,
|
||||
"min": 20.100000023841858,
|
||||
"max": 26
|
||||
},
|
||||
"paintMs": {
|
||||
"median": 29.5,
|
||||
"min": 21.200000047683716,
|
||||
"max": 35.5
|
||||
},
|
||||
"fileReads": {
|
||||
"median": 4,
|
||||
"min": 4,
|
||||
"max": 4
|
||||
}
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": {
|
||||
"median": 42.10000002384186,
|
||||
"min": 36.59999990463257,
|
||||
"max": 45.200000047683716
|
||||
},
|
||||
"paintMs": {
|
||||
"median": 105.69999992847443,
|
||||
"min": 100.89999997615814,
|
||||
"max": 109.20000004768372
|
||||
},
|
||||
"fileReads": {
|
||||
"median": 4,
|
||||
"min": 4,
|
||||
"max": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 847.7999999523163,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 407,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 351.1999999284744,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 43.299999952316284,
|
||||
"paintMs": 126.79999995231628,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 25.100000023841858,
|
||||
"paintMs": 35.5,
|
||||
"fileReads": 4
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 36.59999990463257,
|
||||
"paintMs": 100.89999997615814,
|
||||
"fileReads": 4
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 0,
|
||||
"historyLength": 2,
|
||||
"commands": {
|
||||
"plugin:event|listen": 26,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 539.3999999761581,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 317,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 264.60000002384186,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 39.39999997615814,
|
||||
"paintMs": 120.20000004768372,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 20.100000023841858,
|
||||
"paintMs": 21.200000047683716,
|
||||
"fileReads": 4
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 41.60000002384186,
|
||||
"paintMs": 104.60000002384186,
|
||||
"fileReads": 4
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 0,
|
||||
"historyLength": 2,
|
||||
"commands": {
|
||||
"plugin:event|listen": 26,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 561.9000000953674,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 329,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 248.19999992847443,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 40.40000009536743,
|
||||
"paintMs": 120.90000009536743,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 23.399999976158142,
|
||||
"paintMs": 33.39999997615814,
|
||||
"fileReads": 4
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 37.299999952316284,
|
||||
"paintMs": 101.10000002384186,
|
||||
"fileReads": 4
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 0,
|
||||
"historyLength": 2,
|
||||
"commands": {
|
||||
"plugin:event|listen": 26,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 569.5,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 336,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 248,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 38,
|
||||
"paintMs": 119.30000007152557,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 26,
|
||||
"paintMs": 34.799999952316284,
|
||||
"fileReads": 4
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 45.200000047683716,
|
||||
"paintMs": 109.20000004768372,
|
||||
"fileReads": 4
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 0,
|
||||
"historyLength": 2,
|
||||
"commands": {
|
||||
"plugin:event|listen": 26,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 567.5,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 339,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 248.60000002384186,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 40.89999997615814,
|
||||
"paintMs": 122.10000002384186,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 23.200000047683716,
|
||||
"paintMs": 29.5,
|
||||
"fileReads": 4
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 42.10000002384186,
|
||||
"paintMs": 105.69999992847443,
|
||||
"fileReads": 4
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 0,
|
||||
"historyLength": 2,
|
||||
"commands": {
|
||||
"plugin:event|listen": 26,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 566.3999999761581,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 336,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 281.39999997615814,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 43.09999990463257,
|
||||
"paintMs": 124.69999992847443,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 23.399999976158142,
|
||||
"paintMs": 26.199999928474426,
|
||||
"fileReads": 4
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 42.799999952316284,
|
||||
"paintMs": 107.19999992847443,
|
||||
"fileReads": 4
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 0,
|
||||
"historyLength": 2,
|
||||
"commands": {
|
||||
"plugin:event|listen": 26,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"startup": {
|
||||
"readyMs": 532.3999999761581,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 314,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"refresh": {
|
||||
"readyMs": 247.59999990463257,
|
||||
"jsBytes": 9052991,
|
||||
"longTaskMs": 0,
|
||||
"webglContexts": 1
|
||||
},
|
||||
"firstDraw": {
|
||||
"stateReadyMs": 37.299999952316284,
|
||||
"paintMs": 117.89999997615814,
|
||||
"fileReads": 0
|
||||
},
|
||||
"leaveDraw": {
|
||||
"stateReadyMs": 21.5,
|
||||
"paintMs": 22.899999976158142,
|
||||
"fileReads": 4
|
||||
},
|
||||
"returnDraw": {
|
||||
"stateReadyMs": 44.09999990463257,
|
||||
"paintMs": 108.39999997615814,
|
||||
"fileReads": 4
|
||||
},
|
||||
"fixture": {
|
||||
"images": 4,
|
||||
"imageSize": 1024,
|
||||
"imageBytesEach": 2356706,
|
||||
"strokes": 80,
|
||||
"coordinatesPerStroke": 200
|
||||
},
|
||||
"afterReturn": {
|
||||
"nodeCount": 84,
|
||||
"selectedCount": 0,
|
||||
"historyLength": 2,
|
||||
"commands": {
|
||||
"plugin:event|listen": 26,
|
||||
"get_app_info_command": 1,
|
||||
"list_image_models_command": 1,
|
||||
"list_video_models_command": 1,
|
||||
"get_task_queue_command": 1,
|
||||
"estimate_image_cost_command": 5,
|
||||
"storyteller_get_login_session_command": 1,
|
||||
"plugin:http|fetch": 5,
|
||||
"storyteller_get_credits_command": 1,
|
||||
"storyteller_get_subscription_command": 1,
|
||||
"plugin:window|is_maximized": 1,
|
||||
"plugin:event|unlisten": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
# Website login from the desktop app
|
||||
|
||||
The React login/signup dialog calls Tauri commands only, including its startup
|
||||
session check and password forms. Website and QR actions appear only in login
|
||||
mode. Tauri never connects to MySQL; the desktop uses Rust HTTP API bindings. Creating/polling login challenges and
|
||||
verifying `/v1/session` use `artcraft_client::endpoints::users::login_challenges`.
|
||||
The Rust command uses `AppEnvConfigs.storyteller_host` for every request, keeps the
|
||||
private device token in native memory, and returns only a local challenge handle,
|
||||
approval URL/code/deadline, status, and the verified user to JavaScript. The signed
|
||||
session cookie never crosses IPC or gets written to JavaScript local storage.
|
||||
|
||||
After redemption, Rust verifies the signed session against the same API host,
|
||||
installs the server's cookie into the native HTTP jar and credential manager, and
|
||||
persists the jar. Closing the dialog removes the local handle; a cancellation
|
||||
while HTTP is in flight prevents installing the late response. Retries retrieve
|
||||
the same server session. Neither creation nor website approval pre-creates one.
|
||||
|
||||
## Session response compatibility
|
||||
|
||||
A server feature flag added after a desktop release must not prevent login. The
|
||||
session decoder keeps known feature flags and ignores unknown flag names, such
|
||||
as `use_qt` on older clients. Malformed flag values and required authentication
|
||||
fields still fail validation. Tests cover a real Rust HTTP session response with
|
||||
both known and future flags, followed by native cookie installation/persistence.
|
||||
A late startup session check cannot reset an active or completed website login.
|
||||
|
||||
## Diagnosing 401 responses
|
||||
|
||||
`storyteller_activity_thread` reports errors from `/v1/analytics/active_user_v2`.
|
||||
This is the background activity ping, not the login-challenge endpoint. It requires
|
||||
a user session. An anonymous `visitor` cookie alone is now skipped. Expired or
|
||||
revoked sessions can still produce a 401 and require signing in again.
|
||||
|
||||
Activity failures log the configured API origin (including port), endpoint, and
|
||||
error. Native website-login requests/failures log origin, endpoint, and HTTP
|
||||
status. Request bodies, device tokens, approval URLs, and signed cookies are not
|
||||
logged by the new client, including at debug level.
|
||||
|
||||
## API environment
|
||||
|
||||
`~/Artcraft/settings/env_configs.json` controls the native API host:
|
||||
|
||||
```json
|
||||
{"storyteller_host":"localhost","storyteller_port":12345}
|
||||
```
|
||||
|
||||
Local mode uses `http://localhost:12345` and a local approval website at
|
||||
`http://localhost:4201/login/desktop`. Run storyteller-web with its migrations
|
||||
applied, and the services webapp with `USE_LOCAL_API=1`. The local website must
|
||||
also use the local API. A challenge in a local database cannot be approved on the
|
||||
production website, even when that browser already has a production session.
|
||||
|
||||
For the production API and production website, use `"storyteller_host":"production"`
|
||||
(or omit the override), then restart ArtCraft. API host configuration is not
|
||||
silently changed by login. Local and production approval URLs cannot be mixed.
|
||||
Localhost development URLs are for the same computer; mobile QR testing needs
|
||||
a shared/deployed environment.
|
||||
|
||||
## Tests
|
||||
|
||||
From the desktop repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
|
||||
cd frontend
|
||||
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx
|
||||
```
|
||||
|
||||
Native tests use only ephemeral loopback HTTP servers and temporary cookie stores.
|
||||
They exercise the real API bindings and the command implementation, including
|
||||
cookie verification/persistence, rejection, expiry, retries, in-flight cancellation,
|
||||
origin separation, no redirects, redacted errors, visitor-only telemetry, future
|
||||
feature flags, malformed session data, and password login/signup/session checks.
|
||||
UI tests prohibit JavaScript HTTP and runtime API imports. They verify the IPC
|
||||
contract, native browser opening, QR/code display, status, backoff, cleanup,
|
||||
login-only button placement, native password forms, and stale session responses.
|
||||
|
||||
The services repository also has real MySQL integration tests for approval,
|
||||
redemption, rejection, concurrency, and downstream authentication, using isolated
|
||||
local databases. No test contacts a production API or database.
|
||||
@@ -10,7 +10,8 @@
|
||||
// siblings, never wrapped by EngineProvider, so they have no
|
||||
// dependency on the 3D engine being present.
|
||||
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import * as gpu from "detect-gpu";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
|
||||
import { TopBar } from "~/components";
|
||||
@@ -57,55 +58,26 @@ import { useTextToImageStore } from "./PageImage/TextToImageStore";
|
||||
|
||||
import { AppsIndexPage } from "./PageApps/AppsIndexPage";
|
||||
import PageDraw from "./PageDraw/PageDraw";
|
||||
import TextToImage from "./PageImage/TextToImage";
|
||||
import ImageToVideo from "./PageVideo/ImageToVideo";
|
||||
import CreateAudio from "./PageAudio/CreateAudio";
|
||||
import { VideoFrameExtractor } from "./PageVideoFrameExtractor";
|
||||
import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
|
||||
import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
|
||||
import { ImageTo3DObject } from "./PageImageTo3DObject";
|
||||
import { ImageTo3DWorld } from "./PageImageTo3DWorld";
|
||||
import { RemoveBackground } from "./PageRemoveBackground";
|
||||
import { Angles } from "./PageAngles";
|
||||
import { Storyboard } from "./PageStoryboard";
|
||||
import { PageBackgroundChange } from "./PageBackgroundChange";
|
||||
import { PageScene } from "./PageScene";
|
||||
import { PageVideoEditor } from "./PageVideoEditor";
|
||||
import { PageMoodboard } from "./PageMoodboard";
|
||||
import {
|
||||
topNavMediaId,
|
||||
topNavMediaUrl,
|
||||
} from "~/components/signaled/TopBar/TopBar";
|
||||
|
||||
// Load editors when selected, keeping their code off the launch path. Drawing
|
||||
// stays eager: its shared store/UI is already needed by the shell, so deferring
|
||||
// its small host adapter adds first-open latency for very little byte saving.
|
||||
// Declare lazy components once so navigating does not recreate their identity.
|
||||
const TextToImage = lazy(() => import("./PageImage/TextToImage"));
|
||||
const ImageToVideo = lazy(() => import("./PageVideo/ImageToVideo"));
|
||||
const CreateAudio = lazy(() => import("./PageAudio/CreateAudio"));
|
||||
const VideoFrameExtractor = lazy(() =>
|
||||
import("./PageVideoFrameExtractor").then((m) => ({ default: m.VideoFrameExtractor })),
|
||||
);
|
||||
const VideoWatermarkRemover = lazy(() =>
|
||||
import("./PageVideoWatermarkRemover").then((m) => ({ default: m.VideoWatermarkRemover })),
|
||||
);
|
||||
const ImageWatermarkRemover = lazy(() =>
|
||||
import("./PageImageWatermarkRemover").then((m) => ({ default: m.ImageWatermarkRemover })),
|
||||
);
|
||||
const ImageTo3DObject = lazy(() =>
|
||||
import("./PageImageTo3DObject").then((m) => ({ default: m.ImageTo3DObject })),
|
||||
);
|
||||
const ImageTo3DWorld = lazy(() =>
|
||||
import("./PageImageTo3DWorld").then((m) => ({ default: m.ImageTo3DWorld })),
|
||||
);
|
||||
const RemoveBackground = lazy(() =>
|
||||
import("./PageRemoveBackground").then((m) => ({ default: m.RemoveBackground })),
|
||||
);
|
||||
const Angles = lazy(() =>
|
||||
import("./PageAngles").then((m) => ({ default: m.Angles })),
|
||||
);
|
||||
const Storyboard = lazy(() =>
|
||||
import("./PageStoryboard").then((m) => ({ default: m.Storyboard })),
|
||||
);
|
||||
const PageBackgroundChange = lazy(() =>
|
||||
import("./PageBackgroundChange").then((m) => ({ default: m.PageBackgroundChange })),
|
||||
);
|
||||
const PageScene = lazy(() =>
|
||||
import("./PageScene").then((m) => ({ default: m.PageScene })),
|
||||
);
|
||||
const PageVideoEditor = lazy(() =>
|
||||
import("./PageVideoEditor").then((m) => ({ default: m.PageVideoEditor })),
|
||||
);
|
||||
const PageMoodboard = lazy(() =>
|
||||
import("./PageMoodboard").then((m) => ({ default: m.PageMoodboard })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
sceneToken?: string;
|
||||
}
|
||||
@@ -149,7 +121,8 @@ export const MainApp = ({ sceneToken }: Props) => {
|
||||
toast.error("File deleted.");
|
||||
});
|
||||
|
||||
// Session probe (runs once per shell mount).
|
||||
// Session probe (runs once per shell mount) and GPU detection.
|
||||
// Both are app-wide concerns, not 3D-only.
|
||||
useEffect(() => {
|
||||
const usersApi = new UsersApi();
|
||||
usersApi.GetSession().then((result) => {
|
||||
@@ -159,6 +132,20 @@ export const MainApp = ({ sceneToken }: Props) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [, setValidGpu] = useState("unknown");
|
||||
useEffect(() => {
|
||||
const { getGPUTier } = gpu;
|
||||
getGPUTier().then((gpuTier) => {
|
||||
console.log("GPU tier", gpuTier);
|
||||
let isValid = false;
|
||||
const fps = gpuTier.fps || 0;
|
||||
if (gpuTier.tier > 1) isValid = true;
|
||||
if (fps > 15) isValid = true;
|
||||
if (gpuTier.gpu === "apple gpu (Apple GPU)") isValid = true;
|
||||
setValidGpu(isValid ? "valid" : "error");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { triggerRecheck } = useLoginModalStore();
|
||||
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
|
||||
useCreditsModalStore();
|
||||
@@ -192,18 +179,7 @@ export const MainApp = ({ sceneToken }: Props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
role="status"
|
||||
className="flex h-[calc(100vh-56px)] items-center justify-center text-base-fg/60"
|
||||
>
|
||||
Loading…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TabBody sceneToken={sceneToken} />
|
||||
</Suspense>
|
||||
<TabBody sceneToken={sceneToken} />
|
||||
|
||||
<GalleryDragComponent />
|
||||
<ErrorDialog />
|
||||
@@ -234,7 +210,7 @@ export const MainApp = ({ sceneToken }: Props) => {
|
||||
};
|
||||
|
||||
const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
const tabStore = useTabStore();
|
||||
const storyboardPageEnabled = useStoryboardPageEnabled();
|
||||
|
||||
// The 3D case stays unwrapped because Stage3DBody (lib) already
|
||||
@@ -242,7 +218,7 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
// top-level children may use position: fixed (e.g. PageDraw); the
|
||||
// wrapping <div> scopes them so they don't stack as siblings of
|
||||
// the TopBar at the MainApp root.
|
||||
switch (activeTabId) {
|
||||
switch (tabStore.activeTabId) {
|
||||
case "3D":
|
||||
return <PageScene sceneToken={sceneToken} />;
|
||||
case "APPS":
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { beforeEach, expect, it } from "vitest";
|
||||
import { useTabStore } from "./TabState";
|
||||
import { useSceneStore } from "../../../../../../libs/components/pagedraw/src/lib/stores/SceneState";
|
||||
import { Node } from "../../../../../../libs/components/pagedraw/src/lib/Node";
|
||||
|
||||
const initialScene = useSceneStore.getInitialState();
|
||||
const initialTabs = useTabStore.getInitialState();
|
||||
|
||||
beforeEach(() => {
|
||||
useSceneStore.setState(initialScene, true);
|
||||
useTabStore.setState(initialTabs, true);
|
||||
});
|
||||
|
||||
it("retains decoded images, selection, model metadata, and history across tab changes", async () => {
|
||||
const image = new Node({
|
||||
id: "image", type: "image", x: 10, y: 20, width: 512, height: 512,
|
||||
fill: "transparent", imageFile: new File(["pixels"], "artwork.png"),
|
||||
imageElement: new Image(), modelUrl: "blob:model",
|
||||
});
|
||||
useSceneStore.setState({ drawNodes: [image], selectedNodeIds: [image.id] });
|
||||
useSceneStore.getState().saveState();
|
||||
const before = useSceneStore.getState();
|
||||
|
||||
await useTabStore.getState().setActiveTab("2D");
|
||||
await useTabStore.getState().setActiveTab("APPS");
|
||||
await useTabStore.getState().setActiveTab("2D");
|
||||
|
||||
const after = useSceneStore.getState();
|
||||
expect(after.drawNodes).toBe(before.drawNodes);
|
||||
expect(after.drawNodes[0]).toBe(image);
|
||||
expect(after.selectedNodeIds).toEqual([image.id]);
|
||||
expect(after.history).toBe(before.history);
|
||||
expect(after.historyIndex).toBe(before.historyIndex);
|
||||
});
|
||||
|
||||
it("keeps redo available when navigating away after undo", async () => {
|
||||
const scene = useSceneStore.getState();
|
||||
useSceneStore.setState({ drawNodes: [rectangle("first")] });
|
||||
scene.saveState();
|
||||
useSceneStore.setState({ drawNodes: [rectangle("first"), rectangle("second")] });
|
||||
scene.saveState();
|
||||
await scene.undo();
|
||||
|
||||
await useTabStore.getState().setActiveTab("2D");
|
||||
await useTabStore.getState().setActiveTab("IMAGE");
|
||||
await useTabStore.getState().setActiveTab("2D");
|
||||
await useSceneStore.getState().redo();
|
||||
|
||||
expect(useSceneStore.getState().drawNodes.map((node) => node.id)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("keeps edits made while away and does not restore an old scene after a gallery reset", async () => {
|
||||
useSceneStore.setState({ drawNodes: [rectangle("old")] });
|
||||
await useTabStore.getState().setActiveTab("2D");
|
||||
await useTabStore.getState().setActiveTab("APPS");
|
||||
useSceneStore.getState().RESET();
|
||||
useSceneStore.setState({ drawNodes: [rectangle("new")] });
|
||||
await useTabStore.getState().setActiveTab("2D");
|
||||
expect(useSceneStore.getState().drawNodes.map((node) => node.id)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("honors the latest rapid navigation request", async () => {
|
||||
await useTabStore.getState().setActiveTab("2D");
|
||||
const results = await Promise.all([
|
||||
useTabStore.getState().setActiveTab("IMAGE"),
|
||||
useTabStore.getState().setActiveTab("VIDEO"),
|
||||
]);
|
||||
expect(results).toEqual([true, true]);
|
||||
expect(useTabStore.getState().activeTabId).toBe("VIDEO");
|
||||
});
|
||||
|
||||
it("still exports a portable scene when explicitly requested", async () => {
|
||||
const image = new Node({
|
||||
id: "image", type: "image", x: 0, y: 0, width: 1, height: 1,
|
||||
fill: "transparent", imageFile: new File(["pixels"], "image.png", { type: "image/png" }),
|
||||
});
|
||||
useSceneStore.setState({ drawNodes: [image] });
|
||||
const exported = JSON.parse(await useSceneStore.getState().exportSceneAsJson());
|
||||
expect(exported.drawNodes[0].imageDataUrl).toBe("data:image/png;base64,cGl4ZWxz");
|
||||
});
|
||||
|
||||
function rectangle(id: string) {
|
||||
return new Node({ id, type: "rectangle", x: 0, y: 0, width: 100, height: 100, fill: "red" });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { useSceneStore } from "@storyteller/ui-pagedraw";
|
||||
|
||||
export type TabId =
|
||||
| "2D"
|
||||
@@ -41,14 +42,40 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
tabData: {},
|
||||
|
||||
setActiveTab: async (newTabId) => {
|
||||
// Don't do anything if we're already on this tab
|
||||
if (get().activeTabId === newTabId) return true;
|
||||
const currentTabId = get().activeTabId;
|
||||
|
||||
// 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;
|
||||
// Don't do anything if we're already on this tab
|
||||
if (currentTabId === newTabId) return true;
|
||||
|
||||
try {
|
||||
// Save current 2D state if we're leaving 2D tab
|
||||
if (currentTabId === "2D") {
|
||||
const sceneStore = useSceneStore.getState();
|
||||
const sceneState = await sceneStore.serializeSceneToString();
|
||||
set((state) => ({
|
||||
tabData: {
|
||||
...state.tabData,
|
||||
"2D": sceneState,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Load 2D state if we're entering 2D tab
|
||||
if (newTabId === "2D") {
|
||||
const savedState = get().tabData["2D"];
|
||||
if (savedState) {
|
||||
const sceneStore = useSceneStore.getState();
|
||||
sceneStore.loadSceneFromString(savedState);
|
||||
}
|
||||
}
|
||||
|
||||
// Update active tab
|
||||
set({ activeTabId: newTabId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error during tab change:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
updateTabData: (tabId, data) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useLoginModalStore } from "./useLoginModalStore";
|
||||
|
||||
// Webapp auth-showcase video (swap by passing `videoUrl`).
|
||||
const DEFAULT_SHOWCASE_VIDEO =
|
||||
"https://frontend-cdn.fakeyou.com/videos/knight-video.mp4";
|
||||
"https://player.vimeo.com/video/1169289718?background=1&autoplay=1&loop=1&muted=1";
|
||||
const LOGIN_SUCCESS_DURATION_MS = 3000;
|
||||
|
||||
interface LoginModalProps {
|
||||
@@ -20,7 +20,7 @@ interface LoginModalProps {
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
onArtCraftAuthSuccess?: (userInfo: any) => void;
|
||||
isSignUp?: boolean;
|
||||
/** Optional direct media URL for the right-pane showcase video. */
|
||||
/** Optional override for the right-pane showcase video. */
|
||||
videoUrl?: string;
|
||||
// Accepted for backwards compatibility with existing call sites (MainApp
|
||||
// passes these); no longer used now that the showcase is a single video.
|
||||
@@ -283,19 +283,24 @@ export function LoginModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Right-pane video showcase — muted background video cropped to cover the pane,
|
||||
// Right-pane video showcase — Vimeo background embed scaled to cover the pane,
|
||||
// with a legibility gradient + caption. Mirrors the webapp auth-showcase.
|
||||
function LoginShowcase({ videoUrl }: { videoUrl: string }) {
|
||||
return (
|
||||
<div className="absolute inset-2 overflow-hidden rounded-2xl bg-black">
|
||||
<video
|
||||
<div
|
||||
className="absolute inset-2 overflow-hidden rounded-2xl bg-black"
|
||||
style={{ containerType: "size" }}
|
||||
>
|
||||
<iframe
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
title="ArtCraft"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
width: "max(100cqw, calc(100cqh * 16 / 9))",
|
||||
height: "max(100cqh, calc(100cqw * 9 / 16))",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
Generated
-46
@@ -79,7 +79,6 @@
|
||||
"@nx/vite": "21.2.3",
|
||||
"@nx/web": "21.2.3",
|
||||
"@nx/workspace": "21.2.3",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@remix-run/node": "^2.16.5",
|
||||
"@swc-node/register": "~1.9.1",
|
||||
"@swc/core": "~1.5.7",
|
||||
@@ -5603,22 +5602,6 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -19994,35 +19977,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/portfinder": {
|
||||
"version": "1.0.38",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"name": "@frontend/source",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"perf:desktop": "node tools/performance/run.mjs"
|
||||
},
|
||||
"scripts": {},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -70,7 +68,6 @@
|
||||
"@nx/vite": "21.2.3",
|
||||
"@nx/web": "21.2.3",
|
||||
"@nx/workspace": "21.2.3",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@remix-run/node": "^2.16.5",
|
||||
"@swc-node/register": "~1.9.1",
|
||||
"@swc/core": "~1.5.7",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { relative } from "node:path";
|
||||
|
||||
// Benchmark-only instrumentation. The normal desktop build never uses this plugin.
|
||||
export function benchmarkPlugin({ baselineRef, frontend } = {}) {
|
||||
// Reproduce this experiment without checking out over local work. This is a
|
||||
// two-module overlay, not a build of an entire historical repository.
|
||||
const baseline = new Map();
|
||||
if (baselineRef) {
|
||||
for (const path of ["pages/MainApp.tsx", "pages/Stores/TabState.ts"]) {
|
||||
baseline.set(`/apps/artcraft/app/src/${path}`, execFileSync("git", ["show", `${baselineRef}:frontend/apps/artcraft/app/src/${path}`], { cwd: frontend, encoding: "utf8" }));
|
||||
}
|
||||
}
|
||||
const exports = new Map([
|
||||
["/pages/Stores/TabState.ts", ["tabs", "useTabStore"]],
|
||||
["/pagedraw/src/lib/stores/SceneState.ts", ["scene", "useSceneStore"]],
|
||||
["/pagedraw/src/lib/Node.ts", ["Node", "Node"]],
|
||||
]);
|
||||
return {
|
||||
name: "artcraft-performance-fixtures",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
const source = [...baseline].find(([suffix]) => id.endsWith(suffix));
|
||||
if (source) code = source[1];
|
||||
for (const [suffix, [key, value]] of exports) {
|
||||
if (id.endsWith(suffix)) {
|
||||
return `${code}\nwindow.__ARTCRAFT_BENCH__ ??= {};\nwindow.__ARTCRAFT_BENCH__.${key} = ${value};\n`;
|
||||
}
|
||||
}
|
||||
if (source) return code;
|
||||
},
|
||||
generateBundle: {
|
||||
order: "post",
|
||||
handler(_options, bundle) {
|
||||
const chunks = Object.values(bundle).filter((item) => item.type === "chunk");
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: "performance-bundle.json",
|
||||
source: JSON.stringify(chunks.map((chunk) => ({
|
||||
file: chunk.fileName,
|
||||
entry: chunk.isEntry,
|
||||
dynamicEntry: chunk.isDynamicEntry,
|
||||
bytes: Buffer.byteLength(chunk.code),
|
||||
imports: chunk.imports,
|
||||
largestModules: Object.entries(chunk.modules)
|
||||
.sort((a, b) => b[1].renderedLength - a[1].renderedLength)
|
||||
.slice(0, 25)
|
||||
.map(([id, module]) => ({ module: relative(frontend, id), renderedBytes: module.renderedLength })),
|
||||
})), null, 2),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// Runs before any application script. No real account, Rust IPC, or remote API.
|
||||
export function installBrowserFixture() {
|
||||
const counters = { commands: {}, fileReads: 0, webglContexts: 0, longTasks: [] };
|
||||
window.__ARTCRAFT_COUNTERS__ = counters;
|
||||
const getContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
|
||||
if (type === "webgl" || type === "webgl2" || type === "experimental-webgl") counters.webglContexts++;
|
||||
return getContext.call(this, type, ...args);
|
||||
};
|
||||
const readAsDataURL = FileReader.prototype.readAsDataURL;
|
||||
FileReader.prototype.readAsDataURL = function (...args) {
|
||||
counters.fileReads++;
|
||||
return readAsDataURL.apply(this, args);
|
||||
};
|
||||
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
|
||||
new PerformanceObserver((list) => {
|
||||
counters.longTasks.push(...list.getEntries().map(({ startTime, duration }) => ({ startTime, duration })));
|
||||
}).observe({ type: "longtask", buffered: true });
|
||||
}
|
||||
let callbackId = 0;
|
||||
const callbacks = new Map();
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } },
|
||||
transformCallback(callback) { callbacks.set(++callbackId, callback); return callbackId; },
|
||||
unregisterCallback(id) { callbacks.delete(id); },
|
||||
convertFileSrc(path) { return path; },
|
||||
async invoke(command, args) {
|
||||
counters.commands[command] = (counters.commands[command] ?? 0) + 1;
|
||||
if (command === "plugin:event|listen") return args.handler;
|
||||
if (command === "plugin:window|inner_size") return { width: 1440, height: 1000 };
|
||||
if (command === "plugin:window|scale_factor") return 1;
|
||||
if (command === "storyteller_get_login_session_command") return {
|
||||
username: "performance_fixture", user_token: "u_performance_fixture",
|
||||
};
|
||||
if (command === "get_task_queue_command") return { payload: { tasks: [] } };
|
||||
// Use the bundled model overlay, as the app does when the listing is unavailable.
|
||||
if (command === "list_image_models_command" || command === "list_video_models_command") throw new Error("Offline model listing fixture");
|
||||
if (command === "plugin:app|version") return "0.0.0-benchmark";
|
||||
if (command === "plugin:app|name") return "ArtCraft Benchmark";
|
||||
if (command === "plugin:os|platform") return "macos";
|
||||
if (command.startsWith("plugin:http|")) throw new Error("Network disabled in performance fixture");
|
||||
return { success: true, payload: {} };
|
||||
},
|
||||
};
|
||||
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} };
|
||||
}
|
||||
|
||||
export async function seedDrawingScene() {
|
||||
const { scene, Node } = window.__ARTCRAFT_BENCH__;
|
||||
scene.getState().RESET();
|
||||
scene.getState().setBaseImageInfo({ url: "", mediaToken: "fixture-blank", isBlankCanvas: true, blankCanvasWidth: 1024, blankCanvasHeight: 1024 });
|
||||
// Deterministic noisy pixels resist PNG compression, like imported artwork.
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1024;
|
||||
const context = canvas.getContext("2d");
|
||||
const pixels = context.createImageData(1024, 1024);
|
||||
let random = 12345;
|
||||
for (let i = 0; i < pixels.data.length; i += 4) {
|
||||
random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
|
||||
pixels.data[i] = random & 255;
|
||||
pixels.data[i + 1] = (random >>> 8) & 255;
|
||||
pixels.data[i + 2] = (random >>> 16) & 255;
|
||||
pixels.data[i + 3] = 255;
|
||||
}
|
||||
context.putImageData(pixels, 0, 0);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve));
|
||||
const drawNodes = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const node = new Node({ id: `fixture-image-${i}`, type: "image", x: i * 220, y: i * 100, width: 400, height: 400, fill: "transparent" });
|
||||
await node.setImageFromFile(new File([blob], `fixture-${i}.png`, { type: "image/png" }));
|
||||
drawNodes.push(node);
|
||||
}
|
||||
for (let i = 0; i < 80; i++) {
|
||||
drawNodes.push({
|
||||
id: `fixture-line-${i}`, type: "line", draggable: true, stroke: "#7766ee", strokeWidth: 3,
|
||||
points: Array.from({ length: 200 }, (_, j) => j % 2 === 0 ? j * 4 : i * 10 + Math.sin(j) * 30),
|
||||
});
|
||||
}
|
||||
scene.setState({ drawNodes, selectedNodeIds: [drawNodes[0].id] });
|
||||
scene.getState().saveState();
|
||||
return { images: 4, imageSize: 1024, imageBytesEach: blob.size, strokes: 80, coordinatesPerStroke: 200 };
|
||||
}
|
||||
|
||||
export async function measureTabChange(target) {
|
||||
const counters = window.__ARTCRAFT_COUNTERS__;
|
||||
const readsBefore = counters.fileReads;
|
||||
const start = performance.now();
|
||||
const success = await window.__ARTCRAFT_BENCH__.tabs.getState().setActiveTab(target);
|
||||
if (!success) throw new Error(`Tab change to ${target} failed`);
|
||||
const stateReadyMs = performance.now() - start;
|
||||
const ready = () => target === "2D"
|
||||
? !!document.querySelector(".konvajs-content canvas")
|
||||
: !!Array.from(document.querySelectorAll("h1")).find((h) => h.textContent.includes("craft"));
|
||||
while (!ready()) {
|
||||
if (performance.now() - start > 15000) throw new Error(`Tab ${target} never rendered`);
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
// A rendering opportunity after the target DOM committed, not GPU completion.
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const paintMs = performance.now() - start;
|
||||
performance.measure(`artcraft:tab:${target}`, { start, end: performance.now() });
|
||||
return { stateReadyMs, paintMs, fileReads: counters.fileReads - readsBefore };
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const [beforePath, afterPath] = process.argv.slice(2);
|
||||
if (!beforePath || !afterPath) throw new Error("Usage: node tools/performance/compare.mjs before.json after.json");
|
||||
const before = JSON.parse(await readFile(beforePath, "utf8"));
|
||||
const after = JSON.parse(await readFile(afterPath, "utf8"));
|
||||
for (const field of ["platform", "arch", "cpu", "browser", "viewport", "ipc", "network", "runs"]) {
|
||||
if (before.environment[field] !== after.environment[field]) throw new Error(`Different ${field}; do not compare these runs`);
|
||||
}
|
||||
if (before.errors.length || after.errors.length) throw new Error("A run contains browser errors");
|
||||
if (JSON.stringify(before.samples[0].fixture) !== JSON.stringify(after.samples[0].fixture)) throw new Error("Different scene fixtures");
|
||||
|
||||
const rows = [["Measurement", "Before", "After", "Reduction"]];
|
||||
for (const [label, event, metric, unit, scale] of [
|
||||
["Startup to frame", "startup", "readyMs", "ms", 1],
|
||||
["Refresh to frame", "refresh", "readyMs", "ms", 1],
|
||||
["Startup JavaScript", "startup", "jsBytes", "MB", 1e6],
|
||||
["Startup long tasks (sum)", "startup", "longTaskMs", "ms", 1],
|
||||
["First drawing page to frame", "firstDraw", "paintMs", "ms", 1],
|
||||
["Drawing to home: dispatch", "leaveDraw", "stateReadyMs", "ms", 1],
|
||||
["Drawing to home: frame", "leaveDraw", "paintMs", "ms", 1],
|
||||
["Home to drawing: dispatch", "returnDraw", "stateReadyMs", "ms", 1],
|
||||
["Home to drawing: frame", "returnDraw", "paintMs", "ms", 1],
|
||||
]) {
|
||||
const a = before.summary[event][metric].median;
|
||||
const b = after.summary[event][metric].median;
|
||||
rows.push([label, `${(a / scale).toFixed(2)} ${unit}`, `${(b / scale).toFixed(2)} ${unit}`, `${((a - b) / a * 100).toFixed(1)}%`]);
|
||||
}
|
||||
const widths = rows[0].map((_, col) => Math.max(...rows.map((row) => row[col].length)));
|
||||
const format = (row) => `| ${row.map((cell, col) => cell.padEnd(widths[col])).join(" | ")} |`;
|
||||
console.log(format(rows[0]));
|
||||
console.log(`|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`);
|
||||
for (const row of rows.slice(1)) console.log(format(row));
|
||||
@@ -1,125 +0,0 @@
|
||||
import { build, preview } from "vite";
|
||||
import { chromium } from "@playwright/test";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parseArgs } from "node:util";
|
||||
import { benchmarkPlugin } from "./benchmark-plugin.mjs";
|
||||
import { installBrowserFixture, seedDrawingScene, measureTabChange } from "./browser-fixture.mjs";
|
||||
|
||||
const frontend = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const { values } = parseArgs({ options: {
|
||||
output: { type: "string", default: process.env.ARTCRAFT_PERF_DIR || "/tmp/artcraft-performance" },
|
||||
runs: { type: "string", default: process.env.ARTCRAFT_PERF_RUNS || "7" },
|
||||
"skip-build": { type: "boolean", default: !!process.env.ARTCRAFT_PERF_SKIP_BUILD },
|
||||
"build-only": { type: "boolean", default: false },
|
||||
channel: { type: "string", default: "chrome" },
|
||||
"baseline-ref": { type: "string" },
|
||||
"smoke-imports": { type: "boolean", default: false },
|
||||
} });
|
||||
const output = resolve(values.output);
|
||||
const runs = Number(values.runs);
|
||||
if (!Number.isInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer");
|
||||
const configFile = resolve(frontend, "apps/artcraft/vite.config.ts");
|
||||
// Match `npm run build` in the app: Tailwind resolves its config/content from cwd.
|
||||
process.chdir(dirname(configFile));
|
||||
await mkdir(output, { recursive: true });
|
||||
if (!values["skip-build"]) {
|
||||
await build({ configFile, plugins: [benchmarkPlugin({ baselineRef: values["baseline-ref"], frontend })], build: { outDir: resolve(output, "dist"), manifest: true } });
|
||||
await writeFile(resolve(output, "build-info.json"), JSON.stringify({
|
||||
builtAt: new Date().toISOString(),
|
||||
revision: execFileSync("git", ["rev-parse", "HEAD"], { cwd: frontend, encoding: "utf8" }).trim(),
|
||||
baselineOverlay: values["baseline-ref"] || null,
|
||||
changedFiles: execFileSync("git", ["diff", "--name-only"], { cwd: frontend, encoding: "utf8" }).trim().split("\n"),
|
||||
}, null, 2));
|
||||
}
|
||||
if (values["build-only"]) process.exit(0);
|
||||
const server = await preview({ configFile, build: { outDir: resolve(output, "dist") }, preview: { host: "127.0.0.1", port: 0, strictPort: false } });
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ channel: values.channel, headless: true });
|
||||
const url = server.resolvedUrls.local[0];
|
||||
const samples = [];
|
||||
const errors = [];
|
||||
const importedPages = [];
|
||||
for (let run = 0; run < runs; run++) {
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
|
||||
await context.addInitScript(installBrowserFixture);
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => { errors.push(error.message); console.error("Browser error:", error.message); });
|
||||
// No analytics, external media, or authenticated API traffic in this fixture.
|
||||
await context.route("**/*", (route) => route.request().url().startsWith(url) || route.request().url().startsWith("blob:")
|
||||
? route.continue() : route.abort());
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send("Network.enable");
|
||||
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("heading", { name: "What will you craft today?" }).waitFor();
|
||||
await page.waitForFunction(() => !document.querySelector("video"));
|
||||
if (!await page.evaluate(() => getComputedStyle(document.querySelector(".fixed")).position === "fixed")) {
|
||||
throw new Error("Tailwind styles are missing; benchmark layout is invalid");
|
||||
}
|
||||
const collectStartup = async () => page.evaluate(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
return {
|
||||
readyMs: performance.now(),
|
||||
jsBytes: performance.getEntriesByType("resource").filter((r) => r.name.endsWith(".js")).reduce((sum, r) => sum + r.decodedBodySize, 0),
|
||||
longTaskMs: window.__ARTCRAFT_COUNTERS__.longTasks.reduce((sum, t) => sum + t.duration, 0),
|
||||
webglContexts: window.__ARTCRAFT_COUNTERS__.webglContexts,
|
||||
};
|
||||
});
|
||||
const startup = await collectStartup();
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("heading", { name: "What will you craft today?" }).waitFor();
|
||||
await page.waitForFunction(() => !document.querySelector("video"));
|
||||
const refresh = await collectStartup();
|
||||
const fixture = await page.evaluate(seedDrawingScene);
|
||||
const firstDraw = await page.evaluate(measureTabChange, "2D");
|
||||
// Allow image decoding and the canvas bake debounce to settle outside timing.
|
||||
await page.waitForTimeout(1000);
|
||||
const leaveDraw = await page.evaluate(measureTabChange, "APPS");
|
||||
const returnDraw = await page.evaluate(measureTabChange, "2D");
|
||||
await page.waitForTimeout(1000);
|
||||
const afterReturn = await page.evaluate(() => ({
|
||||
nodeCount: window.__ARTCRAFT_BENCH__.scene.getState().drawNodes.length,
|
||||
selectedCount: window.__ARTCRAFT_BENCH__.scene.getState().selectedNodeIds.length,
|
||||
historyLength: window.__ARTCRAFT_BENCH__.scene.getState().history.length,
|
||||
commands: window.__ARTCRAFT_COUNTERS__.commands,
|
||||
}));
|
||||
if (run === 0) await page.screenshot({ path: resolve(output, "drawing.png") });
|
||||
samples.push({ startup, refresh, firstDraw, leaveDraw, returnDraw, fixture, afterReturn });
|
||||
console.log(JSON.stringify({ run: run + 1, startup, refresh, firstDraw, leaveDraw, returnDraw }));
|
||||
// Validate deferred module evaluation separately from all timed operations.
|
||||
if (values["smoke-imports"] && run === runs - 1) {
|
||||
const manifest = JSON.parse(await readFile(resolve(output, "dist/.vite/manifest.json"), "utf8"));
|
||||
for (const [source, chunk] of Object.entries(manifest)) {
|
||||
if (source.startsWith("src/pages/") && chunk.isDynamicEntry) {
|
||||
await page.evaluate(async (file) => { await import(/* @vite-ignore */ `/${file}`); }, chunk.file);
|
||||
importedPages.push(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
const summary = {};
|
||||
for (const event of ["startup", "refresh", "firstDraw", "leaveDraw", "returnDraw"]) {
|
||||
summary[event] = {};
|
||||
for (const metric of Object.keys(samples[0][event])) {
|
||||
const values = samples.map((s) => s[event][metric]).sort((a, b) => a - b);
|
||||
summary[event][metric] = { median: values[Math.floor(values.length / 2)], min: values[0], max: values.at(-1) };
|
||||
}
|
||||
}
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(), build: JSON.parse(await readFile(resolve(output, "build-info.json"), "utf8")),
|
||||
environment: { platform: os.platform(), arch: os.arch(), cpu: os.cpus()[0].model, browser: browser.version(), node: process.version, runs, viewport: "1440x1000@1", ipc: "mocked", network: "local assets only, browser cache disabled" },
|
||||
summary, samples, importedPages, errors: [...new Set(errors)],
|
||||
};
|
||||
await writeFile(resolve(output, "results.json"), `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ summary, errors: result.errors, output }, null, 2));
|
||||
if (errors.length) throw new Error("Browser errors invalidate this run; see results.json");
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve, reject) => server.httpServer.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# ArtCraft Studio Linux releases
|
||||
|
||||
The Linux workflow builds every package before the release job signs and uploads them to the
|
||||
existing `artcraft-v<version>` draft, retaining Windows and macOS assets. The version comes from
|
||||
`crates/desktop/artcraft/tauri.conf.json`.
|
||||
|
||||
| Architecture | Outputs | Builder |
|
||||
|--------------|---------------------------------------------------------------|----------------------------|
|
||||
| x86_64 | AppImage, DEB, RPM, tar.gz, Arch pkg.tar.zst, Flatpak bundle, OCI | Ubuntu 22.04 native |
|
||||
| aarch64 | AppImage, DEB, RPM, tar.gz, Arch pkg.tar.zst, Flatpak bundle, OCI | Ubuntu 22.04 ARM native |
|
||||
| riscv64 | DEB, RPM, tar.gz | Ubuntu 24.04 cross compiler |
|
||||
|
||||
Native AppImages also include zsync delta-update metadata.
|
||||
|
||||
Flatpak uses native Ubuntu 24.04 runners and GNOME 51 for GTK3/WebKitGTK 4.1. Its build checks
|
||||
for missing ELF dependencies inside the sandbox. RISC-V packages require Ubuntu 24.04-era
|
||||
libraries; x86_64 and ARM64 use Ubuntu 22.04 as their build baseline. The suite does not publish
|
||||
RISC-V AppImages, Arch packages or Flatpaks.
|
||||
|
||||
`package.sh <architecture> <rust-target>` normalizes Tauri's DEB/RPM/AppImage filenames and
|
||||
extracts the DEB's `/usr` tree into the suite's `bin/` and `share/` tarball layout. Shared Craft
|
||||
Arch packaging consumes that tarball, with WebKitGTK, GTK3 and OpenSSL dependencies added.
|
||||
`flatpak.sh` consumes the same tarball, builds the bundle, and exports the OCI layout.
|
||||
|
||||
Release runs import `LINUX_SIGNING_KEY` for Flatpak commit signatures and OCI publishing. The final shared
|
||||
`draft-release` action signs RPMs, DEBs, Arch packages, AppImages, tarballs, OCI manifests and
|
||||
checksums. Only a stable version on `release` or `artcraft-release` moves the OCI stable architecture tags.
|
||||
Like the macOS and Windows release workflows, Linux release packaging runs on release branches
|
||||
(`release`, `artcraft-release`, `artcraft-linux`) and workflow dispatch rather than on pull requests.
|
||||
|
||||
APT, RPM and pacman repositories are generated by `artcraft-services` from the release assets
|
||||
and checksums. Its release catalog must be updated only after the signed release is published.
|
||||
Installing a DEB manually is separate from publishing that DEB in the website's APT repository.
|
||||
|
||||
A successful build proves artifact production and metadata/dependency checks. It does not replace
|
||||
installation and interactive testing on physical RISC-V, ARM64 and x86_64 desktops.
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>ai.artcraft.app</id>
|
||||
<name>ArtCraft Studio</name>
|
||||
<summary>Create AI images and video</summary>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT OR Apache-2.0</project_license>
|
||||
<description><p>ArtCraft Studio is a desktop workspace for generating AI images and video with connected AI providers.</p></description>
|
||||
<launchable type="desktop-id">ai.artcraft.app.desktop</launchable>
|
||||
<url type="homepage">https://getartcraft.com</url>
|
||||
<developer id="ai.artcraft"><name>ArtCraft</name></developer>
|
||||
<categories><category>Graphics</category><category>AudioVideo</category></categories>
|
||||
<provides><binary>artcraft</binary></provides>
|
||||
</component>
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
arch="$(uname -m)"
|
||||
version="$(jq -r .version crates/desktop/artcraft/tauri.conf.json)"
|
||||
base="artcraft-$version-linux-$arch"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/stage" dist/release
|
||||
tar -xzf "dist/linux/$base.tar.gz" --strip-components=1 -C "$work/stage"
|
||||
cp packaging/linux/flatpak.yml "$work/manifest.yml"
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
sign=()
|
||||
keys=()
|
||||
if [[ -n "${SIGNING_FPR:-}" ]]; then
|
||||
sign=(--gpg-sign="$SIGNING_FPR" --gpg-homedir="$GNUPGHOME")
|
||||
gpg --batch --armor --export "$SIGNING_FPR" > "$work/public.asc"
|
||||
keys=(--gpg-keys="$work/public.asc")
|
||||
fi
|
||||
flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \
|
||||
--default-branch=stable --repo="$work/repo" "${sign[@]}" "$work/build" "$work/manifest.yml"
|
||||
# Fail on missing WebKitGTK or other runtime libraries rather than shipping an unloadable binary.
|
||||
# shellcheck disable=SC2016 # Expand these variables inside the Flatpak runtime shell.
|
||||
flatpak build --runtime "$work/build" sh -c '
|
||||
dependencies=$(ldd /app/bin/artcraft) || exit 1
|
||||
printf "%s\n" "$dependencies"
|
||||
case "$dependencies" in *"not found"*) exit 1 ;; esac
|
||||
'
|
||||
flatpak build-bundle --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo \
|
||||
"${keys[@]}" "$work/repo" "dist/release/$base.flatpak" ai.artcraft.app stable
|
||||
flatpak build-bundle --oci "$work/repo" "dist/release/$base-oci" ai.artcraft.app stable
|
||||
skopeo inspect "oci:dist/release/$base-oci" >/dev/null
|
||||
if [[ -n "${SIGNING_FPR:-}" ]]; then
|
||||
registry="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
creds="$GITHUB_ACTOR:$GITHUB_TOKEN"
|
||||
skopeo copy --format oci --dest-creds "$creds" "oci:dist/release/$base-oci" "docker://$registry:$version-$arch"
|
||||
if [[ "$version" != *-* && ( "$GITHUB_REF" == refs/heads/release || "$GITHUB_REF" == refs/heads/artcraft-release ) ]]; then
|
||||
skopeo copy --format oci --dest-creds "$creds" "oci:dist/release/$base-oci" "docker://$registry:stable-$arch"
|
||||
fi
|
||||
skopeo inspect --raw --creds "$creds" "docker://$registry:$version-$arch" >"dist/release/$base.oci-manifest.json"
|
||||
fi
|
||||
@@ -0,0 +1,25 @@
|
||||
app-id: ai.artcraft.app
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '51'
|
||||
sdk: org.gnome.Sdk
|
||||
command: artcraft
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --socket=pulseaudio
|
||||
- --share=ipc
|
||||
- --share=network
|
||||
- --device=dri
|
||||
- --filesystem=xdg-pictures
|
||||
- --filesystem=xdg-videos
|
||||
- --filesystem=xdg-download
|
||||
modules:
|
||||
- name: artcraft
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- cp -a . /app/
|
||||
- install -Dm644 /app/share/icons/hicolor/128x128/apps/artcraft.png /app/share/icons/hicolor/128x128/apps/ai.artcraft.app.png
|
||||
- sed -i 's/^Icon=.*/Icon=ai.artcraft.app/' /app/share/applications/ai.artcraft.app.desktop
|
||||
sources:
|
||||
- type: dir
|
||||
path: stage
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integration fixture: real DEB/RPM archives, with no Rust compilation or signing key.
|
||||
set -euo pipefail
|
||||
script="$(realpath "$(dirname "$0")/package.sh")"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
cd "$work"
|
||||
mkdir -p crates/desktop/artcraft target/x86_64-unknown-linux-gnu/release/bundle/{deb,rpm,appimage}
|
||||
printf '{"version":"0.42.0"}\n' > crates/desktop/artcraft/tauri.conf.json
|
||||
mkdir -p stage/{DEBIAN,usr/bin,usr/share/applications}
|
||||
cp /bin/true stage/usr/bin/artcraft
|
||||
printf '[Desktop Entry]\nType=Application\nName=ArtCraft\nExec=artcraft\n' > stage/usr/share/applications/ArtCraft.desktop
|
||||
cat > stage/DEBIAN/control <<'CONTROL'
|
||||
Package: artcraft
|
||||
Version: 0.42.0
|
||||
Architecture: amd64
|
||||
Maintainer: Fixture <fixture@example.invalid>
|
||||
Description: Packaging fixture
|
||||
CONTROL
|
||||
dpkg-deb --build stage target/x86_64-unknown-linux-gnu/release/bundle/deb/ArtCraft.deb
|
||||
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
|
||||
cat > rpmbuild/SPECS/artcraft.spec <<'SPEC'
|
||||
Name: artcraft
|
||||
Version: 0.42.0
|
||||
Release: 1
|
||||
Summary: Packaging fixture
|
||||
License: MIT
|
||||
%description
|
||||
Packaging fixture
|
||||
%install
|
||||
mkdir -p %{buildroot}/usr/bin
|
||||
cp /bin/true %{buildroot}/usr/bin/artcraft
|
||||
%files
|
||||
/usr/bin/artcraft
|
||||
SPEC
|
||||
rpmbuild --define "_topdir $work/rpmbuild" -bb rpmbuild/SPECS/artcraft.spec
|
||||
find rpmbuild/RPMS -name '*.rpm' -exec cp {} target/x86_64-unknown-linux-gnu/release/bundle/rpm/ \;
|
||||
printf 'appimage fixture\n' > target/x86_64-unknown-linux-gnu/release/bundle/appimage/ArtCraft.AppImage
|
||||
"$script" x86_64 x86_64-unknown-linux-gnu
|
||||
for format in deb rpm AppImage AppImage.zsync tar.gz; do test -s "dist/release/artcraft-0.42.0-linux-x86_64.$format"; done
|
||||
mkdir unpacked
|
||||
tar -xzf dist/release/artcraft-0.42.0-linux-x86_64.tar.gz -C unpacked
|
||||
test -x unpacked/artcraft-0.42.0-linux-x86_64/bin/artcraft
|
||||
test -f unpacked/artcraft-0.42.0-linux-x86_64/share/applications/ai.artcraft.app.desktop
|
||||
rm target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
if "$script" x86_64 x86_64-unknown-linux-gnu; then
|
||||
echo 'Packaging accepted a missing RPM' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo 'Package integration checks passed'
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Normalize Tauri packages and derive the suite's bin/share tarball from its Debian package.
|
||||
set -euo pipefail
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
arch="${1:?architecture required}"
|
||||
target="${2:?Rust target required}"
|
||||
version="$(jq -r .version crates/desktop/artcraft/tauri.conf.json)"
|
||||
case "$arch" in x86_64|aarch64|riscv64) ;; *) exit 2 ;; esac
|
||||
base="artcraft-$version-linux-$arch"
|
||||
bundle="target/$target/release/bundle"
|
||||
mkdir -p dist/release
|
||||
for format in deb rpm AppImage; do
|
||||
mapfile -t files < <(find "$bundle" -type f -name "*.$format")
|
||||
if [[ "$arch" == riscv64 && "$format" == AppImage ]]; then continue; fi
|
||||
[[ ${#files[@]} == 1 ]] || { echo "Expected one $format in $bundle, found ${#files[@]}" >&2; exit 1; }
|
||||
cp "${files[0]}" "dist/release/$base.$format"
|
||||
done
|
||||
if [[ "$arch" != riscv64 ]]; then
|
||||
zsyncmake -u "https://github.com/storytold/artcraft/releases/download/artcraft-v$version/$base.AppImage" \
|
||||
-o "dist/release/$base.AppImage.zsync" "dist/release/$base.AppImage"
|
||||
fi
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
dpkg-deb -x "dist/release/$base.deb" "$work/deb"
|
||||
mkdir -p "$work/$base"
|
||||
cp -a "$work/deb/usr/." "$work/$base/"
|
||||
mapfile -t desktops < <(find "$work/$base/share/applications" -name '*.desktop')
|
||||
[[ ${#desktops[@]} == 1 ]] || { echo 'Expected one desktop entry' >&2; exit 1; }
|
||||
if [[ "${desktops[0]}" != "$work/$base/share/applications/ai.artcraft.app.desktop" ]]; then
|
||||
mv "${desktops[0]}" "$work/$base/share/applications/ai.artcraft.app.desktop"
|
||||
fi
|
||||
install -Dm644 "$script_dir/ai.artcraft.app.metainfo.xml" "$work/$base/share/metainfo/ai.artcraft.app.metainfo.xml"
|
||||
sed -i 's/^Name=.*/Name=ArtCraft Studio/' "$work/$base/share/applications/ai.artcraft.app.desktop"
|
||||
test -x "$work/$base/bin/artcraft"
|
||||
case "$arch" in
|
||||
x86_64) machine='Advanced Micro Devices X86-64' ;;
|
||||
aarch64) machine='AArch64' ;;
|
||||
riscv64) machine='RISC-V' ;;
|
||||
esac
|
||||
readelf -h "$work/$base/bin/artcraft" | grep -F "$machine"
|
||||
if [[ "$arch" == "$(uname -m)" ]]; then
|
||||
dependencies="$(ldd "$work/$base/bin/artcraft")"
|
||||
if grep -q 'not found' <<< "$dependencies"; then
|
||||
printf '%s\n' "$dependencies" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# The tarball layout is consumed by the launcher, Arch packaging and Flatpak.
|
||||
tar -czf "dist/release/$base.tar.gz" -C "$work" "$base"
|
||||
case "$arch" in x86_64) deb_arch=amd64 ;; aarch64) deb_arch=arm64 ;; riscv64) deb_arch=riscv64 ;; esac
|
||||
[[ "$(dpkg-deb -f "dist/release/$base.deb" Architecture)" == "$deb_arch" ]]
|
||||
[[ "$(rpm -qp --qf '%{ARCH}' "dist/release/$base.rpm")" == "$arch" ]]
|
||||
dpkg-deb --info "dist/release/$base.deb"
|
||||
rpm -qp --requires "dist/release/$base.rpm"
|
||||
@@ -0,0 +1,7 @@
|
||||
# boring-sys2 has built-in ARM toolchains but no RISC-V toolchain. Set the target
|
||||
# explicitly so BoringSSL does not select the x86_64 runner's assembly sources.
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR riscv64)
|
||||
set(CMAKE_C_COMPILER riscv64-linux-gnu-gcc)
|
||||
set(CMAKE_CXX_COMPILER riscv64-linux-gnu-g++)
|
||||
set(OPENSSL_NO_ASM ON CACHE BOOL "Use BoringSSL's portable RISC-V implementation")
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"bundle": {
|
||||
"category": "GraphicsAndDesign",
|
||||
"shortDescription": "AI image and video creation",
|
||||
"longDescription": "ArtCraft Studio is a desktop workspace for generating AI images and video.",
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Windows Packaging
|
||||
|
||||
ArtCraft Studio Windows release packaging assets and configuration.
|
||||
Reference in New Issue
Block a user