Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 218fd97a2e docs: add CodeRabbit badge to README 2026-11-09 10:09:02 -05:00
234 changed files with 1477 additions and 2313 deletions
+4 -24
View File
@@ -13,10 +13,6 @@ jobs:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
@@ -38,14 +34,10 @@ jobs:
TAURI_FRONTEND_PATH: ${{ github.workspace }}/frontend
TAURI_APP_PATH: ${{ github.workspace }}/crates/desktop/artcraft
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libclang-dev libasound2-dev libxdo-dev libdbus-1-dev build-essential clang file jq perl cmake nasm rpm zsync gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav xdg-utils
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
@@ -76,11 +68,11 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: tauri-linux-${{ matrix.arch }}
cache-on-failure: true
@@ -88,7 +80,7 @@ jobs:
working-directory: frontend
- run: ./node_modules/.bin/nx run artcraft:build
working-directory: frontend
- uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
- uses: 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
@@ -110,10 +102,6 @@ jobs:
matrix:
arch: [x86_64, aarch64]
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
@@ -157,10 +145,6 @@ jobs:
- {arch: x86_64, runner: ubuntu-24.04}
- {arch: aarch64, runner: ubuntu-24.04-arm}
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
@@ -193,10 +177,6 @@ jobs:
permissions:
contents: write
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- name: Read version
id: version
+6 -19
View File
@@ -10,13 +10,6 @@ on:
# This workflow creates Universal macOS builds with signing certificate support.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
# One release build per branch at a time. A run in progress is never cancelled, because it may
# already have created or uploaded to the draft release. If more pushes arrive while one runs,
# GitHub keeps only the newest as pending, so the next build is of the latest commit.
concurrency:
group: macos-release-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-artcraft:
permissions:
@@ -35,13 +28,7 @@ jobs:
# args: ''
runs-on: ${{ matrix.platform }}
# Successful builds took 10-22 min (2026-04 to 2026-11).
timeout-minutes: 60
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- name: setup node
@@ -50,9 +37,8 @@ jobs:
node-version: lts/*
- name: install Rust stable
uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
@@ -77,7 +63,7 @@ jobs:
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci --ignore-scripts
npm ci
echo "=== Version Information ==="
echo "Node version:"
@@ -210,8 +196,9 @@ jobs:
# Increase Node.js memory limit for the build
export NODE_OPTIONS="--max-old-space-size=8192"
echo "Building with nx..."
./node_modules/.bin/nx run artcraft:build
# Use npx to ensure we use the local nx version
echo "Building with npx nx..."
npx nx run artcraft:build
echo "Checking build output..."
# shellcheck disable=SC2012
@@ -230,7 +217,7 @@ jobs:
- name: build and publish
uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
+3 -18
View File
@@ -10,13 +10,6 @@ on:
# This workflow creates Windows builds.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
# One release build per branch at a time. A run in progress is never cancelled, because it may
# already have created or uploaded to the draft release. If more pushes arrive while one runs,
# GitHub keeps only the newest as pending, so the next build is of the latest commit.
concurrency:
group: windows-release-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-artcraft-windows:
permissions:
@@ -30,19 +23,11 @@ jobs:
args: ''
runs-on: ${{ matrix.platform }}
# Successful builds took 17-36 min (2026-04 to 2026-11); one hung run used the full 6-hour default.
timeout-minutes: 90
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7
- name: install Rust stable
uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
with:
toolchain: stable
uses: dtolnay/rust-toolchain@stable
- name: install wreq dependencies
run: |
@@ -67,7 +52,7 @@ jobs:
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci --ignore-scripts
npm ci
echo "=== Version Information ==="
echo "Node version:"
@@ -107,7 +92,7 @@ jobs:
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
- name: build and publish
uses: step-security/tauri-action@dc662d498edbd35ec8ed564b45d44e955444d1bc # v1.0.0
uses: tauri-apps/tauri-action@v0
env:
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
RUSTFLAGS: "-C target-feature=+crt-static"
Generated
+68 -175
View File
@@ -304,7 +304,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -383,7 +383,6 @@ dependencies = [
"chrono",
"cookie_store 0.0.1",
"cookie_store_wrapper",
"craft-update",
"directories",
"dirs",
"discord-rich-presence",
@@ -554,6 +553,28 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "assertor"
version = "0.0.3"
@@ -617,6 +638,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -646,6 +678,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -839,12 +882,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.6.0"
@@ -1431,9 +1468,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.45"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -1775,31 +1812,6 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2a24c10817173d26d0ea4689acbbade36f91b5dc#2a24c10817173d26d0ea4689acbbade36f91b5dc"
dependencies = [
"chrono",
"dunce",
"flate2",
"getrandom 0.3.1",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
"sha2 0.10.8",
"tar",
"tempfile",
"ureq",
"verifysign",
"winreg 0.55.0",
"winsafe",
"zip",
]
[[package]]
name = "crc"
version = "3.0.1"
@@ -1817,9 +1829,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.5"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
@@ -2124,7 +2136,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2496,7 +2508,7 @@ dependencies = [
"rustc_version",
"toml 0.8.23",
"vswhom",
"winreg 0.52.0",
"winreg",
]
[[package]]
@@ -2686,7 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2850,16 +2862,6 @@ dependencies = [
"walkdir",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -4766,7 +4768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@@ -5115,7 +5117,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6270,6 +6272,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "polycool"
version = "0.4.0"
@@ -6980,6 +6988,7 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"glib-sys",
@@ -6991,7 +7000,9 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -7161,7 +7172,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7181,7 +7192,6 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -7232,33 +7242,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls 0.23.36",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.9",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.60.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@@ -8092,7 +8075,6 @@ dependencies = [
"sqlx",
"tempfile",
"tokens",
"tokio",
]
[[package]]
@@ -8720,16 +8702,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
]
[[package]]
name = "target-lexicon"
version = "0.12.14"
@@ -9114,7 +9086,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9624,7 +9596,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9688,12 +9660,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "typed-path"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
[[package]]
name = "typeid"
version = "1.0.2"
@@ -9723,7 +9689,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9875,35 +9841,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls 0.23.36",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -9993,12 +9930,6 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -10114,16 +10045,6 @@ dependencies = [
"vello_common",
]
[[package]]
name = "verifysign"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6efd25aadc22c756a9012267fae0b130278d335d6ceb512ec02ce82029b5ca9"
dependencies = [
"core-foundation 0.10.1",
"windows-sys 0.61.2",
]
[[package]]
name = "version-compare"
version = "0.2.0"
@@ -11459,22 +11380,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
]
[[package]]
name = "winsafe"
version = "0.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -12037,19 +11942,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711e8c9646791c49b7eb799a19c4ae70a9b8372ddc6a1fa4778defd1e2aa5d5e"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.13.0",
"memchr",
"typed-path",
]
[[package]]
name = "zmij"
version = "1.0.21"
@@ -12117,6 +12009,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.0",
"zcheapstr",
"zvariant_derive",
@@ -33,7 +33,6 @@ chrono.workspace = true
cookie.workspace = true
log.workspace = true
reqwest = { workspace = true, features = ["multipart", "gzip"] }
tokio = { workspace = true, features = ["fs"] }
url.workspace = true
# Serialization
@@ -60,7 +60,7 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_bytes = tokio::fs::read(args.path.as_ref()).await
let file_bytes = std::fs::read(args.path.as_ref())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
@@ -69,7 +69,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_bytes = tokio::fs::read(args.path.as_ref()).await
let file_bytes = std::fs::read(args.path.as_ref())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
@@ -35,7 +35,7 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
let client = storyteller_client_builder(api_host)
.build()?;
let file_bytes = tokio::fs::read(path.as_ref()).await?;
let file_bytes = std::fs::read(path.as_ref())?;
let file_name = path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
@@ -61,7 +61,7 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
let client = storyteller_client_builder(args.api_host)
.build()?;
let file_bytes = tokio::fs::read(args.path.as_ref()).await?;
let file_bytes = std::fs::read(args.path.as_ref())?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
@@ -1,3 +1,5 @@
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use log::{info, warn};
@@ -66,7 +68,13 @@ pub async fn download_media_file<P: AsRef<Path>>(
let bytes = download_bytes(cdn_url).await?;
// 4. Write to disk.
tokio::fs::write(&output_path, &bytes).await
let mut file = fs::File::create(&output_path)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.write_all(&bytes)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.flush()
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
info!("Downloaded {} bytes to {:?}", bytes.len(), output_path);
@@ -31,7 +31,7 @@ reqwest.workspace = true
serde.workspace = true
wreq.workspace = true
serde_derive.workspace = true
tokio = { workspace = true, features = ["fs"] }
tokio.workspace = true
uuid.workspace = true
[dev-dependencies]
@@ -24,7 +24,7 @@ pub(crate) async fn upload_to_kinovi_web(
.unwrap_or_else(|| "jpg".to_string());
let file_bytes = match maybe_local_path {
Some(local_path) => match tokio::fs::read(local_path).await {
Some(local_path) => match std::fs::read(local_path) {
Ok(bytes) => {
info!("Reusing predownloaded file for kinovi upload: {}", source_url);
bytes
@@ -93,7 +93,7 @@ impl MidjourneyCredentialManager {
let _guard = self.mutation.lock().await;
// Remove every format, so a later startup cannot migrate a logged-out account.
for path in self.legacy_paths.iter().chain([&self.credential_path]) {
match tokio::fs::remove_file(path).await {
match std::fs::remove_file(path) {
Ok(()) => {},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {},
Err(err) => return Err(err.into()),
@@ -21,6 +21,10 @@ impl TaskId {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_string(&self) -> String {
self.0.to_string()
}
}
impl Display for TaskId {
@@ -11,7 +11,7 @@ use errors::AnyhowResult;
use log::{debug, error, warn};
use serde_derive::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::path::Path;
use std::{fs::File, io::Write, path::Path};
use url::Url;
use wreq::Client;
@@ -271,7 +271,9 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> {
let dir = Path::new(dir);
tokio::fs::create_dir_all(dir).await?;
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
for generation in generations {
let response = wreq::get(generation.url.clone()).send().await?;
let image_bytes = response.bytes().await?;
@@ -280,13 +282,15 @@ pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> A
let ext = url.path().split(".").last().unwrap_or("png");
let path = Path::new(dir).join(format!("{}.{}", generation.id, ext));
tokio::fs::write(path, &image_bytes).await?;
let mut file = File::create(path)?;
file.write_all(&image_bytes)?;
let thumbnail_url = generation.encodings.thumbnail.path.clone();
let thumbnail_response = wreq::get(thumbnail_url).send().await?;
let thumbnail_bytes = thumbnail_response.bytes().await?;
let thumbnail_path = Path::new(dir).join(format!("{}_thumbnail.{}", generation.id, ext));
tokio::fs::write(thumbnail_path, &thumbnail_bytes).await?;
let mut thumbnail_file = File::create(thumbnail_path)?;
thumbnail_file.write_all(&thumbnail_bytes)?;
}
Ok(())
@@ -141,8 +141,8 @@ mod tests {
async fn test_requests() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Debug);
let refresh_token = std::env::var("WORLDLABS_REFRESH_TOKEN")?;
let refresh_token = WorldLabsRefreshToken::new(refresh_token);
let refresh_token = "AMf-vByUgA0J9S93vULECe-sD50cbR85AFVYF60gKAmLFF1MBYwXlKRJCsv7z3oSpLtP0ApyOU5fAl52qHWM2U0yKgEu5gQ5UCUDt5SEG4UBRHWvHEmLLcu-Zl04Fq4ljocSEO3qJzuX1wyshmptZxlDPRkjdLYgyn-Kp03woI2yRPKLuQF5hQtABHabsNUh9Zhs129sgxuUlOuw4zsFI4L8UdE3bEuF3k6Mic7KlE3440YwyQ8Qtmk9zHtXIx2ob56N-1WTGSqwMUuQ1mX4OOvLve9bw2zDW3UzchomTWkh732886RPrP-DNRkPbO3FejUEMxuYQFWYX3EEeIEdfNGXFNGPL1dNniSXlSaKadpX699Ishjviv-x9o-pPQa3zoAj3NzWLyd8mn5rDBh9qAO_iDETaeo01Sjhb4mGYfAWZ-g4S6nW778";
let refresh_token = WorldLabsRefreshToken::new(refresh_token.to_string());
let result = google_refresh_token(GoogleRefreshTokenArgs {
refresh_token: &refresh_token,
-1
View File
@@ -22,7 +22,6 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "2a24c10817173d26d0ea4689acbbade36f91b5dc" }
toml.workspace = true
browser_emulation.workspace = true
cookie_store_wrapper = { workspace = true, features = ["wreq"] }
@@ -12,5 +12,4 @@ pub mod platform_info_command;
pub mod providers;
pub mod response;
pub mod task_queue;
pub mod deprecated;
pub mod updates;
pub mod deprecated;
@@ -1,8 +0,0 @@
use crate::updates::{UpdateInfo, Updates};
use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn check_for_updates_command(app: AppHandle, updates: State<'_, Arc<Updates>>) -> Result<UpdateInfo, String> {
updates.inner().run("The update check stopped unexpectedly", move |state| state.check(&app)).await
}
@@ -1,9 +0,0 @@
use crate::updates::{require_confirmation, Updates};
use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn install_update_command(app: AppHandle, updates: State<'_, Arc<Updates>>, confirmed: bool) -> Result<(), String> {
require_confirmation(confirmed)?;
updates.inner().run("The update installer stopped unexpectedly", move |state| state.install(&app)).await
}
@@ -1,2 +0,0 @@
pub mod check_for_updates_command;
pub mod install_update_command;
@@ -31,6 +31,8 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{
update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs,
};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use tauri::AppHandle;
@@ -253,7 +255,8 @@ async fn download_file(
let filename = format!("fal_{}_{}.{}", generate_random_uuid(), index, extension);
let download_path = tempdir.join(filename);
tokio::fs::write(&download_path, bytes).await?;
let mut file = File::create(&download_path)?;
file.write_all(&bytes)?;
Ok(download_path)
}
@@ -94,7 +94,7 @@ async fn download_generated_results(
}
ensure!(!urls.is_empty(), "Completed task has no downloadable results");
let path = checkpoint_path(root, task);
let mut checkpoint: AutoDownloadCheckpoint = match tokio::fs::read(&path).await {
let mut checkpoint: AutoDownloadCheckpoint = match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes)?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
AutoDownloadCheckpoint { download_time: Local::now(), saved: BTreeMap::new() }
@@ -1,6 +1,8 @@
use errors::AnyhowResult;
use reqwest::Client;
use reqwest::Url;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
const USER_AGENT: &str = "storyteller-client/1.0";
@@ -20,7 +22,13 @@ pub async fn simple_http_download<P: AsRef<Path>>(url: &Url, download_path: P) -
//let response : Response = reqwest::get(url).await?;
let bytes = response.bytes().await?;
tokio::fs::write(download_path, bytes).await?;
let mut file = OpenOptions::new()
.create(true) // To create a new file
.write(true)
.truncate(true)
.open(download_path)?;
file.write_all(&bytes)?;
Ok(())
}
+2 -26
View File
@@ -6,11 +6,6 @@ use crate::core::commands::generate::generate_mesh_command::generate_mesh_comman
pub mod core;
pub mod services;
pub mod version;
pub mod updates;
use std::sync::Arc;
use crate::core::commands::updates::check_for_updates_command::check_for_updates_command;
use crate::core::commands::updates::install_update_command::install_update_command;
use tauri::Manager;
@@ -160,20 +155,11 @@ pub fn run() {
println!("Initializing backend runtime...");
let builder = tauri::Builder::default()
.manage(Arc::new(updates::Updates::default()))
.on_window_event(|window, event| {
if window.try_state::<Arc<updates::Updates>>().is_some_and(|updates| updates.prevents_exit()) {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
}
}
})
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_upload::init())
.setup(move |app| {
updates::show_previous_outcome(app.handle());
// TODO(bt): This is broken on windows
// log_environment_details();
@@ -239,8 +225,6 @@ pub fn run() {
// TODO: Break this out into another module, because RustRover/IntelliJ lags with these macros.
// My first attempt at naively doing this didn't work because the macros can't find their codegen'd targets.
let builder = builder.invoke_handler(tauri::generate_handler![
check_for_updates_command,
install_update_command,
check_sora_session_command,
download_directory_reveal_command,
download_media_file_command,
@@ -306,14 +290,6 @@ pub fn run() {
worldlabs_receive_bearer_command,
]);
let app = builder.build(tauri::generate_context!("tauri.conf.json"))
.expect("error while building tauri application");
app.run(|handle, event| {
let updates = handle.state::<Arc<updates::Updates>>();
match event {
tauri::RunEvent::ExitRequested { api, .. } if updates.prevents_exit() => api.prevent_exit(),
tauri::RunEvent::Exit => updates.finish(),
_ => {}
}
});
builder.run(tauri::generate_context!("tauri.conf.json"))
.expect("error while running tauri application");
}
-5
View File
@@ -2,10 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
if let Some(result) = artcraft_app_lib::updates::finish_helper() {
let failed = result.is_err();
if let Err(error) = result { log::error!("Update failed: {error}"); }
std::process::exit(i32::from(failed));
}
artcraft_app_lib::run();
}
@@ -119,7 +119,7 @@ pub(super) async fn upload_midjourney_batch(maybe_app_handle: Option<&AppHandle>
}
}
if let Err(err) = tokio::fs::remove_file(MidjourneyUploadCheckpoint::path(app_data_root, local_task)).await {
if let Err(err) = std::fs::remove_file(MidjourneyUploadCheckpoint::path(app_data_root, local_task)) {
log::warn!("Could not remove completed Midjourney upload checkpoint: {}", err);
}
Ok(())
@@ -2,6 +2,8 @@ use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use errors::AnyhowResult;
use midjourney_client::utils::get_image_url::get_image_url;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use url::Url;
use midjourney_client::utils::image_downloader_client::ImageDownloaderClient;
@@ -26,7 +28,8 @@ pub async fn download_midjourney_image(
let download_filename = format!("{}_{}.{}", job_id, index, ext);
let download_path = tempdir.join(download_filename);
tokio::fs::write(&download_path, &image_bytes).await?;
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
Ok(download_path)
}
@@ -14,7 +14,7 @@ pub async fn initialize_sora_jwt_bearer_token(app_data_root: &AppDataRoot) -> An
return Ok(()); // Bearer token already exists.
}
let cookie = tokio::fs::read_to_string(app_data_root.get_sora_cookie_file_path()).await?
let cookie = std::fs::read_to_string(app_data_root.get_sora_cookie_file_path())?
.trim()
.to_string();
@@ -22,7 +22,7 @@ pub async fn initialize_sora_jwt_bearer_token(app_data_root: &AppDataRoot) -> An
let bearer = generate_bearer_jwt_with_cookie(&cookie).await?;
tokio::fs::write(app_data_root.get_sora_bearer_token_file_path(), bearer).await?;
std::fs::write(app_data_root.get_sora_bearer_token_file_path(), bearer)?;
Ok(())
}
@@ -8,7 +8,8 @@ use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use openai_sora_client::creds::sora_sentinel_token::SoraSentinelToken;
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use tokio::fs::{read_to_string, write};
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
@@ -20,7 +21,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
}
let value = read_to_string(cookie_file).await?
let value = read_to_string(cookie_file)?
.trim()
.to_string();
@@ -31,14 +32,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
let mut sentinel_token = None;
if bearer_file.exists() {
let value = read_to_string(&bearer_file).await?
let value = read_to_string(&bearer_file)?
.trim()
.to_string();
bearer = Some(SoraJwtBearerToken::new(value)?);
}
if legacy_sentinel_file.exists() {
let value = read_to_string(&legacy_sentinel_file).await?
let value = read_to_string(&legacy_sentinel_file)?
.trim()
.to_string();
@@ -46,7 +47,7 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
}
if sentinel_token_file.exists() {
let value = read_to_string(&sentinel_token_file).await?
let value = read_to_string(&sentinel_token_file)?
.trim()
.to_string();
@@ -72,7 +73,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
info!("Persisting bearer token value...");
let value = bearer.token_str();
write(&bearer_file, value.as_bytes()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&bearer_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
@@ -81,7 +89,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
info!("Persisting sentinel value...");
let value = sentinel.get_sentinel();
write(&legacy_sentinel_file, value.as_bytes()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&legacy_sentinel_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
@@ -90,7 +105,14 @@ pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDat
info!("Persisting sentinel token...");
let value = sentinel_token.to_persistent_storage_json()?;
write(&sentinel_token_file, value.as_bytes()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&sentinel_token_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
@@ -162,7 +162,14 @@ impl SoraCredentialManager {
self.holder.set_credentials(&creds)?;
tokio::fs::write(self.app_data_root.get_sora_legacy_sentinel_file_path(), sentinel.as_bytes()).await?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(self.app_data_root.get_sora_legacy_sentinel_file_path())?;
file.write_all(sentinel.as_bytes())?;
file.flush()?;
Ok(creds)
}
@@ -33,6 +33,8 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
@@ -211,7 +213,8 @@ async fn download_generation_item(
info!("Writing to path: {:?}", download_path);
tokio::fs::write(&download_path, image_bytes).await?;
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
Ok(download_path)
}
@@ -39,6 +39,8 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
@@ -376,7 +378,8 @@ async fn download_spz(
info!("Writing to path: {:?}", download_path);
tokio::fs::write(&download_path, image_bytes).await?;
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
Ok(download_path)
}
-567
View File
@@ -1,567 +0,0 @@
//! Manual update checks and verified installation shared with the native Craft apps.
use craft_update::pins::{Choice, Pins};
use craft_update::self_update::Install;
use craft_update::{self_update, pick_asset, App, Http, Os, PackageKind, Paths, Platform, Release, Stop};
use enums::tauri::tasks::task_status::TaskStatus;
use serde_derive::Serialize;
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::DialogExt;
use crate::core::state::task_database::TaskDatabase;
use crate::version::ARTCRAFT_VERSION;
const APP: App = App { bundle_id_override: Some("ai.artcraft.app"), ..App::new("artcraft", "ArtCraft", "Create images and video", "storytold/artcraft") };
#[derive(Default)]
pub struct Updates {
busy: AtomicBool,
installing: AtomicBool,
maybe_release: Mutex<Option<Release>>,
maybe_restart: Mutex<Option<Restart>>,
}
#[derive(Debug, Serialize)]
pub struct UpdateInfo {
pub current_version: String,
pub maybe_available_version: Option<String>,
pub can_install: bool,
pub tasks_running: bool,
pub releases_url: String,
}
type Restart = Box<dyn FnOnce() -> Result<(), String> + Send>;
/// Only effects cross this boundary. Tests exercise the same state transitions without
/// networking, changing an installed copy, reading personal preferences, or exiting a process.
trait Backend {
fn stable_choice(&self) -> Result<(), String>;
fn check(&self) -> Result<Option<Release>, String>;
fn idle(&self) -> Result<(), String>;
fn installation(&self) -> Option<(Install, Platform)>;
fn can_replace(&self) -> bool;
fn prepare(&self, install: &Install, release: &Release, platform: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String>;
fn request_exit(&self);
}
struct NativeBackend<'a>(&'a AppHandle);
struct Installing<'a>(&'a AtomicBool);
pub(crate) struct Operation {
updates: Arc<Updates>,
}
impl Updates {
pub(crate) fn begin(self: &Arc<Self>) -> Result<Operation, String> {
self.busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).map_err(|_| "An update operation is already running. Please wait.".to_owned())?;
Ok(Operation { updates: Arc::clone(self) })
}
/// Runs `work` off the async runtime while it holds the one-operation guard.
pub(crate) async fn run<T: Send + 'static>(self: &Arc<Self>, stopped: &str, work: impl FnOnce(&Updates) -> Result<T, String> + Send + 'static) -> Result<T, String> {
let operation = self.begin()?;
let state = Arc::clone(self);
tauri::async_runtime::spawn_blocking(move || {
let _operation = operation;
work(&state)
})
.await
.map_err(|error| format!("{stopped}: {error}"))?
}
pub(crate) fn check(&self, app: &AppHandle) -> Result<UpdateInfo, String> {
self.check_with(&NativeBackend(app))
}
fn check_with(&self, backend: &impl Backend) -> Result<UpdateInfo, String> {
backend.stable_choice()?;
let maybe_release = backend.check()?;
let tasks_running = backend.idle().is_err();
let self_updating = maybe_release.as_ref().is_some_and(|release| backend.can_replace() && backend.installation().is_some_and(|(install, platform)| release_fits_copy(release, &install, platform)));
let info = update_info(maybe_release.as_ref(), self_updating, tasks_running);
*self.maybe_release.lock().map_err(|_| "The update state is unavailable. Restart ArtCraft and try again.")? = maybe_release;
Ok(info)
}
pub(crate) fn install(&self, app: &AppHandle) -> Result<(), String> {
self.install_with(&NativeBackend(app))
}
fn install_with(&self, backend: &impl Backend) -> Result<(), String> {
self.installing.store(true, Ordering::Release);
let _installing = Installing(&self.installing);
backend.idle()?;
backend.stable_choice()?;
let release = self.maybe_release.lock().map_err(|_| "The update state is unavailable. Restart ArtCraft and try again.")?.clone().ok_or("Check for updates before installing one.")?;
let (install, platform) = backend.installation().ok_or("This installation cannot update itself. Open Releases or use its package manager.")?;
if !backend.can_replace() || !release_fits_copy(&release, &install, platform) {
return Err("Use Releases or your package manager to update this installation.".into());
}
// Recheck tasks and launcher choices immediately before replacing any application files.
let before_replace = || backend.idle().and_then(|()| backend.stable_choice());
let restart = backend.prepare(&install, &release, platform, &before_replace)?;
*self.maybe_restart.lock().map_err(|_| "The restart state is unavailable. Restart ArtCraft manually.")? = Some(restart);
// The helper starts in RunEvent::Exit, after Tauri accepts the normal exit request.
backend.request_exit();
Ok(())
}
/// Keep the native process and window alive until replacement finishes. A prepared helper
/// may request the normal exit itself, so its restart must not be cancelled here.
pub fn prevents_exit(&self) -> bool {
self.installing.load(Ordering::Acquire) && self.maybe_restart.lock().map_or(true, |pending| pending.is_none())
}
pub fn finish(&self) {
match self.maybe_restart.lock() {
Ok(mut pending) => {
if let Some(restart) = pending.take() {
if let Err(error) = restart() {
log::error!("Could not start the update helper: {error}");
}
}
},
Err(_) => log::error!("Could not read the prepared update helper"),
}
}
}
impl Drop for Installing<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
impl Drop for Operation {
fn drop(&mut self) {
self.updates.busy.store(false, Ordering::Release);
}
}
pub fn finish_helper() -> Option<Result<(), String>> {
self_update::finish(&APP, &std::env::args_os().skip(1).collect::<Vec<_>>())
}
pub fn show_previous_outcome(app: &AppHandle) {
let Ok(paths) = Paths::for_app(&APP) else {
return;
};
if let Some(message) = outcome_message(self_update::take_outcome(&paths, ARTCRAFT_VERSION)) {
app.dialog().message(message).title("ArtCraft update").show(|_| {});
}
}
fn outcome_message(outcome: Option<self_update::Outcome>) -> Option<String> {
match outcome? {
self_update::Outcome::Installed(version) => Some(format!("ArtCraft was updated to {version}.")),
self_update::Outcome::Failed(error) => Some(format!("The ArtCraft update failed: {error}")),
}
}
pub(crate) fn require_confirmation(confirmed: bool) -> Result<(), String> {
if confirmed {
Ok(())
} else {
Err("Confirm that you saved your work before installing the update.".into())
}
}
fn stable_choice(pins: &Pins) -> Result<(), String> {
match pins.choice(APP.id) {
Choice::Stable => Ok(()),
_ => Err("ArtCraft Launcher manages this app's version. Change its version there.".into()),
}
}
fn ensure_idle(app: &AppHandle) -> Result<(), String> {
let db = app.try_state::<TaskDatabase>().ok_or("ArtCraft is still starting. Try checking again in a moment.")?;
let tasks = tauri::async_runtime::block_on(list_tasks_for_frontend(db.get_connection())).map_err(|error| format!("Could not check running tasks: {error}"))?;
no_active_tasks(tasks.tasks.iter().map(|task| task.status))
}
fn no_active_tasks(statuses: impl IntoIterator<Item = TaskStatus>) -> Result<(), String> {
if statuses.into_iter().any(active_task) {
Err("Finish or cancel running tasks before installing an update.".into())
} else {
Ok(())
}
}
fn active_task(status: TaskStatus) -> bool {
matches!(status, TaskStatus::Pending | TaskStatus::Started | TaskStatus::AttemptFailed)
}
impl Backend for NativeBackend<'_> {
fn stable_choice(&self) -> Result<(), String> {
stable_choice(&Pins::load_default())
}
fn check(&self) -> Result<Option<Release>, String> {
let http = Http::new(&format!("ArtCraft/{ARTCRAFT_VERSION}"))?;
craft_update::check_for_update(&APP, &http, ARTCRAFT_VERSION)
}
fn idle(&self) -> Result<(), String> {
ensure_idle(self.0)
}
fn installation(&self) -> Option<(Install, Platform)> {
self_update::current(&APP).zip(Platform::current())
}
fn can_replace(&self) -> bool {
self_update::is_self_updating(&APP)
}
fn prepare(&self, install: &Install, release: &Release, platform: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String> {
let http = Http::new(&format!("ArtCraft/{ARTCRAFT_VERSION}"))?;
let paths = Paths::for_app(&APP)?;
let restart = self_update::prepare_update(&APP, install, release, platform, &http, &paths, &Stop::default(), &mut |_| {}, before_replace, Some(ARTCRAFT_VERSION))?;
Ok(Box::new(move || restart.start()))
}
fn request_exit(&self) {
self.0.exit(0);
}
}
fn release_fits_copy(release: &Release, install: &Install, platform: Platform) -> bool {
let kind = match install {
Install::MacApp(_) => PackageKind::Dmg,
Install::Msi(_) => PackageKind::Msi,
Install::Portable(_) => PackageKind::PortableZip,
Install::AppImage(_) => PackageKind::AppImage,
Install::Package(kind, _) => *kind,
Install::Flatpak => PackageKind::Flatpak,
// ArtCraft also ships NSIS. It must not be unpacked over as a loose portable copy;
// its registered installer remains its update path until the shared helper supports it.
Install::Standalone(_) if platform.os == Os::Windows => return false,
Install::Standalone(_) => PackageKind::TarGz,
};
pick_asset(release, &[APP.id], platform, &[kind]).is_some()
}
fn update_info(maybe_release: Option<&Release>, self_updating: bool, tasks_running: bool) -> UpdateInfo {
UpdateInfo { current_version: ARTCRAFT_VERSION.into(), maybe_available_version: maybe_release.map(Release::version_label), can_install: !tasks_running && self_updating && maybe_release.is_some(), tasks_running, releases_url: APP.releases_url() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_identity_and_display_version_match_the_release_configuration() {
let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).expect("release configuration");
assert_eq!(config["version"].as_str(), Some(ARTCRAFT_VERSION));
assert_eq!(config["identifier"].as_str(), Some(APP.bundle_id().as_str()));
let mac: serde_json::Value = serde_json::from_str(include_str!("../tauri-mac.conf.json")).expect("macOS release configuration");
assert_eq!(mac["version"].as_str(), Some(ARTCRAFT_VERSION), "the macOS build must report the same version");
}
#[test]
fn tauri_installers_and_flatpaks_are_offered_only_for_the_matching_copy() {
use craft_update::{Arch, Asset};
let release = Release { assets: ["ArtCraft_0.42.1_x64_en-US.msi", "ArtCraft_0.42.1_universal.dmg", "artcraft-0.42.1-linux-x86_64.flatpak"].into_iter().map(|name| Asset { name: name.into(), ..Asset::default() }).collect(), ..Release::default() };
assert!(release_fits_copy(&release, &Install::Msi("unused.exe".into()), Platform { os: Os::Windows, arch: Arch::X64 }));
assert!(release_fits_copy(&release, &Install::MacApp("unused.app".into()), Platform { os: Os::MacOs, arch: Arch::Arm64 }));
assert!(release_fits_copy(&release, &Install::Flatpak, Platform { os: Os::Linux, arch: Arch::X64 }));
assert!(!release_fits_copy(&release, &Install::Flatpak, Platform { os: Os::Linux, arch: Arch::Arm64 }));
assert!(!release_fits_copy(&release, &Install::Standalone("unused.exe".into()), Platform { os: Os::Windows, arch: Arch::X64 }));
}
#[test]
fn checks_allow_closing_but_an_unfinished_install_keeps_the_window_alive() {
let state = Arc::new(Updates::default());
let _check = state.begin().expect("checking");
assert!(!state.prevents_exit());
state.installing.store(true, Ordering::Release);
let installing = Installing(&state.installing);
assert!(state.prevents_exit());
drop(installing);
assert!(!state.prevents_exit());
}
#[test]
fn running_and_retrying_tasks_block_an_update_but_completed_tasks_do_not() {
for status in [TaskStatus::Pending, TaskStatus::Started, TaskStatus::AttemptFailed] {
assert!(active_task(status));
}
for status in [TaskStatus::CompleteSuccess, TaskStatus::CompleteFailure, TaskStatus::Dead, TaskStatus::CancelledByUser, TaskStatus::CancelledByProvider, TaskStatus::CancelledByUs] {
assert!(!active_task(status));
}
assert!(no_active_tasks([TaskStatus::CompleteSuccess, TaskStatus::Dead]).is_ok());
assert!(no_active_tasks([]).is_ok());
assert_eq!(no_active_tasks([TaskStatus::CompleteSuccess, TaskStatus::Started]), Err("Finish or cancel running tasks before installing an update.".into()));
}
#[test]
fn only_the_stable_track_lets_artcraft_update_itself() {
use craft_update::pins::VersionTrack;
let mut pins = Pins::default();
assert!(stable_choice(&pins).is_ok());
pins.set(APP.id, &VersionTrack::Pinned("0.41.0".into()));
assert!(stable_choice(&pins).unwrap_err().contains("ArtCraft Launcher manages this app's version"));
pins.set(APP.id, &VersionTrack::Nightly);
assert!(stable_choice(&pins).is_err());
pins.set(APP.id, &VersionTrack::Stable);
assert!(stable_choice(&pins).is_ok());
}
#[test]
fn a_previous_update_outcome_is_reported_once_with_its_version_or_error() {
let installed = craft_update::parse_version("0.42.1").expect("version");
assert_eq!(outcome_message(Some(self_update::Outcome::Installed(installed))).as_deref(), Some("ArtCraft was updated to 0.42.1."));
assert_eq!(outcome_message(Some(self_update::Outcome::Failed("disk full".into()))).as_deref(), Some("The ArtCraft update failed: disk full"));
assert_eq!(outcome_message(None), None);
}
#[test]
fn installing_requires_confirmation_and_an_ordinary_start_is_not_the_helper() {
assert!(require_confirmation(false).unwrap_err().contains("saved your work"));
assert!(require_confirmation(true).is_ok());
// The test harness's own arguments never carry the helper flag.
assert!(finish_helper().is_none());
}
#[test]
fn commands_run_one_operation_at_a_time_and_release_it_after_failures() {
let state = Arc::new(Updates::default());
let value = tauri::async_runtime::block_on(state.run("stopped", |_| Ok(7)));
assert_eq!(value, Ok(7));
let failed: Result<(), String> = tauri::async_runtime::block_on(state.run("stopped", |_| Err("offline".into())));
assert_eq!(failed, Err("offline".into()));
let held = state.begin().expect("guard released after a failure");
let busy = tauri::async_runtime::block_on(state.run("stopped", |_| Ok(())));
assert!(busy.unwrap_err().contains("already running"));
drop(held);
let panicked: Result<(), String> = tauri::async_runtime::block_on(state.run("The update check stopped unexpectedly", |_| panic!("worker crashed")));
assert!(panicked.unwrap_err().starts_with("The update check stopped unexpectedly:"));
assert!(state.begin().is_ok(), "a crashed worker still releases the guard");
}
#[test]
fn only_a_replaceable_idle_copy_can_offer_installation() {
let release = Release { tag: "artcraft-v99.0.0".into(), ..Release::default() };
assert!(!update_info(Some(&release), false, false).can_install);
assert!(!update_info(Some(&release), true, true).can_install);
assert!(!update_info(None, true, false).can_install);
assert_eq!(update_info(Some(&release), false, false).maybe_available_version.as_deref(), Some("artcraft-v99.0.0"));
assert_eq!(APP.bundle_id(), "ai.artcraft.app");
}
#[test]
fn operations_are_serialized_and_errors_release_the_guard() {
let state = Arc::new(Updates::default());
let first = state.begin().expect("first check");
assert!(state.begin().is_err());
drop(first);
assert!(state.begin().is_ok());
assert!(state.maybe_release.lock().expect("state").is_none());
}
#[test]
fn a_manual_check_offers_a_matching_idle_install_and_defers_its_helper_until_exit() {
let state = Updates::default();
let backend = FakeBackend::default();
let info = state.check_with(&backend).expect("checked");
assert!(info.can_install);
state.install_with(&backend).expect("prepared");
assert!(backend.exit_requested.load(Ordering::Acquire));
assert!(!backend.helper_started.load(Ordering::Acquire));
assert!(!state.prevents_exit(), "the prepared update may exit normally");
state.finish();
assert!(backend.helper_started.load(Ordering::Acquire));
state.finish();
assert_eq!(backend.helper_runs.load(Ordering::Acquire), 1, "exit events cannot run a helper twice");
}
#[test]
fn a_pin_stops_network_checks_and_replacement() {
let state = Updates::default();
let backend = FakeBackend::default();
backend.pinned.store(true, Ordering::Release);
assert!(state.check_with(&backend).unwrap_err().contains("pinned"));
assert!(!backend.checked.load(Ordering::Acquire));
assert!(state.install_with(&backend).unwrap_err().contains("pinned"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn running_tasks_allow_a_check_but_block_the_install() {
let state = Updates::default();
let backend = FakeBackend::default();
backend.busy.store(true, Ordering::Release);
let info = state.check_with(&backend).expect("checking while busy");
assert!(info.tasks_running);
assert!(!info.can_install);
assert!(state.install_with(&backend).unwrap_err().contains("busy"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn a_pin_or_task_started_during_download_stops_replacement_and_exit() {
for pin in [false, true] {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("checked");
backend.pin_during_download.store(pin, Ordering::Release);
backend.task_during_download.store(!pin, Ordering::Release);
assert!(state.install_with(&backend).is_err());
assert!(!state.prevents_exit(), "failed installation releases the close guard");
assert!(!backend.exit_requested.load(Ordering::Acquire));
assert!(state.maybe_restart.lock().expect("restart state").is_none());
}
}
#[test]
fn an_install_requires_a_previous_check_and_a_replaceable_matching_copy() {
let state = Updates::default();
let backend = FakeBackend::default();
assert!(state.install_with(&backend).unwrap_err().contains("Check for updates"));
state.check_with(&backend).expect("checked");
backend.managed.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("managed check").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("package manager"));
backend.managed.store(false, Ordering::Release);
backend.missing_install.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("unknown install").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("cannot update"));
backend.missing_install.store(false, Ordering::Release);
backend.wrong_arch.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("no matching asset").can_install);
assert!(state.install_with(&backend).unwrap_err().contains("package manager"));
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn no_new_release_clears_a_previous_offer_and_a_failed_check_is_reported() {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("first check");
backend.no_release.store(true, Ordering::Release);
assert!(!state.check_with(&backend).expect("current version").can_install);
assert!(state.maybe_release.lock().expect("release").is_none());
backend.network_error.store(true, Ordering::Release);
assert!(state.check_with(&backend).unwrap_err().contains("offline"));
}
#[test]
fn a_failed_download_never_requests_exit_or_keeps_the_close_guard() {
let state = Updates::default();
let backend = FakeBackend::default();
state.check_with(&backend).expect("check");
backend.download_error.store(true, Ordering::Release);
assert!(state.install_with(&backend).unwrap_err().contains("download"));
assert!(!state.prevents_exit());
assert!(!backend.exit_requested.load(Ordering::Acquire));
}
#[test]
fn poisoned_state_returns_an_error_and_refuses_an_unsafe_exit() {
let state = Arc::new(Updates::default());
let other = Arc::clone(&state);
let _ = std::thread::spawn(move || {
let _guard = other.maybe_release.lock().unwrap();
panic!("simulate a previous worker failure");
})
.join();
let backend = FakeBackend::default();
assert!(state.check_with(&backend).is_err());
assert!(state.install_with(&backend).is_err());
let other = Arc::clone(&state);
let _ = std::thread::spawn(move || {
let _guard = other.maybe_restart.lock().unwrap();
panic!("simulate a previous helper-state failure");
})
.join();
state.installing.store(true, Ordering::Release);
assert!(state.prevents_exit());
state.finish();
}
#[test]
fn every_supported_install_kind_requires_its_own_asset() {
use craft_update::{Arch, Asset};
let linux = Platform { os: Os::Linux, arch: Arch::X64 };
let windows = Platform { os: Os::Windows, arch: Arch::X64 };
let release = Release { assets: ["artcraft-99.0.0-linux-x86_64.AppImage", "artcraft-99.0.0-linux-x86_64.tar.gz", "artcraft-99.0.0-linux-x86_64.deb", "artcraft-99.0.0-windows-x64-portable.zip"].into_iter().map(|name| Asset { name: name.into(), ..Asset::default() }).collect(), ..Release::default() };
for install in [Install::AppImage("unused".into()), Install::Standalone("unused".into()), Install::Package(PackageKind::Deb, "unused".into())] {
assert!(release_fits_copy(&release, &install, linux));
}
assert!(release_fits_copy(&release, &Install::Portable("unused".into()), windows));
assert!(!release_fits_copy(&release, &Install::Package(PackageKind::Rpm, "unused".into()), linux));
}
#[derive(Default)]
struct FakeBackend {
pinned: AtomicBool,
busy: AtomicBool,
managed: AtomicBool,
missing_install: AtomicBool,
wrong_arch: AtomicBool,
no_release: AtomicBool,
network_error: AtomicBool,
download_error: AtomicBool,
pin_during_download: AtomicBool,
task_during_download: AtomicBool,
checked: AtomicBool,
exit_requested: AtomicBool,
helper_started: Arc<AtomicBool>,
helper_runs: Arc<std::sync::atomic::AtomicUsize>,
}
impl Backend for FakeBackend {
fn stable_choice(&self) -> Result<(), String> {
if self.pinned.load(Ordering::Acquire) {
Err("pinned".into())
} else {
Ok(())
}
}
fn check(&self) -> Result<Option<Release>, String> {
self.checked.store(true, Ordering::Release);
if self.network_error.load(Ordering::Acquire) {
return Err("offline".into());
}
Ok((!self.no_release.load(Ordering::Acquire)).then(|| Release { tag: "artcraft-v99.0.0".into(), assets: vec![craft_update::Asset { name: "artcraft-99.0.0-linux-x86_64.flatpak".into(), ..Default::default() }], ..Default::default() }))
}
fn idle(&self) -> Result<(), String> {
if self.busy.load(Ordering::Acquire) {
Err("busy".into())
} else {
Ok(())
}
}
fn installation(&self) -> Option<(Install, Platform)> {
(!self.missing_install.load(Ordering::Acquire)).then(|| (Install::Flatpak, Platform { os: Os::Linux, arch: if self.wrong_arch.load(Ordering::Acquire) { craft_update::Arch::Arm64 } else { craft_update::Arch::X64 } }))
}
fn can_replace(&self) -> bool {
!self.managed.load(Ordering::Acquire)
}
fn prepare(&self, _: &Install, _: &Release, _: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result<Restart, String> {
if self.download_error.load(Ordering::Acquire) {
return Err("download failed".into());
}
if self.pin_during_download.load(Ordering::Acquire) {
self.pinned.store(true, Ordering::Release);
}
if self.task_during_download.load(Ordering::Acquire) {
self.busy.store(true, Ordering::Release);
}
before_replace()?;
let started = Arc::clone(&self.helper_started);
let runs = Arc::clone(&self.helper_runs);
Ok(Box::new(move || {
started.store(true, Ordering::Release);
runs.fetch_add(1, Ordering::AcqRel);
Ok(())
}))
}
fn request_exit(&self) {
self.exit_requested.store(true, Ordering::Release);
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.42.1";
pub const ARTCRAFT_VERSION: &str = "0.42.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.42.1",
"version": "0.42.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.42.1",
"version": "0.42.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -1,3 +1,4 @@
/// When downloading a remote file, it makes sense to try to match the same file extension (with reasonable safeguards).
#[deprecated(note = "Use `Extension` in `url_utils::extension::extension` instead")]
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
@@ -12,7 +12,9 @@ pub fn extract_download_extension_from_url_str(url: &str) -> Option<DownloadExte
#[deprecated(note = "Use `extract_extension_from_url` in `url_utils::extension::extract_extension_from_url` instead")]
pub fn extract_download_extension_from_url(url: &Url) -> Option<DownloadExtension> {
let path = url.path();
let extension = std::path::Path::new(path).extension().and_then(|ext| ext.to_str())?;
let extension = std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())?;
DownloadExtension::try_from_str(extension)
}
@@ -24,14 +26,14 @@ mod tests {
#[test]
fn success_case() {
let url = "https://storyteller.ai/example.jpg";
let maybe_extension = extract_download_extension_from_url_str(url);
let maybe_extension= extract_download_extension_from_url_str(url);
assert_eq!(maybe_extension, Some(DownloadExtension::Jpg));
}
#[test]
fn difficult_url() {
let url = "https://videos.youtube.com/foo/bar/files/00000000-f19a-1275-a092-caa60b38f8d2%2Fraw?se=2025-11-21T22%3A26%3A53Z&sp=r&sv=2024-08-09&sr=b&skoid=8cfff87a-01fc-27c1-9091-32999dcd1389&sktid=aa8ccaf6-e6da-484e-ab11-9c7496c21c53&skt=2025-11-17T21%3A09%3A08Z&ske=2025-10-23T21%3A14%3A08Z&sks=b&skv=2024-08-04&sig=T2U6%2Brw9WJdJagBkCL4/GlFr72KhZGP3QPlobIO7JlU%3D&ac=oaisdsorprnorthcentralus";
let maybe_extension = extract_download_extension_from_url_str(url);
let maybe_extension= extract_download_extension_from_url_str(url);
assert!(maybe_extension.is_none());
}
}
@@ -1,4 +1,3 @@
#[expect(clippy::module_inception, reason = "Preserve the existing public module path")]
mod download_extension;
pub mod extract_download_extension_from_url;
+12 -29
View File
@@ -1,6 +1,5 @@
use std::borrow::Cow;
use std::fmt;
use std::hash::{Hash, Hasher};
/// A file extension that can be either a compile-time constant or a runtime-parsed value.
///
@@ -17,7 +16,7 @@ use std::hash::{Hash, Hasher};
/// assert_eq!(ext.without_period(), "mp4");
/// assert_eq!(ext.with_period(), ".mp4");
/// ```
#[derive(Clone, Eq)]
#[derive(Clone, Eq, Hash)]
pub struct Extension {
/// The extension without the leading period, e.g. "png", "mp4".
value: Cow<'static, str>,
@@ -35,14 +34,20 @@ impl Extension {
/// const MP4: Extension = Extension::from_static("mp4", ".mp4");
/// ```
pub const fn from_static(ext: &'static str, dotted: &'static str) -> Self {
Self { value: Cow::Borrowed(ext), dotted: Cow::Borrowed(dotted) }
Self {
value: Cow::Borrowed(ext),
dotted: Cow::Borrowed(dotted),
}
}
/// Creates an `Extension` from a `&str` at runtime.
/// The input must NOT include a leading period.
pub fn new(ext: &str) -> Self {
let dotted = format!(".{ext}");
Self { value: Cow::Owned(ext.to_string()), dotted: Cow::Owned(dotted) }
let dotted = format!(".{}", ext);
Self {
value: Cow::Owned(ext.to_string()),
dotted: Cow::Owned(dotted),
}
}
/// Returns the extension without the leading period, e.g. `"png"`.
@@ -62,12 +67,6 @@ impl PartialEq for Extension {
}
}
impl Hash for Extension {
fn hash<H: Hasher>(&self, state: &mut H) {
self.value.hash(state);
}
}
impl fmt::Debug for Extension {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Extension(\"{}\")", self.value)
@@ -136,28 +135,12 @@ mod tests {
#[test]
fn display() {
assert_eq!(format!("{PNG}"), "png");
assert_eq!(format!("{}", PNG), "png");
assert_eq!(format!("{}", Extension::new("xyz")), "xyz");
}
#[test]
fn debug() {
assert_eq!(format!("{PNG:?}"), "Extension(\"png\")");
}
#[test]
fn hash_matches_equality() {
let plain = Extension::from_static("png", ".png");
let inconsistent = Extension::from_static("png", ".not-a-suffix");
assert_eq!(plain, inconsistent);
assert_eq!(hash(&plain), hash(&inconsistent));
}
fn hash(extension: &Extension) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
extension.hash(&mut hasher);
hasher.finish()
assert_eq!(format!("{:?}", PNG), "Extension(\"png\")");
}
}
@@ -6,13 +6,24 @@ use url::Url;
// TODO: Lookups against these as lists will be slow
/// Known image extensions.
const KNOWN_IMAGE_EXTENSIONS: &[Extension] = &[Extension::from_static("png", ".png"), Extension::from_static("jpg", ".jpg"), Extension::from_static("jpeg", ".jpeg"), Extension::from_static("webp", ".webp")];
const KNOWN_IMAGE_EXTENSIONS: &[Extension] = &[
Extension::from_static("png", ".png"),
Extension::from_static("jpg", ".jpg"),
Extension::from_static("jpeg", ".jpeg"),
Extension::from_static("webp", ".webp"),
];
/// Known audio extensions.
const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[Extension::from_static("wav", ".wav"), Extension::from_static("mp3", ".mp3")];
const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[
Extension::from_static("wav", ".wav"),
Extension::from_static("mp3", ".mp3"),
];
/// Known video extensions.
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[Extension::from_static("mp4", ".mp4"), Extension::from_static("webm", ".webm")];
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[
Extension::from_static("mp4", ".mp4"),
Extension::from_static("webm", ".webm"),
];
/// Controls which extensions are accepted when extracting from a URL.
pub enum ExtractExtensions {
@@ -51,7 +62,9 @@ pub fn extract_extension_from_url_str(url: &str, accept: &ExtractExtensions) ->
/// Extracts a file extension from a parsed `Url`, filtered by `accept`.
pub fn extract_extension_from_url(url: &Url, accept: &ExtractExtensions) -> Option<Extension> {
let path = url.path();
let raw_ext = std::path::Path::new(path).extension().and_then(|ext| ext.to_str())?;
let raw_ext = std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())?;
let lower = raw_ext.to_lowercase();
let candidate = Extension::new(&lower);
@@ -59,40 +72,27 @@ pub fn extract_extension_from_url(url: &Url, accept: &ExtractExtensions) -> Opti
match accept {
ExtractExtensions::All => Some(candidate),
ExtractExtensions::Set(set) => {
if set.contains(&candidate) {
Some(candidate)
} else {
None
}
},
if set.contains(&candidate) { Some(candidate) } else { None }
}
ExtractExtensions::KnownImage => {
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) {
Some(candidate)
} else {
None
}
},
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) { Some(candidate) } else { None }
}
ExtractExtensions::KnownAudio => {
if slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) {
Some(candidate)
} else {
None
}
},
if slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) { Some(candidate) } else { None }
}
ExtractExtensions::KnownVideo => {
if slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) {
Some(candidate)
} else {
None
}
},
if slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) { Some(candidate) } else { None }
}
ExtractExtensions::KnownMedia => {
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) || slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) || slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) {
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate)
|| slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate)
|| slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate)
{
Some(candidate)
} else {
None
}
},
}
}
}
@@ -190,14 +190,19 @@ mod tests {
#[test]
fn set_matches_custom_list() {
let accept = ExtractExtensions::from_vec(vec![Extension::from_static("csv", ".csv"), Extension::from_static("tsv", ".tsv")]);
let accept = ExtractExtensions::from_vec(vec![
Extension::from_static("csv", ".csv"),
Extension::from_static("tsv", ".tsv"),
]);
let ext = extract_extension_from_url_str("https://example.com/data.csv", &accept);
assert_eq!(ext.unwrap().without_period(), "csv");
}
#[test]
fn set_rejects_non_member() {
let accept = ExtractExtensions::from_vec(vec![Extension::from_static("csv", ".csv")]);
let accept = ExtractExtensions::from_vec(vec![
Extension::from_static("csv", ".csv"),
]);
let ext = extract_extension_from_url_str("https://example.com/data.png", &accept);
assert!(ext.is_none());
}
@@ -210,7 +215,10 @@ mod tests {
#[test]
fn query_params_ignored() {
let ext = extract_extension_from_url_str("https://example.com/file.mp4?token=abc&format=hd", &ExtractExtensions::KnownVideo);
let ext = extract_extension_from_url_str(
"https://example.com/file.mp4?token=abc&format=hd",
&ExtractExtensions::KnownVideo,
);
assert_eq!(ext.unwrap().without_period(), "mp4");
}
@@ -222,7 +230,8 @@ mod tests {
}
fn extract_extension_all(url: &str) -> Option<String> {
extract_extension_from_url_str(url, &ExtractExtensions::All).map(|ext| ext.without_period().to_string())
extract_extension_from_url_str(url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
}
#[test]
+1 -2
View File
@@ -1,3 +1,2 @@
#[expect(clippy::module_inception, reason = "Preserve the existing public module path")]
pub mod extension;
pub mod extract_extension_from_url;
pub mod extract_extension_from_url;
+1 -2
View File
@@ -1,3 +1,2 @@
pub mod extension;
#[expect(deprecated, reason = "Keep the public compatibility module while downstream users migrate to Extension")]
pub mod download_extension;
pub mod download_extension;
@@ -19,7 +19,6 @@ tokens.workspace = true
anyhow.workspace = true
chrono.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["fs"] }
# TODO(bt,2025-07-09): Update sqlx ASAP. We're stuck on 0.7 because of the complex use of internal types and macros in the database crate.
# We're going to embed in-memory SQLite
@@ -22,7 +22,7 @@ impl TaskDbConnection {
info!("Deleting and recreating SQLite database at {:?}", database_file.as_ref());
if let Err(err) = tokio::fs::remove_file(&database_file).await {
if let Err(err) = std::fs::remove_file(&database_file) {
error!("Error deleting SQLite database file: {:?}", err);
}
+3 -3
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>Storyteller API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" integrity="sha384-3nuX7df3EaAoiqLBeyS1Ola0Gpg9ryJKVtarubwfnA1cOH8AWHUdbPSIvEqPZ9VH" crossorigin="anonymous" />
<link rel="stylesheet" href="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" integrity="sha384-7xcoc6ZKDFF7Ek627QTC3Bg/K+5Y36NJ8MWAE43D2m6+3Sh9XO3tdsfHhrS8gNIQ" crossorigin="anonymous"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" integrity="sha384-FjFI+0PRyd5aAEoduVmUYk65iDC5+onNzam4WJtbAKKBim/kWvGUMk/2+9qRaYZb" crossorigin="anonymous"></script>
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" crossorigin></script>
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
@@ -120,7 +120,7 @@ export function GlobalFileDropHandler() {
}
};
void setup();
setup();
return () => { unlisteners.forEach((fn) => fn()); };
} else {
@@ -38,7 +38,6 @@ import {
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
import { clickOnEnterOrSpace } from "@storyteller/common";
type Mode = "image" | "text";
type Variant = "object" | "world";
@@ -70,7 +69,10 @@ const formatTime = (timestamp: number) => {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const generateId = () => uuidv4();
const generateId = () =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2, 10);
const WORLD_MODEL_PAGE = ModelPage.ImageTo3DWorld;
const OBJECT_MODEL_PAGE = ModelPage.ImageTo3DObject;
@@ -254,7 +256,7 @@ export const ImageTo3DExperience = ({
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
void handleFiles(event.target.files);
handleFiles(event.target.files);
event.target.value = "";
};
@@ -262,7 +264,7 @@ export const ImageTo3DExperience = ({
event.preventDefault();
event.stopPropagation();
setDragActive(false);
void handleFiles(event.dataTransfer?.files);
handleFiles(event.dataTransfer?.files);
};
const handlePickFromLibrary = () => {
@@ -597,9 +599,6 @@ export const ImageTo3DExperience = ({
<div className="flex justify-center">
{uploadedPreview ? (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
className={twMerge(
"group relative cursor-pointer overflow-hidden border border-white/30 bg-black/30 transition-all hover:border-white/60",
hasResults ? "aspect-square w-24" : "aspect-square w-48",
@@ -717,7 +716,7 @@ export const ImageTo3DExperience = ({
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer?.files)
void handleWorldFiles(
handleWorldFiles(
Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith("image/"),
),
@@ -755,9 +754,6 @@ export const ImageTo3DExperience = ({
const worldImageThumbnail = (img: WorldImage) => (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
key={img.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-[3px] border border-white/30 bg-black/30 transition-all",
@@ -948,7 +944,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"mx-auto grid h-full w-full grid-cols-[1fr_300px] gap-4 overflow-hidden pb-10",
"max-w-[1600px]",
variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]",
)}
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
>
@@ -1115,7 +1111,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"flex items-center justify-between gap-2.5",
"mt-3",
hasResults ? "mt-3" : "mt-3",
)}
>
<ClassyModelSelector
@@ -1181,7 +1177,7 @@ export const ImageTo3DExperience = ({
className="hidden"
onChange={(e) => {
if (e.target.files) {
void handleWorldFiles(Array.from(e.target.files));
handleWorldFiles(Array.from(e.target.files));
}
e.target.value = "";
}}
@@ -1206,7 +1202,6 @@ export const ImageTo3DExperience = ({
{previewImage && (
<div
role="presentation"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onClick={() => setPreviewImage(null)}
>
@@ -248,7 +248,7 @@ function ItemActions({
e.stopPropagation();
const url = item.fullImage || item.thumbnail;
if (!url) return;
void applyMakeVideoFromImage(url, item.id);
applyMakeVideoFromImage(url, item.id);
},
[item.fullImage, item.thumbnail, item.id],
);
@@ -39,7 +39,6 @@ import {
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { useTabStore } from "~/pages/Stores/TabState";
import { v4 as uuidv4 } from "uuid";
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
// the create pages' generation feed (rows + cards). All of them operate on
@@ -131,7 +130,7 @@ export async function downloadMediaFilesToFolder(
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
try {
const referenceImage: RefImage = {
id: uuidv4(),
id: Math.random().toString(36).substring(7),
url,
file: new File([], "library-image"),
mediaToken: mediaToken || "",
@@ -179,7 +178,7 @@ export function applyRecreateFromPromptData(data: {
for (const ci of contextImages) {
const base = {
id: uuidv4(),
id: Math.random().toString(36).substring(7),
url: ci.media_links.cdn_url,
mediaToken: ci.media_token,
};
@@ -295,8 +295,8 @@ export function useDesktopGenerationFeed(options: {
cancelled = true;
clearInterval(intervalId);
window.removeEventListener("task-queue-update", handleTaskUpdate);
void unlistenComplete.then((f) => f());
void unlistenFailed.then((f) => f());
unlistenComplete.then((f) => f());
unlistenFailed.then((f) => f());
};
}, [load]);
@@ -8,7 +8,7 @@ export function useDesktopUsername(): string | null {
useEffect(() => {
let cancelled = false;
void (async () => {
(async () => {
try {
const session = await new UsersApi().GetSession();
if (!cancelled && session.success && session.data?.user) {
@@ -66,9 +66,9 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
</div>
<div className="space-y-1">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Currency
</span>
</label>
<Select
options={selectOptions}
value={currency}
@@ -48,7 +48,7 @@ export const UploadFilesMedia = ({
return;
}
void uploadPlane({
uploadPlane({
title: uploadTitle.value,
assetFile: assetFile.value,
progressCallback: onUploadProgress,
@@ -1,75 +0,0 @@
import { PauseIcon, PlayIcon, SkipBackIcon, SkipForwardIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
interface VideoTransportControlsProps {
isPlaying: boolean;
isMuted: boolean;
volume: number;
currentTime: number;
duration: number;
onSeek: (to: "first" | "last") => void;
onTogglePlay: () => void;
onToggleMute: () => void;
onVolumeChange: (volume: number) => void;
formatTime: (seconds: number) => string;
}
/** The row under a video page's player: first / play / last frame, mute and volume, and the time. */
export const VideoTransportControls = ({
isPlaying,
isMuted,
volume,
currentTime,
duration,
onSeek,
onTogglePlay,
onToggleMute,
onVolumeChange,
formatTime,
}: VideoTransportControlsProps) => (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => onSeek("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon className="text-sm text-white" />
</button>
<button
onClick={onTogglePlay}
aria-label={isPlaying ? "Pause" : "Play"}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} className="text-white" />
</button>
<button
onClick={() => onSeek("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={onToggleMute}
aria-label={isMuted ? "Unmute" : "Mute"}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon icon={isMuted ? VolumeXIcon : Volume2Icon} className="text-sm text-white" />
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) => onVolumeChange(Number.parseFloat(e.target.value))}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
);
@@ -26,7 +26,7 @@ export default function ProfileDropdown() {
label: "Logout",
icon: LogOutIcon,
onClick: () => {
void logout();
logout();
},
},
];
@@ -67,7 +67,7 @@ export const SceneTitleInput = ({ pageName }: Props) => {
e.currentTarget.focus();
} else if (scene.value.token) {
setIsSaving(true);
void renameScene(
renameScene(
scene.value.title!, //guarunteed by input
scene.value.token,
);
@@ -164,7 +164,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
aria-label="Copy prompt"
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard.writeText(prompt);
navigator.clipboard.writeText(prompt);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}}
@@ -513,7 +513,7 @@ export const TaskQueue = () => {
const [failed, setFailed] = useState<FailedTask[]>([]);
const [lastReadAt, setLastReadAt] = useState<number>(() => {
const stored = localStorage.getItem("taskQueueLastReadAt");
return stored ? Number.parseInt(stored, 10) : 0;
return stored ? parseInt(stored, 10) : 0;
});
// remove unread state; unread tracking handled via IDs below
@@ -869,7 +869,7 @@ export const TaskQueue = () => {
};
cleanupOldEntries();
void load();
load();
const id = setInterval(load, 5000);
// Listen for cover image uploads to refresh and show new thumbnails
@@ -883,17 +883,17 @@ export const TaskQueue = () => {
let unlistenComplete: Promise<UnlistenFn> | null = null;
let unlistenFailed: Promise<UnlistenFn> | null = null;
void (async () => {
(async () => {
// Update immediately when Tauri signals a generation completion
unlistenComplete = listen("generation-complete-event", () => {
if (!cancelled) {
void load();
load();
}
});
// Also update immediately when a generation fails
unlistenFailed = listen("generation-failed-event", () => {
if (!cancelled) {
void load();
load();
// Refunded credits may take a moment to settle in the database
setTimeout(() => {
useCreditsState.getState().fetchFromServer();
@@ -906,10 +906,10 @@ export const TaskQueue = () => {
clearInterval(id);
window.removeEventListener("cover-image-uploaded", handleCoverUploaded);
if (unlistenComplete) {
void unlistenComplete.then((f) => f());
unlistenComplete.then((f) => f());
}
if (unlistenFailed) {
void unlistenFailed.then((f) => f());
unlistenFailed.then((f) => f());
}
};
}, [
@@ -125,7 +125,7 @@ export function storeEnqueueMeta(meta: EnqueueMeta) {
// Then convert blob URLs to base64 in the background for persistence
if (meta.refImageUrls && meta.refImageUrls.length > 0) {
void Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
const current = readEntries();
const found = current.find((e) => e.id === entryId);
if (found) {
@@ -12,7 +12,7 @@ export const withProtectionRoute = <P extends object>(
function ProtectionRoute(rest: P) {
useSignals();
useSignalEffect(() => {
void persistLogin();
persistLogin();
});
// const { status, userInfo } = authentication;
@@ -38,7 +38,7 @@ export const useActiveJobs = () => {
// then set new one with new interval
clearInterval(curr.timer);
const newTimer = setInterval(() => {
void PollRecentJobs();
PollRecentJobs();
}, newTimeInterval);
return {
timer: newTimer,
@@ -72,7 +72,7 @@ export const useActiveJobs = () => {
useSignalEffect(() => {
// if active jobs is not initiated, do do a first pull;
if (!activeJobs.value) {
void PollRecentJobs();
PollRecentJobs();
return;
}
// if there are no active jobs, stop making polls
@@ -115,7 +115,7 @@ export const useActiveJobs = () => {
//else make and set timer
const newTimer = setInterval(() => {
void PollRecentJobs();
PollRecentJobs();
}, curr.interval);
return {
...curr,
@@ -24,7 +24,7 @@ export const useBackgroundLoadingMedia = () => {
//CASE 1: first load
// if myMovies undefined, poll for the first time
if (!userMovies.value) {
void PollUserGeneratedMovies();
PollUserGeneratedMovies();
return;
}
@@ -45,7 +45,7 @@ export const useBackgroundLoadingMedia = () => {
//there are videos newly completed, set and poll
lastCompletedWorkflow.current = completedWorkflowJobs.value;
void PollUserGeneratedMovies().then((ret: boolean) => {
PollUserGeneratedMovies().then((ret: boolean) => {
if (ret) {
addToast(
ToastTypes.SUCCESS,
@@ -60,7 +60,7 @@ export const useBackgroundLoadingMedia = () => {
//CASE 1: first load
// if audioItems undefined, poll for the first time
if (!userAudioItems.value) {
void PollUserAudioItems();
PollUserAudioItems();
return;
}
@@ -82,7 +82,7 @@ export const useBackgroundLoadingMedia = () => {
//there are audio jobs newly completed, set and poll
lastCompletedAudioJobs.current = completedAudioJobs.value;
void PollUserAudioItems().then((ret: boolean) => {
PollUserAudioItems().then((ret: boolean) => {
if (ret) {
addToast(
ToastTypes.SUCCESS,
+2 -2
View File
@@ -26,12 +26,12 @@ const ENV = {
const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
console.log("GlobalSettingsManager()");
void SyncStorytellerApiConfig();
SyncStorytellerApiConfig();
useSignals();
useSignalEffect(() => {
void persistLogin();
persistLogin();
});
/// Initizations that depends on ENV vars ///
@@ -152,7 +152,7 @@ export const MainApp = ({ sceneToken }: Props) => {
// Session probe (runs once per shell mount).
useEffect(() => {
const usersApi = new UsersApi();
void usersApi.GetSession().then((result) => {
usersApi.GetSession().then((result) => {
console.log(
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
);
@@ -11,7 +11,6 @@ import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
import { v4 as uuidv4 } from "uuid";
const MAX_GALLERY_SELECTIONS = 1;
@@ -65,7 +64,7 @@ export const BaseImageSelector = ({
setIsBlankCanvasModalOpen(false);
const blankImage: BaseSelectorImage = {
url: "",
mediaToken: `blank_canvas_${width}x${height}_${uuidv4()}`,
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
@@ -80,12 +79,12 @@ export const BaseImageSelector = ({
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${uuidv4()}`,
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
if (newState.status === UploaderStates.success && newState.data) {
const mediaToken = newState.data || "";
void (async () => {
(async () => {
let finalUrl = reader.result as string;
let thumbnailUrlTemplate = undefined;
try {
@@ -1,7 +1,6 @@
import { create } from "zustand";
import type { GeneratedImage } from "@storyteller/tauri-events";
import type { ModelCreator } from "@storyteller/model-list";
import { v4 as uuidv4 } from "uuid";
export type TextToImageBatch = {
id: string;
@@ -44,7 +43,11 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
subscriberId?: string,
modelCreator?: ModelCreator,
) => {
const id = subscriberId || uuidv4();
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const batch: TextToImageBatch = {
id,
prompt,
@@ -3,7 +3,6 @@ import { listen } from "@tauri-apps/api/event";
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { v4 as uuidv4 } from "uuid";
import { captureModelThumbnail } from "./captureModelThumbnail";
import { completeGenerationAction } from "../Stores/completeGenerationResult";
export interface PendingExternalImage {
url: string;
@@ -73,7 +72,11 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
meshOnly: boolean,
subscriberId?: string,
) => {
const id = subscriberId || uuidv4();
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const result: ImageTo3DResult = {
id,
mode,
@@ -87,7 +90,52 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
set((s) => ({ results: [result, ...s.results] }));
return id;
},
completeGeneration: completeGenerationAction(set, "ImageTo3DStore", "Generated Model"),
completeGeneration: (
modelUrl: string,
mediaToken: string,
maybeSubscriberId?: string,
) => {
console.log("[ImageTo3DStore] completeGeneration", {
modelUrl,
mediaToken,
maybeSubscriberId,
});
const pending = maybeSubscriberId
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
: get().results.find((r) => r.status === "pending");
set((s) => {
const results = [...s.results];
const targetIdx = pending
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
: -1;
if (targetIdx === -1) {
const generatedId =
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
const result: ImageTo3DResult = {
id: generatedId,
subscriberId: generatedId,
mode: "image",
timestamp: Date.now(),
note: "Generated Model",
status: "completed",
modelUrl,
mediaToken,
};
return { results: [result, ...results] };
}
results[targetIdx] = {
...results[targetIdx],
status: "completed",
modelUrl,
mediaToken,
};
return { results };
});
},
uploadCoverImage: async (mediaToken: string, thumbnailDataUrl: string) => {
try {
// Check if already uploaded
@@ -2,7 +2,6 @@ import { create } from "zustand";
import { listen } from "@tauri-apps/api/event";
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { v4 as uuidv4 } from "uuid";
import { completeGenerationAction } from "../Stores/completeGenerationResult";
export type ImageTo3DWorldResult = {
id: string;
@@ -55,7 +54,11 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
previewUrl: string | undefined,
subscriberId?: string,
) => {
const id = subscriberId || uuidv4();
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const result: ImageTo3DWorldResult = {
id,
mode,
@@ -68,7 +71,52 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
set((s) => ({ results: [result, ...s.results] }));
return id;
},
completeGeneration: completeGenerationAction(set, "ImageTo3DWorldStore", "Generated World"),
completeGeneration: (
modelUrl: string,
mediaToken: string,
maybeSubscriberId?: string,
) => {
console.log("[ImageTo3DWorldStore] completeGeneration", {
modelUrl,
mediaToken,
maybeSubscriberId,
});
const pending = maybeSubscriberId
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
: get().results.find((r) => r.status === "pending");
set((s) => {
const results = [...s.results];
const targetIdx = pending
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
: -1;
if (targetIdx === -1) {
const generatedId =
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
const result: ImageTo3DWorldResult = {
id: generatedId,
subscriberId: generatedId,
mode: "image",
timestamp: Date.now(),
note: "Generated World",
status: "completed",
modelUrl,
mediaToken,
};
return { results: [result, ...results] };
}
results[targetIdx] = {
...results[targetIdx],
status: "completed",
modelUrl,
mediaToken,
};
return { results };
});
},
uploadCoverFromPreview: async (mediaToken: string) => {
try {
const result = get().results.find((r) => r.mediaToken === mediaToken);
@@ -148,7 +148,7 @@ export const RemoveBackground = () => {
}
};
void processExternalUrl();
processExternalUrl();
}, [
pendingExternalUrl,
setPendingExternalUrl,
@@ -4,7 +4,6 @@ import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
import { useStoryboardStore, type Board } from "./StoryboardStore";
import { clickOnEnterOrSpace } from "@storyteller/common";
const PIXELS_PER_SECOND = 80;
const MIN_BLOCK_WIDTH = 40;
@@ -74,9 +73,8 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-title" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
<input
id="storyboard-title"
type="text"
value={board.title}
onChange={(e) => onUpdate({ title: e.target.value })}
@@ -86,9 +84,8 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-dialogue" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
<textarea
id="storyboard-dialogue"
value={board.dialogue}
onChange={(e) => onUpdate({ dialogue: e.target.value })}
placeholder="Character dialogue..."
@@ -98,9 +95,8 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-action" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
<textarea
id="storyboard-action"
value={board.action}
onChange={(e) => onUpdate({ action: e.target.value })}
placeholder="On-screen action..."
@@ -110,9 +106,8 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-notes" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
<textarea
id="storyboard-notes"
value={board.notes}
onChange={(e) => onUpdate({ notes: e.target.value })}
placeholder="Director notes..."
@@ -122,14 +117,13 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label htmlFor="storyboard-duration" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
<input
id="storyboard-duration"
type="number"
min={0}
step={0.5}
value={board.duration}
onChange={(e) => onUpdate({ duration: Number.parseFloat(e.target.value) || 0 })}
onChange={(e) => onUpdate({ duration: parseFloat(e.target.value) || 0 })}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 font-mono text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
/>
</div>
@@ -359,7 +353,6 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
{/* Color picker */}
<label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-[3px] border border-white/15 hover:border-white/30" title="Stroke color">
<span className="sr-only">Stroke color</span>
<span
className="h-4 w-4 rounded-sm border border-white/20"
style={{ background: color }}
@@ -464,9 +457,6 @@ const ThumbnailItem = ({
onDragEnd,
}: ThumbnailItemProps) => (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => {
@@ -604,7 +594,7 @@ const Timeline = ({
const delta = ev.clientX - resizeRef.current.startX;
const newDuration = Math.max(
MIN_DURATION,
Number.parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
);
onUpdateDuration(resizeRef.current.id, newDuration);
};
@@ -1,5 +1,4 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
export type VideoItem = {
media_token: string;
@@ -34,7 +33,11 @@ type ImageToVideoState = {
export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
batches: [],
startBatch: (prompt: string, modelLabel: string, subscriberId?: string) => {
const id = subscriberId || uuidv4();
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const batch: ImageToVideoBatch = {
id,
prompt,
@@ -58,7 +61,7 @@ export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
? s.batches.findIndex((b) => b.id === pending.id)
: -1;
if (idx === -1) {
const id = uuidv4();
const id = Math.random().toString(36).slice(2);
const batch: ImageToVideoBatch = {
id,
prompt,
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { CheckIcon, ImagesIcon, RotateCwIcon, SaveIcon, SparklesIcon, VideoIcon } from "lucide-react";
import { CheckIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SaveIcon, SkipBackIcon, SkipForwardIcon, SparklesIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
@@ -12,7 +13,6 @@ import { v4 as uuidv4 } from "uuid";
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import { useTabStore } from "~/pages/Stores/TabState";
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
export const VideoFrameExtractor = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
@@ -420,7 +420,7 @@ export const VideoFrameExtractor = () => {
const file = new File([blob], fileName, { type: "image/png" });
const referenceImage: RefImage = {
id: uuidv4(),
id: Math.random().toString(36).substring(7),
url: frameUrl,
file: file,
mediaToken: uploadResponse.data,
@@ -515,7 +515,6 @@ export const VideoFrameExtractor = () => {
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
role="presentation"
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
onClick={handleProgressBarClick}
@@ -527,7 +526,6 @@ export const VideoFrameExtractor = () => {
}}
/>
<div
role="presentation"
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize bg-yellow-400"
style={{
left: `${duration ? (startTime / duration) * 100 : 0}%`,
@@ -568,18 +566,61 @@ export const VideoFrameExtractor = () => {
}}
/>
</div>
<VideoTransportControls
isPlaying={isPlaying}
isMuted={isMuted}
volume={volume}
currentTime={currentTime}
duration={duration}
onSeek={seekToFrame}
onTogglePlay={togglePlayPause}
onToggleMute={toggleMute}
onVolumeChange={setVolume}
formatTime={formatTime}
/>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon
className="text-sm text-white" />
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon
className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
</div>
@@ -671,7 +712,7 @@ export const VideoFrameExtractor = () => {
setNumFrames(
Math.max(
1,
Math.min(50, Number.parseInt(e.target.value) || 1),
Math.min(50, parseInt(e.target.value) || 1),
),
)
}
@@ -694,7 +735,7 @@ export const VideoFrameExtractor = () => {
value={frameDistance}
onChange={(e) =>
setFrameDistance(
Math.max(1, Number.parseInt(e.target.value) || 1),
Math.max(1, parseInt(e.target.value) || 1),
)
}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
@@ -1,11 +1,11 @@
import { useState, useRef, useEffect } from "react";
import { DropletIcon, ImagesIcon, RotateCwIcon, VideoIcon } from "lucide-react";
import { DropletIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SkipBackIcon, SkipForwardIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
export const VideoWatermarkRemover = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
@@ -255,7 +255,6 @@ export const VideoWatermarkRemover = () => {
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
role="presentation"
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
onClick={handleProgressBarClick}
@@ -267,18 +266,61 @@ export const VideoWatermarkRemover = () => {
}}
/>
</div>
<VideoTransportControls
isPlaying={isPlaying}
isMuted={isMuted}
volume={volume}
currentTime={currentTime}
duration={duration}
onSeek={seekToFrame}
onTogglePlay={togglePlayPause}
onToggleMute={toggleMute}
onVolumeChange={setVolume}
formatTime={formatTime}
/>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon
className="text-sm text-white" />
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon
className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
</div>
@@ -30,7 +30,7 @@ interface TabState {
[K in TabId]?: string;
};
// Actions
setActiveTab: (tabId: TabId) => Promise<void>;
setActiveTab: (tabId: TabId) => Promise<boolean>;
updateTabData: (tabId: TabId, data: unknown) => void;
getTabData: <T>(tabId: TabId) => T | null;
clearTabData: (tabId: TabId) => void;
@@ -42,12 +42,13 @@ export const useTabStore = create<TabState>((set, get) => ({
setActiveTab: async (newTabId) => {
// Don't do anything if we're already on this tab
if (get().activeTabId === newTabId) return;
if (get().activeTabId === newTabId) return true;
// The drawing store outlives its React page. Keep its live image objects,
// selection, and undo history when switching tabs. Export/import is for
// files; round-tripping here reread every image and rebuilt the scene.
set({ activeTabId: newTabId });
return true;
},
updateTabData: (tabId, data) => {
@@ -1,50 +0,0 @@
import { v4 as uuidv4 } from "uuid";
interface GenerationResult {
id: string;
mode: "image" | "text";
timestamp: number;
note?: string;
status: "pending" | "completed";
subscriberId: string;
modelUrl?: string;
mediaToken?: string;
}
/**
* The results after a 3D job finishes: the one with that subscriber id (else the first still
* pending) becomes completed with the model; with none to update, a completed one is added first.
*/
export function completeGenerationResult<T extends GenerationResult>(
results: readonly T[],
subscriberId: string | undefined,
completed: { modelUrl: string; mediaToken: string; note: string },
): T[] {
const { modelUrl, mediaToken, note } = completed;
const index = subscriberId
? results.findIndex((r) => r.subscriberId === subscriberId)
: results.findIndex((r) => r.status === "pending");
if (index === -1) {
const id = uuidv4();
// The other fields of T are optional, as the 3D result types declare them.
const added = { id, subscriberId: id, mode: "image", timestamp: Date.now(), note, status: "completed", modelUrl, mediaToken } as T;
return [added, ...results];
}
const next = [...results];
next[index] = { ...next[index], status: "completed", modelUrl, mediaToken };
return next;
}
/** A store's `completeGeneration`: logs the finished job under `store` and applies completeGenerationResult. */
export function completeGenerationAction<T extends GenerationResult>(
set: (update: (state: { results: T[] }) => { results: T[] }) => void,
store: string,
note: string,
) {
return (modelUrl: string, mediaToken: string, maybeSubscriberId?: string) => {
console.log(`[${store}] completeGeneration`, { modelUrl, mediaToken, maybeSubscriberId });
set((s) => ({
results: completeGenerationResult(s.results, maybeSubscriberId, { modelUrl, mediaToken, note }),
}));
};
}
@@ -87,7 +87,7 @@ export const completedAudioJobs = computed(() => {
export const shouldPollActiveJobs = signal<boolean>(false);
export const startPollingActiveJobs = () => {
void PollRecentJobs(); //poll once regardless of polling intervals
PollRecentJobs(); //poll once regardless of polling intervals
shouldPollActiveJobs.value = true;
};
@@ -47,7 +47,7 @@ export const login = async ({
// technically user is login with the system now, HOWEVER,
// in storyteller studio, only having a sesison is not enough,
// we need session info and active subscription info as well
void getUserInfoAndSubcriptions();
getUserInfoAndSubcriptions();
window.location.href = "/"; // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
};
@@ -86,7 +86,7 @@ export const signUp = async ({
// technically user is login with the system now, HOWEVER,
// in storyteller studio, only having a sesison is not enough,
// we need session info and active subscription info as well
void getUserInfoAndSubcriptions();
getUserInfoAndSubcriptions();
return "";
};
@@ -95,12 +95,12 @@ export const persistLogin = async () => {
if (authentication.status.value !== AUTH_STATUS.INIT) {
return;
}
void getUserInfoAndSubcriptions();
getUserInfoAndSubcriptions();
};
// NB: Only for SyncStorytellerApiConfig.
export const forceGetUserInfoAndSubcriptions = async () => {
void getUserInfoAndSubcriptions();
getUserInfoAndSubcriptions();
};
async function getUserInfoAndSubcriptions() {
+1 -1
View File
@@ -40,7 +40,7 @@ export const isJobStatusTerminal = (curr: JobStatus) => {
};
export const isNumberString = (value: string) => {
return !Number.isNaN(Number(value));
return !isNaN(Number(value));
};
export const isJobStatusError = (curr: JobStatus) => {
+1 -1
View File
@@ -17,7 +17,7 @@ set -euxo pipefail
echo 'TEMPORARY HACK: Build internal dependencies of artcraft since nx is not finding them...'
# A 'clean' will remove nx
npm install --ignore-scripts
npm install
# TODO: Temp commented out
nx build "@storyteller/api"
@@ -1,4 +1,3 @@
export * from "./thumbnail-utils";
export * from "./media-format";
export * from "./referral";
export * from "./keyboard";
@@ -1,16 +0,0 @@
/**
* For an element with `role="button"`: Enter or Space clicks it, as they would a native button.
* Keys pressed on a control inside it are left to that control.
*/
export function clickOnEnterOrSpace(event: {
key: string;
preventDefault(): void;
target: unknown;
currentTarget: { click(): void };
}) {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.currentTarget.click();
}
}
@@ -251,7 +251,6 @@ export function WaveformAudioPlayer({
return (
<div
role="presentation"
ref={rootRef}
className={`flex items-center gap-2.5 ${compact ? "px-2.5 py-2" : "px-3 py-2.5"} ${className}`}
onClick={(event) => event.stopPropagation()}
@@ -274,7 +273,6 @@ export function WaveformAudioPlayer({
)}
{(waveState === "pending" || waveState === "failed") && (
<div
role="presentation"
className="absolute inset-0 flex cursor-pointer items-center gap-[1.5px]"
onClick={handleBarSeek}
>
@@ -135,7 +135,7 @@ export const ButtonDropdown = ({
)}
<div className="grow text-start">{option.label}</div>
<div className="ml-6 font-mono text-[11px] font-normal text-white/45">
{option.description}
{option.description && option.description}
</div>
{showSelected && (
<>
@@ -52,7 +52,6 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
"conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)",
}}
>
<span className="sr-only">Custom color</span>
<input
type="color"
value={
@@ -64,6 +64,7 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
if (e.key === "Enter") submit();
}}
className="w-full rounded-[3px] border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
@@ -13,7 +13,6 @@ import {
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { toast } from "@storyteller/ui-toaster";
import { clickOnEnterOrSpace } from "@storyteller/common";
type ModalMode = "select" | "view";
@@ -337,7 +336,6 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{/* dropdown menu */}
{mode !== "select" && (
<div
role="presentation"
// Tile chrome stays under sticky page headers (e.g. the library
// filter bar at z-10): z-[2]/z-[1] keep the in-tile ordering
// (menu/checkbox above badges) without escaping the page layers.
@@ -378,7 +376,6 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
createPortal(
<>
<div
role="presentation"
className="fixed inset-0 z-[9998]"
onClick={() => setCtxMenu(null)}
onContextMenu={(e) => {
@@ -411,11 +408,6 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{/* Bulk selection checkbox — top-left (only when a bulk handler is wired) */}
{mode !== "select" && onBulkSelectToggle && (
<div
role="checkbox"
aria-checked={bulkSelected}
aria-label={`Select ${item.label}`}
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
className={twMerge(
"absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded-[3px] border-2 cursor-pointer transition-all duration-100",
bulkSelected
@@ -423,7 +415,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
: "border-white/60 bg-black/40 hover:border-white",
bulkSelectionMode
? "opacity-100"
: "opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(pointer:coarse)]:opacity-100",
: "opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100",
)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -403,7 +403,7 @@ const SIDEBAR_TAG_LIMIT = 5;
const hashString = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i++) h = Math.imul(Math.imul(h, 31) + s.charCodeAt(i), 1); // int32 wrap
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return h;
};
@@ -948,7 +948,7 @@ export const GalleryModal = React.memo(
// couldn't see it — making the library button appear broken.
galleryModalDraggingUnder.value = false;
let cancelled = false;
void (async () => {
(async () => {
setUsernameError(false);
const name = await fetchCurrentUsername();
if (cancelled) return;
@@ -2710,6 +2710,7 @@ export const GalleryModal = React.memo(
}}
onBlur={handleConfirmRename}
className="text-lg font-semibold bg-transparent px-1.5 py-0 text-base-fg outline-none min-w-[6rem] caret-primary"
autoFocus
/>
) : (
<h2
@@ -2738,7 +2739,6 @@ export const GalleryModal = React.memo(
{folderMenuOpen && (
<>
<div
role="presentation"
className="fixed inset-0 z-[58]"
onClick={() => setFolderMenuOpen(false)}
/>
@@ -3573,7 +3573,6 @@ export const GalleryModal = React.memo(
<>
{/* Backdrop to close */}
<div
role="presentation"
className="fixed inset-0 z-[59]"
onClick={() => setBulkFolderPopoverOpen(false)}
/>
@@ -3717,7 +3716,6 @@ export const GalleryModal = React.memo(
createPortal(
<>
<div
role="presentation"
className="fixed inset-0 z-[9998]"
onClick={() => setContextMenu(null)}
onContextMenu={(e) => {
@@ -87,10 +87,10 @@ export function useMediaPromptTokens(
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list, order-independent.
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
const key = [...new Set(cleaned)].sort().join(",");
useEffect(() => {
if (key.length > 0) void fetchMissing(key.split(","));
if (key.length > 0) fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, string>();
@@ -60,10 +60,10 @@ export function usePrompts(
}
// Stable cache key for the effect's dep list. Sorting keeps it stable when
// a poll returns the same set in a different order.
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
const key = [...new Set(cleaned)].sort().join(",");
useEffect(() => {
if (key.length > 0) void fetchMissing(key.split(","));
if (key.length > 0) fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, Prompts>();
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import HelpMenuButton from './help-menu';
@@ -9,11 +9,4 @@ describe('HelpMenuButton', () => {
expect(baseElement).toBeTruthy();
});
it('opens the update dialog from Help', async () => {
render(<HelpMenuButton />);
fireEvent.click(screen.getByRole('button', { name: 'Help' }));
fireEvent.click(await screen.findByText('Check for Updates…'));
expect(await screen.findByRole('dialog', { name: 'Check for Updates' }, { timeout: 5000 })).toBeTruthy();
expect(screen.getByText('The web app updates when you reload. Open Releases to get the desktop app.')).toBeTruthy();
});
});
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRightIcon, BookIcon, ChevronLeftIcon, CircleHelpIcon, NewspaperIcon, RefreshCwIcon } from "lucide-react";
import { ArrowRightIcon, BookIcon, ChevronLeftIcon, CircleHelpIcon, NewspaperIcon } from "lucide-react";
import { DiscordIcon, GithubIcon, YoutubeIcon } from "@storyteller/icons";
import { OpenUrl } from "@storyteller/tauri-api";
import { Modal } from "@storyteller/ui-modal";
@@ -8,9 +8,6 @@ import { useTutorialModalStore } from "./help-menu-store";
import { Button } from "@storyteller/ui-button";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { getNewsPosts, markdownToHtml } from "@storyteller/markdown-content";
import { clickOnEnterOrSpace } from "@storyteller/common";
import { UpdateDialog } from "./update-dialog";
export type HelpMenuButtonProps = {
items?: TutorialItem[];
@@ -45,7 +42,7 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
console.warn("Failed to fetch news feed, using bundled content.", e);
}
};
void fetchNews();
fetchNews();
}, []);
const selectedPost = useMemo(
@@ -112,9 +109,6 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
<div className="flex flex-col gap-4 mt-2 h-[500px] overflow-y-auto pr-2 custom-scrollbar">
{items.map((item) => (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
key={item.slug}
onClick={() => setSelectedSlug(item.slug)}
className="bg-white/5 hover:bg-white/[0.07] border border-white/15 hover:border-white/25 p-4 rounded-none cursor-pointer transition-colors group"
@@ -152,7 +146,6 @@ export function HelpMenuButton({
onOpenChange,
}: HelpMenuButtonProps) {
const [open, setOpen] = useState(false);
const [updatesOpen, setUpdatesOpen] = useState(false);
const tutorials = useMemo(() => items ?? defaultTutorials, [items]);
const view = useTutorialModalStore((s) => s.view);
const selected = useTutorialModalStore((s) => s.selected);
@@ -183,7 +176,6 @@ export function HelpMenuButton({
// Menu items for the popover
const menuItems: PopoverItem[] = [
{ label: "Check for Updates…", selected: false, icon: <RefreshCwIcon className="text-base" />, action: "updates" },
{
label: "Tutorials",
selected: false,
@@ -218,9 +210,6 @@ export function HelpMenuButton({
const handleMenuSelect = (item: PopoverItem) => {
switch (item.action) {
case "updates":
setUpdatesOpen(true);
break;
case "tutorials":
handleOpenTutorials();
break;
@@ -323,7 +312,7 @@ export function HelpMenuButton({
});
};
void run();
run();
return () => {
if (progressTimerRef.current) {
@@ -347,7 +336,6 @@ export function HelpMenuButton({
return (
<div className={className}>
<UpdateDialog isOpen={updatesOpen} onClose={() => setUpdatesOpen(false)} />
<PopoverMenu
items={menuItems}
onSelect={handleMenuSelect}
@@ -1,106 +0,0 @@
import { StrictMode } from "react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isTauri } from "@tauri-apps/api/core";
import { CheckForUpdates, InstallUpdate, OpenUrl, type UpdateInfo } from "@storyteller/tauri-api";
import { UpdateDialog } from "./update-dialog";
vi.mock("@tauri-apps/api/core", () => ({ isTauri: vi.fn() }));
vi.mock("@storyteller/tauri-api", () => ({ CheckForUpdates: vi.fn(), InstallUpdate: vi.fn(), OpenUrl: vi.fn() }));
vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, variant: _variant, ...props }: any) => <button {...props}>{children}</button> }));
vi.mock("@storyteller/ui-modal", () => ({ Modal: ({ isOpen, title, onClose, showClose, children }: any) => isOpen ? (
<section role="dialog" aria-label={title}><button aria-label="Close" disabled={!showClose} onClick={onClose}>×</button>{children}</section>
) : null }));
const INFO: UpdateInfo = { current_version: "0.42.0", maybe_available_version: "0.42.1", can_install: true,
tasks_running: false, releases_url: "https://github.com/storytold/artcraft/releases" };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(CheckForUpdates).mockResolvedValue(INFO);
vi.mocked(InstallUpdate).mockResolvedValue(undefined);
});
afterEach(cleanup);
describe("manual updates", () => {
it("does not check while closed and cancelling never installs", async () => {
const close = vi.fn();
const view = render(<UpdateDialog isOpen={false} onClose={close} />);
expect(CheckForUpdates).not.toHaveBeenCalled();
view.rerender(<UpdateDialog isOpen onClose={close} />);
await screen.findByText("0.42.1 is available.");
expect(screen.getByText("ArtCraft 0.42.0")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(close).toHaveBeenCalledOnce();
expect(InstallUpdate).not.toHaveBeenCalled();
});
it("shares StrictMode checks while progress remains visible", async () => {
let complete!: (info: UpdateInfo) => void;
vi.mocked(CheckForUpdates).mockReturnValue(new Promise((resolve) => { complete = resolve; }));
render(<StrictMode><UpdateDialog isOpen onClose={vi.fn()} /></StrictMode>);
expect(CheckForUpdates).toHaveBeenCalledOnce();
expect(screen.getByRole("status").textContent).toContain("Checking for updates");
expect((screen.getByRole("button", { name: "Check again" }) as HTMLButtonElement).disabled).toBe(true);
await act(async () => { complete(INFO); });
expect(screen.getByText("0.42.1 is available.")).toBeTruthy();
});
it("requires an install click and prevents dismissal during installation", async () => {
vi.mocked(InstallUpdate).mockReturnValue(new Promise(() => {}));
const close = vi.fn();
render(<UpdateDialog isOpen onClose={close} />);
await screen.findByText("0.42.1 is available.");
expect(InstallUpdate).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Update and restart" }));
expect(InstallUpdate).toHaveBeenCalledOnce();
expect(screen.getByRole("status").textContent).toContain("Downloading and verifying");
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
expect((screen.getByRole("button", { name: "Close" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement).disabled).toBe(true);
expect(close).not.toHaveBeenCalled();
});
it("reports a check failure and allows another manual check", async () => {
vi.mocked(CheckForUpdates).mockRejectedValueOnce("Network unavailable");
render(<UpdateDialog isOpen onClose={vi.fn()} />);
expect((await screen.findByRole("alert")).textContent).toBe("Network unavailable");
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
await screen.findByText("0.42.1 is available.");
expect(CheckForUpdates).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("alert")).toBeNull();
});
it("restores controls after verification fails without closing the app", async () => {
vi.mocked(InstallUpdate).mockRejectedValue("Signature verification failed");
const close = vi.fn();
render(<UpdateDialog isOpen onClose={close} />);
await screen.findByText("0.42.1 is available.");
fireEvent.click(screen.getByRole("button", { name: "Update and restart" }));
expect((await screen.findByRole("alert")).textContent).toBe("Signature verification failed");
expect((screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
expect(close).not.toHaveBeenCalled();
});
it.each([false, true])("cannot install a managed or busy copy (tasks running: %s)", async (running) => {
vi.mocked(CheckForUpdates).mockResolvedValue({ ...INFO, can_install: false, tasks_running: running });
render(<UpdateDialog isOpen onClose={vi.fn()} />);
await screen.findByText("0.42.1 is available.");
const button = screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(InstallUpdate).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Releases" }));
expect(OpenUrl).toHaveBeenCalledWith(INFO.releases_url);
});
it("offers browser users a reload without invoking native commands", () => {
vi.mocked(isTauri).mockReturnValue(false);
render(<UpdateDialog isOpen onClose={vi.fn()} />);
expect(screen.getByRole("status").textContent).toContain("updates when you reload");
expect(CheckForUpdates).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Update and restart" })).toBeNull();
});
});
@@ -1,90 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { isTauri } from "@tauri-apps/api/core";
import { CheckForUpdates, InstallUpdate, OpenUrl, type UpdateInfo } from "@storyteller/tauri-api";
import { Button } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
const RELEASES = "https://github.com/storytold/artcraft/releases";
export function UpdateDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const pendingCheck = useRef<Promise<UpdateInfo> | null>(null);
const desktop = isTauri();
const check = async () => {
setChecking(true);
setError(null);
setInfo(null);
try {
// StrictMode or a repeated click must share one native background check.
const pending = pendingCheck.current ?? CheckForUpdates();
pendingCheck.current = pending;
setInfo(await pending);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
pendingCheck.current = null;
setChecking(false);
}
};
useEffect(() => {
if (isOpen && desktop) void check();
// A check is manual: reopening this dialog is the only automatic trigger.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, desktop]);
const install = async () => {
if (!info?.can_install || installing) return;
setInstalling(true);
setError(null);
try {
await InstallUpdate();
// Success closes the application through its normal native exit path.
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setInstalling(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={() => { if (!installing) onClose(); }}
title="Check for Updates"
accessibleTitle="Check for Updates"
className="max-w-lg"
showClose={!installing}
closeOnEsc={!installing}
closeOnOutsideClick={!installing}
>
<div className="flex flex-col gap-4 text-base-fg">
<p>ArtCraft{info ? ` ${info.current_version}` : ""}</p>
<div role="status" aria-live="polite">
{!desktop ? "The web app updates when you reload. Open Releases to get the desktop app."
: installing ? "Downloading and verifying the update… ArtCraft will restart when it is ready."
: checking ? "Checking for updates…"
: info?.maybe_available_version ? `${info.maybe_available_version} is available.`
: info ? "You're up to date." : null}
</div>
{error && <p role="alert">{error}</p>}
{info?.maybe_available_version && (
<p className="text-sm text-base-fg/70">
{info.tasks_running ? "Finish or cancel running tasks, then check again before updating."
: info.can_install ? "Save your work before updating. ArtCraft will close and restart."
: "Use Releases or your package manager to update this installation."}
</p>
)}
<div className="flex flex-wrap justify-end gap-2">
<Button variant="action" disabled={installing} onClick={() => OpenUrl(info?.releases_url ?? RELEASES)}>Releases</Button>
{desktop && <Button variant="action" disabled={checking || installing} onClick={() => void check()}>Check again</Button>}
{info?.maybe_available_version && <Button variant="primary" disabled={!info.can_install || checking || installing} onClick={() => void install()}>Update and restart</Button>}
{!installing && <Button variant="action" onClick={onClose}>Cancel</Button>}
</div>
</div>
</Modal>
);
}
@@ -104,7 +104,7 @@ export const InputVector = ({
} as Record<string, string>;
const newValue =
Number.parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
newVector[ref.current?.name ?? ""] = (
Math.round(newValue * 1000) / 1000
@@ -22,10 +22,10 @@ export const NumberInput: React.FC<NumberInputProps> = ({
const newValue = event.target.value;
if (
newValue === "" ||
(!Number.isNaN(Number(newValue)) && Number(newValue) <= 100)
(!isNaN(Number(newValue)) && Number(newValue) <= 100)
) {
setInputValue(newValue);
const numericValue = Number.parseFloat(newValue);
const numericValue = parseFloat(newValue);
if (newValue !== "") {
onChange(Math.round(numericValue * 100) / 100);
}
@@ -42,7 +42,6 @@ import {
formatAspectRatio,
formatDuration,
formatResolution,
clickOnEnterOrSpace,
} from "@storyteller/common";
import { TagsSection } from "./tags/TagsSection";
@@ -180,7 +179,7 @@ export function LightboxModal({
audioPlayerRef.current = null;
};
audioPlayerRef.current = audio;
void audio.play();
audio.play();
setPlayingAudioToken(token);
}
},
@@ -963,16 +962,6 @@ export function LightboxModal({
}
>
<div
role={isVideoRef ? "presentation" : "button"}
tabIndex={isVideoRef ? undefined : 0}
aria-label={
isAudio
? `${isPlayingThis ? "Pause" : "Play"} reference ${index + 1} audio`
: isVideoRef
? undefined
: `Preview reference ${index + 1}`
}
onKeyDown={clickOnEnterOrSpace}
className="relative aspect-square w-12 overflow-hidden border border-white/15 bg-white/5 transition-colors hover:border-white/40 group cursor-pointer"
onClick={() => {
if (isAudio) {
@@ -141,7 +141,6 @@ export function TagChipInput({
return (
<div className="relative">
<div
role="presentation"
className={`flex flex-wrap items-center gap-1.5 rounded-[3px] bg-ui-panel border border-ui-panel-border focus-within:border-primary/60 px-3 py-2.5 ${
disabled ? "" : "cursor-text"
}`}
@@ -83,7 +83,7 @@ export function TagsSection({
useEffect(() => {
if (currentUsername !== undefined) return;
let cancelled = false;
void resolveSessionUsername().then((username) => {
resolveSessionUsername().then((username) => {
if (!cancelled) setResolvedUsername(username);
});
return () => {
@@ -133,7 +133,7 @@ export function TagsSection({
}
ownTagsLoadedRef.current = true;
let cancelled = false;
void (async () => {
(async () => {
const all: TagDetails[] = [];
let cursor: string | undefined = undefined;
for (let page = 0; page < 50; page++) {
@@ -89,9 +89,8 @@ export const ArtCraftSignUp = ({
{isSignUp ? (
<>
<div className="space-y-2">
<label htmlFor="signup-username" className={FIELD_LABEL}>Username</label>
<label className={FIELD_LABEL}>Username</label>
<Input
id="signup-username"
name="username"
placeholder="Username"
required
@@ -100,9 +99,8 @@ export const ArtCraftSignUp = ({
/>
</div>
<div className="space-y-2">
<label htmlFor="signup-email" className={FIELD_LABEL}>Email</label>
<label className={FIELD_LABEL}>Email</label>
<Input
id="signup-email"
name="email"
type="email"
placeholder="you@example.com"
@@ -114,9 +112,8 @@ export const ArtCraftSignUp = ({
</>
) : (
<div className="space-y-2">
<label htmlFor="signup-username-or-email" className={FIELD_LABEL}>Email or Username</label>
<label className={FIELD_LABEL}>Email or Username</label>
<Input
id="signup-username-or-email"
name="usernameOrEmail"
placeholder="you@example.com or username"
required
@@ -127,10 +124,9 @@ export const ArtCraftSignUp = ({
)}
<div className="space-y-2">
<label htmlFor="signup-password" className={FIELD_LABEL}>Password</label>
<label className={FIELD_LABEL}>Password</label>
<div className="relative">
<Input
id="signup-password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="Min. 8 characters"
@@ -151,10 +147,9 @@ export const ArtCraftSignUp = ({
{isSignUp && (
<div className="space-y-2">
<label htmlFor="signup-confirm-password" className={FIELD_LABEL}>Confirm Password</label>
<label className={FIELD_LABEL}>Confirm Password</label>
<div className="relative">
<Input
id="signup-confirm-password"
name="confirmPassword"
type={showConfirm ? "text" : "password"}
placeholder="Re-enter password"

Some files were not shown because too many files have changed in this diff Show More