Compare commits

..
29 changed files with 613 additions and 1885 deletions
@@ -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 }}
+10 -8
View File
@@ -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
+24
View File
@@ -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
+23
View File
@@ -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
+29 -14
View File
@@ -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");
}
-219
View File
@@ -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.
-527
View File
@@ -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": []
}
-510
View File
@@ -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": []
}
-80
View File
@@ -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
-46
View File
@@ -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",
+1 -4
View File
@@ -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 };
}
-33
View File
@@ -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));
-125
View File
@@ -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()));
}
+36
View File
@@ -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>
+40
View File
@@ -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
+25
View File
@@ -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
+50
View File
@@ -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'
+54
View File
@@ -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"
+7
View File
@@ -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")
+15
View File
@@ -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
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
# Windows Packaging
ArtCraft Studio Windows release packaging assets and configuration.