Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 0cfc098ea0 ci: install xdg-utils for Linux AppImage bundling
The ubuntu-22.04-arm runner image does not ship /usr/bin/xdg-open, so
tauri-action failed while bundling the aarch64 AppImage and uploaded no
Linux artifacts. Install xdg-utils explicitly in the Linux build job.
2026-11-11 22:51:01 -05:00
Dylan M. Taylor 84a6a7dcf5 Report 0.42.1 in the macOS build config (#2836)
The 0.42.1 bump in #2835 updated `tauri.conf.json` and `version.rs` but
left `tauri-mac.conf.json` at 0.42.0, so the macOS release build would
report the wrong version.

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

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

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

## Summary by CodeRabbit

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

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

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

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

Validation: native all-target cargo check inside GNOME SDK 51; all six
focused Rust updater tests; all ten Help-menu/UI tests; TypeScript
project build; production frontend build; Flatpak permission regression
red before and green after. All six native SDK tests also pass at the
final 0.42.1 version. The existing lockfile needs compatible chrono and
crc32fast updates for the shared updater; other dependency revisions are
preserved.
2026-11-11 21:36:49 -05:00
Dylan M. Taylor a787c371f6 ci: use StepSecurity's maintained actions (#2832)
Each third-party action that StepSecurity publishes a secure drop-in
replacement for now uses that replacement, pinned to a commit with its
version as a comment. Versions follow what we used: the newest
StepSecurity release in the same major, or the closest major they
publish. dtolnay/rust-toolchain refs that named a toolchain (stable,
nightly, 1.76) become the replacement's `toolchain` input.

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

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

## Summary by CodeRabbit

* **Chores**
* Updated the Linux, macOS, and Windows release workflows to use pinned
build and publishing actions. Stable Rust toolchain selection and
existing target settings are preserved.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 14:57:24 -05:00
Dylan M. Taylor e546fff742 TabState: setActiveTab returns nothing instead of always true (Sonar S3516) (#2831)
`setActiveTab` returned `true` on every path, the last open Sonar issue
in this project (blocker, S3516). No caller reads the result, so it now
returns `Promise<void>`.

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

## Summary by CodeRabbit

* **Refactor**
* Tab selection works as before: selecting the active tab keeps it
selected, and selecting another tab switches to it. This update changes
how the selection operation reports its result internally, without
changing what users see or how tabs behave. No other user-facing changes
are included.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 14:43:57 -05:00
Dylan M. Taylor 1d79cdf52d ci: run Harden-Runner on every job (#2829)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have
it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:08:54 -05:00
Dylan M. Taylor 55e6e5cabb frontend: Sonar follow-ups (S8786, S6852, S7059) (#2824)
Two issues SonarCloud raised on main after #2821/#2822 merged:

- **typescript:S8786** `SceneState.ts` `base64ToFile`: #2821's
`/:([^;]*);/` is still flagged. The mime type is now read with
`indexOf`: the text between the first `:` and the `;` after it, else
`image/png`. Checked equal to the regex on `data:image/png;base64`,
`data:;base64`, a header with no `:` or `;`, and several `:`.
- **typescript:S6852** `MentionTextarea`: the `role="textbox"` editor
gets `tabIndex={disabled ? -1 : 0}`. A `contentEditable` element was
focusable already, so nothing changes for users.

Typechecked locally against main (no new errors).

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved image import handling so MIME types are identified more
reliably, with PNG used as a fallback when the type cannot be
determined.
* Disabled text editors are no longer reachable through sequential
keyboard navigation; enabled editors remain tabbable.
* Physics support is initialized during editor setup after the required
canvas and container elements are available.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
- **typescript:S7059** `editor.ts`: `ensureAmmoLoaded()` (an async load)
moves out of the `Editor` constructor to `EngineProvider`, the only
place an `Editor` is built, right before `new Editor(...)`. The loader
is a singleton, so the script is still appended once.
2026-11-10 22:51:32 -05:00
Dylan M. Taylor 7c8419fabd frontend: Number.parseFloat/parseInt/isFinite/isNaN (Sonar S7773) (#2819)
Clears the 82 open typescript:S7773 issues (35 files), only on the lines
Sonar flagged.

- `parseFloat` / `parseInt` → `Number.parseFloat` / `Number.parseInt`:
the same functions (`Number.parseFloat === parseFloat`).
- `isFinite` / `isNaN` → `Number.isFinite` / `Number.isNaN`: they differ
only for arguments that aren't numbers. TypeScript's lib types the
globals as taking `number`, and each call already passes one (`maxLen`,
`Number(value)`, a parsed count), so nothing changes. Typecheck is left
to CI.

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

## Summary by CodeRabbit

* **Improvements**
* Video frame extraction and watermark-removal pages now provide
consistent playback controls, including seeking, playback, mute, and
volume adjustment.
* Prompt-length indicators and validation consistently reflect
configured limits, including unlimited limits.
* **New Features**
* Prompt fields display a character counter that highlights text
exceeding the configured limit.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Also: rebased on main, and the length counter and over-limit check that
the five prompt boxes repeated are now `PromptLengthCounter` /
`isOverPromptLimit` (`promptbox/src/lib/PromptLengthCounter.tsx`). That
removes the duplication the new-code gate counted against this PR.
2026-11-10 22:40:36 -05:00
FredAppsandDylan M. Taylor 130bfd71d6 libs/components: mark fire-and-forget promises with void (Sonar S9383) (#2807)
## Summary

Resolves all 66 SonarCloud **typescript:S9383** findings ("Promises must
be awaited, end with a call to .catch, end with a call to .then with a
rejection handler or be explicitly marked as ignored with the `void`
operator") in `frontend/libs/components`, across 41 files:

| Library                | Findings |
|------------------------|----------|
| `upload-modal`         | 14       |
| `promptbox`            | 11       |
| `settings-modal`       | 11       |
| `pagescene`            | 10       |
| `video-editor`         | 7        |
| `lightbox-modal`       | 3        |
| `pagedraw`             | 3        |
| `generation-list`      | 2        |
| `help-menu`            | 2        |
| `gallery-modal`        | 1        |
| `pricing-modal`        | 1        |
| `provider-setup-modal` | 1        |

The flagged expressions include settings and session loaders
(`fetchData()`, `fetchSession()`) called from effects, upload starts and
retries in the upload modals, enqueue handlers in the prompt boxes,
`audio.play()` and `el.play()` previews, scene render and load calls in
`pagescene`, bookmark actions in `video-editor`, and async IIFEs.

This is part 3 of 3 for artcraft's 157 S9383 findings. Part 1 (Tauri
libraries) is #2805, and part 2 (`apps/artcraft/app`) is #2806.

Some of these files are also changed by open PRs for unrelated reasons,
for example #2615 (settings panes), #2519, #2520 and #2083. Each change
here inserts one word at the start of a statement, so a rebase either
way is trivial.

## Approach

This ports the approach of storytold/artcraft-services#179, which fixes
the same rule in artcraft-services. **#179 is still open and unmerged.**
This PR follows its approach and does not depend on it.

- Each flagged expression is a fire-and-forget promise: a listener
`setup()`, an `unlisten.then(f => f())` cleanup, a loader called from an
effect, an upload kicked off from a handler, or an async IIFE. Prefixing
it with `void` marks the ignored promise explicitly, which is the fix
the rule documents.
- It deliberately does not add `.catch()` everywhere. These call sites
already handle failures inside the async function or surface them
through their own state, and empty handlers would hide errors.
- Async IIFEs become `void (async () => …)()`. `if (cond) call()`
becomes `if (cond) void call()`.
- There is no behaviour change. `void` only discards the promise value,
which was already being discarded.

Each insertion is placed at the exact `textRange` start offset of the
SonarCloud finding, with no other edits.

## Testing

- Each changed file parses with `esbuild <file>`, using the same check
as #179.
- I parsed each changed file with the TypeScript 5.9 compiler API
(`createSourceFile`). That found no parse errors, and every inserted
`void` is either a whole expression statement or an arrow-function body
applied to a call, never part of a larger expression.
- `frontend/.eslintrc.json` has no `no-void` rule.
- I did not run the full Nx type-check, lint or test targets, because
`frontend/node_modules` is not installed in my environment. Please rely
on CI.
- No tests are added. Discarding an already-discarded promise value is
not observable, so no test can fail before this change and pass after
it.

**Added by @dylanmtaylor, to clear the new-code duplication gate.** The
`void` lines sat inside copy-pasted code:
- `ProviderAccountBlock` replaces the identical `GrokAccountBlock` and
`MidjourneyAccountBlock`.
- `UploadFilesPanels` (file list, resize handle, previous/next; each
modal passes its preview) replaces the layout the 3D, Image and Splat
upload modals repeated.

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

## Summary by CodeRabbit

* **New Features**
* Multi-file uploads across image, 3D, and splat workflows now share a
two-panel layout with file statuses, preview selection, removal, retry,
and previous/next navigation.
* Grok and Midjourney account controls now use a shared interface that
displays login status and provides available connect or disconnect
actions. Failed credential checks clear the displayed session, and
account controls show a loading state while checking.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-11-10 22:39:17 -05:00
Dylan M. Taylor d6772fdd94 frontend: uuidv4 for local ids instead of Math.random (Sonar S2245) (#2820)
Clears the 38 open typescript:S2245 issues (18 files).

Each `Math.random().toString(36)…` made a local id: an upload row, a
reference title, a batch or subscriber id, an idempotency token, an
image cache-buster. They now use `uuidv4()` from `uuid`, which is
already a dependency and imported the same way elsewhere (`import { v4
as uuidv4 } from "uuid"`). It reads `crypto.getRandomValues`, so it also
works where `crypto.randomUUID` doesn't (outside a secure context). The
`crypto.randomUUID ? … : Math.random()…` fallbacks therefore collapse to
`uuidv4()`, and `subscriberId ? subscriberId : …` becomes `subscriberId
|| uuidv4()`.

Ids are now 36 characters instead of 5–13. Nothing parses or compares
their length. Typecheck is left to CI.

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

## Summary by CodeRabbit

* **Improvements**
* Creative tools now handle identifiers more consistently for generated
content, uploads, reference media, and generation batches.
* Completed generation results are displayed more reliably when there
isn’t a matching pending result.
* These improvements span image, video, audio, 3D, drawing, and
visual-effects workflows, helping keep related media and results
organized across the app.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Also: `ImageTo3DStore` and `ImageTo3DWorldStore` shared the same 30-line
`completeGeneration` (find the subscriber's result, else the first
pending one, and mark it completed, or add a completed one). It's now
one `completeGenerationResult` in `pages/Stores/`, which removes the
duplication the new-code gate counted against this PR.
2026-11-10 21:49:49 -05:00
Dylan M. Taylor 0c11395467 Sonar: clear the shell, workflow, Rust and HTML findings (#2818)
Clears the open SonarCloud issues outside the TypeScript frontend,
except the Rust ones, which moved to #2823 so each PR stays small.
Compile and tests are left to CI.

**Security**
- `frontend/api_docs.html` (Web:S5725): `integrity` (sha384 of the
pinned `swagger-ui-dist@5.32.0` files) and `crossorigin="anonymous"` on
the three unpkg assets, over `https:`.
- S6505: `--ignore-scripts` on `npm install` / `npm ci` in the dev and
build scripts and in the macOS and Windows publish workflows.
`artcraft-linux-publish.yml` already builds with `npm ci
--ignore-scripts`, and the frontend has no install scripts of its own.
- S7637: `dtolnay/rust-toolchain@stable` and
`tauri-apps/tauri-action@v0` pinned by commit SHA. For tauri-action
that's the commit `v0` points to (`84b9d35…`). The Linux workflow had
pinned the tag object instead, and is fixed the same way.
- S6505 / S8543: the macOS build runs `./node_modules/.bin/nx` instead
of `npx nx`, as the Linux workflow does.

**Reliability**
- shell:S7688: `[[ ]]` in place of `[` / `test` in
`frontend_preflight.sh` and the Linux packaging scripts.

**Analysis scope** (`sonar-project.properties`), each with its reason in
the file:
- Not analysed: `crates/vendor/**` (third-party), the upstream Python
reference implementations beside the Grok and Sora clients (nothing runs
them), and the hand-run developer tools `frontend/scripts/unix-dev.mjs`
and `frontend/tools/performance/**`. These held the S8564, S1244, S2245,
S4036, S5443 and S8707 findings.
- rust:S4347 not raised on `DeterministicRng`, the fixed-seed generator
for tests.

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

## Summary by CodeRabbit

* **Improvements**
* File downloads and database maintenance now use asynchronous
operations, helping the app remain responsive during these tasks.
  * API documentation assets load over HTTPS with integrity checks.
* **Build and Reliability**
* Build and publishing workflows use pinned tool versions and skip
package lifecycle scripts during dependency installation.
  * Linux packaging checks use consistent shell conditionals.
* Frontend builds use the locally installed Nx command where applicable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
- typescript:S2187 not raised on the ApiManager tests, which build their
cases with shared helpers that call `it` (`BetaKeysApi.test.ts`).
typescript:S2819 not raised on `*.worker.ts`: a dedicated worker's
messages come only from the page that started it.
2026-11-10 21:39:12 -05:00
Dylan M. Taylor 773e5925b9 Rust: tokio::fs in async functions, TaskId Display, refresh token out of source (Sonar) (#2823)
The Rust part of #2818, split out so each PR stays small. Clears the
open rust:S7493, rust:S7458 and secrets:S6418 issues. Compile and tests
are left to CI.

- rust:S7493: `tokio::fs` in place of blocking `std::fs` inside async
functions (`auto_download`, `simple_http_download`, the
fal/Sora/WorldLabs downloads, the `sqlite_tasks` reset), as in
#2803/#2804. The downloaded bytes are handed to `tokio::fs::write` owned
(no copy), per CodeRabbit on #2818. `sqlite_tasks` now depends on
workspace `tokio` with `fs` (one line in `Cargo.lock`).
- rust:S7458: `TaskId`'s inherent `to_string` is removed. `Display`
gives the same string.
- secrets:S6418: the `#[ignore]` WorldLabs refresh-token test reads
`WORLDLABS_REFRESH_TOKEN` instead of holding a token in source. ⚠️ The
token is still in git history, so its owner should revoke it.

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

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

## Summary by CodeRabbit

* **Refactor**
* Downloaded files and task checkpoints are now handled using
asynchronous file operations across several generation workflows.
* Database-file cleanup during connection and migration is also
asynchronous; removal errors continue to be logged, and migration
proceeds.
* These changes preserve the existing download, checkpoint, and
error-handling behavior while reducing blocking file operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 21:38:23 -05:00
Dylan M. Taylor 8d6fc363b1 frontend: roles, keyboard access and label association (Sonar S6848, S6853) (#2822)
Clears the 59 open typescript:S6848 issues ("non-native interactive
element without a role").

- **Clickable things that act like buttons (16)**: upload and preview
tiles, storyboard frames, help items, reference thumbnails, remove
buttons, deck cards, asset items, popover menu items. They get
`role="button"`, `tabIndex={0}` and `onKeyDown={clickOnEnterOrSpace}`,
the pattern `GalleryRow` already uses. `clickOnEnterOrSpace` (new,
`@storyteller/common`) calls the element's own `click()`, so each
`onClick` runs unchanged. Menu items also get `aria-disabled` when
disabled (their `onClick` already ignores the click). The bulk-select
box is `role="checkbox"` with `aria-checked`. In `ImagePromptRow` the
attributes come before dnd-kit's `attributes`/`listeners` spread, so
when reordering is on, dnd-kit's own role and keyboard sensor still win.
- **Not controls (42)**: modal and menu backdrops, wrappers that only
stop event propagation, mouse-only drag/resize handles and slider
tracks, trigger wrappers around a child button, the tooltip body. They
get `role="presentation"`, which says what they already are to assistive
tech. Nothing changes for mouse users.
- **The prompt editor** (`contentEditable` in `MentionTextarea`) is
`role="textbox"` with `aria-multiline="true"`.

Mouse behaviour is unchanged. Keyboard users can now Tab to and activate
the 16 button-like elements. Typecheck is left to CI.

**typescript:S6853 (13)**: labels tied to their controls. In the
storyboard metadata editor, the signup form and the cost modal, each
label gets `htmlFor` and its control a matching `id` (`Input` and
`Select` pass `id` through, and `Select`'s goes on its `ListboxButton`).
These render once per page. The two colour-picker labels, which already
wrap their `<input type="color">`, get `sr-only` text in place of their
`title`-only name.

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

## Summary by CodeRabbit

* **Accessibility**
* Added keyboard access to image tiles, asset cards, list items, and
other controls, including Enter and Space activation.
* Improved screen-reader support with clearer labels for controls and
thumbnails, and associations between form labels and fields.
* Updated accessibility semantics for decorative content and interactive
elements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 20:22:41 -05:00
Dylan M. Taylor f9f4c99e4f frontend: Sonar reliability fixes (promises in conditions, async executors, identical branches, reduce, regex) (#2821)
Clears 51 open TypeScript issues. Typecheck and build are left to CI.

**typescript:S6544 (17)**
- A cached promise checked by truthiness (`if (existing)`, `if
(cached)`, `if (ammoLoadPromise)`…) is checked as `!== undefined` / `!==
null` against its map or field type. Same branches, since a Promise is
always truthy.
- `scene.ts` `load_glb_wrapped_no_cors` / `load_splat_wrapped_no_cors`:
the fetch moves out of the `async` Promise executor. The methods were
already `async`, so a failed fetch now `throw`s (rejects the same way),
and only the callback APIs (`GLTFLoader.parse`, `SplatMesh` `onLoad`)
stay inside `new Promise`.
- `SceneManagerAPI` declares the promises its implementation returns
(`create`/`retrieve` → `Promise<THREE.Object3D | undefined>`,
`update`/`delete` → `Promise<void>`). Nothing calls through the
interface.
- `convertFbxToGlb.worker.ts`: `onmessage` calls `void convert(event)`.

**typescript:S3923 / S1764 / S3516 / S905 (12)**: identical branches
collapsed (`variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]"`,
`ScissorIcon : ScissorIcon`, `index % 2 === 0 ? point + offset : point +
offset`, PromptBox3D's two `setIsEnqueueing(false); return;` arms,
`{DEFAULT_GRID_CONFIG ? null : null}` and its now-unused import), `x &&
x`, the `this.gridHelper;` statement, and `CurrentUserBadge`, which
always rendered `null` (its markup was commented out).

**typescript:S6959 (5)**: each `reduce` starts from the first element.
Every one is behind an emptiness check, and comparing the first element
with itself keeps it.

**typescript:S2871 (4)**: `sort((a, b) => a.localeCompare(b))` for the
order-independent cache keys and the board and font name lists.

**typescript:S8786 (8)**
- Markdown link text is `[^\][]+`. `@image(url, size)` drops the `\s*`
before the size (it's `.trim()`ed already). ATX headings `trimStart()`
the text instead of `\s*`. Slugs trim one `-` (runs were already
collapsed to one).
- `color.ts` `stripCssNoise`: `!important` is replaced by a space, then
trailing `;` are trimmed in a loop.
- Data-URL mime: `/:([^;]*);/`.
- `ControlPanelSceneObject` number check: `/^-?\d*(\.\d*)?$/`. The old
`.` was unescaped and matched any character, so `12a3` passed as a
number.

**Others**: `window.open(…, "noopener,noreferrer")` (S5148).
`AlertTitle` renders `children` explicitly (S6850). The folder name hash
wraps with `Math.imul(…, 1)` instead of `| 0`, the same int32 result
(S7767). `autoFocus` is removed from the two rename inputs, which their
refs already focus and select (S9379).

**Not here**: S7059 in `editor.ts` (`ensureAmmoLoaded()` in the
constructor) is on a line #2807 changes.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability when loading 3D scene assets and completing
drag-and-drop operations.
* Corrected numeric input validation, scene import handling, and video
duration tie handling.
* Improved media, audio, and video cache handling to avoid unnecessary
retries or missed waits.
* Refined markdown parsing for links, headings, image directives, and
heading anchors.
  * Discord invites now open with additional browser protections.
  * Improved CSS color cleanup and handling of pasted scene elements.
* **Improvements**
* Made ordering of tags, fonts, and prompt-related items more
consistent.
  * Improved video editor timeline selections.
* Removed the non-functional account badge from the video editor header.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

**typescript:S1848 (2)**: `new SplatMesh({ …, onLoad })` created an
instance only for its callback. `scene.ts` now awaits `mesh.initialized`
(Spark's promise, which resolves after load), so a failed load rejects
instead of leaving the promise pending. `viewer-3d.tsx` runs the same
body in `initialized.then`, with a `.catch` that logs.

Kept as is: `TabState.setActiveTab` still resolves `true` (S3516 stays
open), because `TabState.spec.ts` and the tab-change benchmark read its
result.
2026-11-10 20:22:36 -05:00
FredApps 71adfd4701 artcraft app: mark fire-and-forget promises with void (Sonar S9383) (#2806)
## Summary

Resolves all 38 SonarCloud **typescript:S9383** findings ("Promises must
be awaited, end with a call to .catch, end with a call to .then with a
rejection handler or be explicitly marked as ignored with the `void`
operator") in `frontend/apps/artcraft/app`, across 19 files. They are:

- startup calls in `index.tsx`, such as `SyncStorytellerApiConfig()` and
`persistLogin()`
- polling in `useActiveJobs`, `useBackgroundLoadingMedia`,
`signals/activeJobs.ts` and `signals/authentication/fetchers.ts`
- listener cleanups (`unlisten*.then(f => f())`) in `TaskQueue` and
`useDesktopGenerationFeed`
- UI handlers that start uploads or renames, such as
`ImageTo3DExperience`, `UploadFilesMedia`, `SceneTitleInput`,
`ProfileDropdown` and `DesktopGenerationGallery`
- async IIFEs in `useDesktopUsername`, `TaskQueue` and
`BaseImageSelector`

This is part 2 of 3 for artcraft's 157 S9383 findings. Part 1 (Tauri
libraries) is #2805. Part 3 (`libs/components`) is #2807.

Several of these files are also changed by open PRs for unrelated
reasons, for example #2519, #2520, #2615 and #2064. Each change here
inserts one word at the start of a statement, so a rebase either way is
trivial.

## Approach

This ports the approach of storytold/artcraft-services#179, which fixes
the same rule in artcraft-services. **#179 is still open and unmerged.**
This PR follows its approach and does not depend on it.

- Each flagged expression is a fire-and-forget promise: a listener
`setup()`, an `unlisten.then(f => f())` cleanup, a loader called from an
effect, an upload kicked off from a handler, or an async IIFE. Prefixing
it with `void` marks the ignored promise explicitly, which is the fix
the rule documents.
- It deliberately does not add `.catch()` everywhere. These call sites
already handle failures inside the async function or surface them
through their own state, and empty handlers would hide errors.
- Async IIFEs become `void (async () => …)()`. `if (cond) call()`
becomes `if (cond) void call()`.
- There is no behaviour change. `void` only discards the promise value,
which was already being discarded.

Each insertion is placed at the exact `textRange` start offset of the
SonarCloud finding, with no other edits.

## Testing

- Each changed file parses with `esbuild <file>`, using the same check
as #179.
- I parsed each changed file with the TypeScript 5.9 compiler API
(`createSourceFile`). That found no parse errors, and every inserted
`void` is either a whole expression statement or an arrow-function body
applied to a call, never part of a larger expression.
- `frontend/.eslintrc.json` has no `no-void` rule.
- I did not run the full Nx type-check, lint or test targets, because
`frontend/node_modules` is not installed in my environment. Please rely
on CI.
- No tests are added. Discarding an already-discarded promise value is
not observable, so no test can fail before this change and pass after
it.


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

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

## Summary by CodeRabbit

* **Chores**
* Updated background tasks across uploads, sign-in, polling, scene
editing, and other app workflows to handle asynchronous operations
consistently.
* Existing workflows and their success and error handling remain
unchanged; no user-facing behavior changes are expected.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 21:35:11 +01:00
FredApps c9da5cac18 tauri-events/tauri-api/tauri-utils: mark fire-and-forget promises with void (Sonar S9383) (#2805)
## Summary

Resolves all 53 SonarCloud **typescript:S9383** findings ("Promises must
be awaited, end with a call to .catch, end with a call to .then with a
rejection handler or be explicitly marked as ignored with the `void`
operator") in the Tauri frontend libraries:

| Library | Findings | What is marked |

|----------------------------|----------|------------------------------------------------------------------------|
| `frontend/libs/tauri-events` | 48 | `setup()` and the `unlisten.then(f
=> f())` cleanups in each `use*Event` hook |
| `frontend/libs/tauri-api` | 4 | Same pattern in
`CanvasBgRemovedEvent`, plus `setupListener()` in `useSoraLoginListener`
|
| `frontend/libs/tauri-utils` | 1 | The async IIFE in
`useTauriWindowControls` |

This is part 1 of 3 for artcraft's 157 S9383 findings. Part 2
(`apps/artcraft/app`) is #2806, and part 3 (`libs/components`) is #2807.

## Approach

This ports the approach of storytold/artcraft-services#179, which fixes
the same rule in artcraft-services. **#179 is still open and unmerged.**
This PR follows its approach and does not depend on it.

- Each flagged expression is a fire-and-forget promise: a listener
`setup()`, an `unlisten.then(f => f())` cleanup, a loader called from an
effect, an upload kicked off from a handler, or an async IIFE. Prefixing
it with `void` marks the ignored promise explicitly, which is the fix
the rule documents.
- It deliberately does not add `.catch()` everywhere. These call sites
already handle failures inside the async function or surface them
through their own state, and empty handlers would hide errors.
- Async IIFEs become `void (async () => …)()`. `if (cond) call()`
becomes `if (cond) void call()`.
- There is no behaviour change. `void` only discards the promise value,
which was already being discarded.

Each insertion is placed at the exact `textRange` start offset of the
SonarCloud finding, with no other edits.

## Testing

- Each changed file parses with `esbuild <file>`, using the same check
as #179.
- I parsed each changed file with the TypeScript 5.9 compiler API
(`createSourceFile`). That found no parse errors, and every inserted
`void` is either a whole expression statement or an arrow-function body
applied to a call, never part of a larger expression.
- `frontend/.eslintrc.json` has no `no-void` rule.
- I did not run the full Nx type-check, lint or test targets, because
`frontend/node_modules` is not installed in my environment. Please rely
on CI.
- No tests are added. Discarding an already-discarded promise value is
not observable, so no test can fail before this change and pass after
it.


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

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

## Summary by CodeRabbit

* **Refactor**
* Clarified that asynchronous event listener setup, cleanup, and
window-control initialization run without blocking. Existing
subscription behavior and cleanup conditions remain unchanged; no
user-facing functionality changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 21:21:36 +01:00
FredApps 83aad5eb54 Exclude TypeScript and JavaScript from Sonar coverage (#2813)
## Summary

Adds `sonar.coverage.exclusions=**/*.ts,**/*.tsx,**/*.js,**/*.jsx` to
`sonar-project.properties`. There were no existing exclusion entries.

The shared SonarCloud workflow only publishes the Rust lcov report
(`sonar.rust.lcov.reportPaths=lcov.info`). Artcraft's workflow wrapper
does not pass a TypeScript or JavaScript coverage report, so those files
can never appear covered.

That is why [#2805](https://github.com/storytold/artcraft/pull/2805) and
[#2806](https://github.com/storytold/artcraft/pull/2806) fail the
quality gate on **new_coverage** (0.0% against the 80% target) after the
organisation was recreated. They only add `void` in front of existing
TypeScript calls:

- #2805: 53 new lines to cover, all uncovered
- #2806: 38 new lines to cover, all uncovered

[#2807](https://github.com/storytold/artcraft/pull/2807) also touches
TypeScript (`void` in `libs/components`) and is listed here for the same
exclusion, but its current gate failure is duplicated new code (10.6%),
which this PR does not address.

Same approach as
[anicraft#106](https://github.com/storytold/anicraft/pull/106), which
excluded JS, PowerShell and Python for the same reason. filmcraft and
lightcraft already exclude `**/tools/**,**/docs/**,**/examples/**`. This
PR uses extensions because the uncovered TypeScript and JavaScript live
under `frontend/`.

No product code changes. No open PR already edits
`sonar-project.properties`.

## Testing

Visual review of `sonar-project.properties` against anicraft, filmcraft
and lightcraft, and of the shared `sonarcloud.yml` coverage inputs. No
Rust or frontend tests were run (properties file only). The quality-gate
effect on #2805 and #2806 will be visible after this merges and those
branches are updated from main.

- [x] Clean room: no proprietary code, scripts or assets copied
(AGENTS.md)

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

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

## Summary by CodeRabbit

* **Chores**
* Updated project configuration for coverage reporting. No user-facing
changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 21:07:25 +01:00
FredApps 50afb36f9c api_clients: use tokio::fs inside async functions (Sonar S7493) (#2803)
## Summary

Replaces blocking `std::fs` calls inside `async fn`s with `tokio::fs` in
four API client crates. SonarCloud reports these as **rust:S7493** ("use
an asynchronous file API instead of this blocking file operation"). This
PR resolves 13 of the 40 artcraft findings.

| Crate | File | Change |

|---------------------|-------------------------------------------------------------|------------------------------------------------------------|
| `artcraft_client` | `endpoints/media_files/*_from_file.rs` (4 files) |
`std::fs::read` → `tokio::fs::read(..).await` |
| `artcraft_client` | `recipes/download_media_file.rs` | `File::create`
+ `write_all` + `flush` → `tokio::fs::write` |
| `artcraft_router` | `generate_video/providers/kinovi/upload.rs` |
`std::fs::read` → `tokio::fs::read(..).await` |
| `midjourney_client` | `credentials/credential_manager.rs`
(`clear_credentials`) | `std::fs::remove_file` →
`tokio::fs::remove_file(..).await` |
| `openai_sora_client`|
`requests/deprecated/job_status/sora_job_status.rs` | `create_dir_all` /
`File::create` + `write_all` → `tokio::fs` |

The workspace `tokio` dependency enables only `macros`.
`artcraft_client` (which had `tokio` only as a dev-dependency) and
`artcraft_router` now declare `tokio = { workspace = true, features =
["fs"] }`. `midjourney_client` and `openai_sora_client` already use
`tokio` with `full`. `Cargo.lock` does not change. In
`save_generations_to_dir`, the blocking `dir.exists()` check before
`create_dir_all` is dropped, because `create_dir_all` already succeeds
when the directory exists.

The behaviour stays the same: same paths, same error mapping, and the
same `NotFound` handling in `clear_credentials`.

## Not included

- `crates/schema/database/sqlite_tasks/src/connection.rs:25` (S7493):
#2360 removes this `remove_file` entirely, so it's left to that PR.
- `crates/desktop/artcraft` (26 findings): handled in a separate PR.

#2365, #2373 and #2785 edit nearby lines in the `*_from_file.rs` upload
files (client builder, idempotency token). None of them touch the
`std::fs::read` lines. Expect at most a trivial rebase.

## Testing

Run locally on Linux x86_64 with stable Rust:

- `cargo check -p artcraft_client -p artcraft_router -p
midjourney_client -p openai_sora_client` passes.
- `cargo test` on the same four crates passes: 2555 + 39 + 16 + 6 unit
tests, with ignored tests left ignored. That includes
`midjourney_client`'s existing
`loads_a_saved_login_and_logout_removes_every_format`, which exercises
the changed `clear_credentials` path.
- `cargo clippy` on these crates reports nothing on the changed lines.
Clippy cannot finish on `main` itself: `enums` fails on
`clippy::deprecated_semver` and `openai_sora_client` fails on
`should_implement_trait` in `task_id.rs`, both pre-existing.

No new test is added. This is a non-blocking I/O refactor with identical
observable behaviour, so there is no test that fails before the change
and passes after it. The other changed paths do network I/O and have no
offline tests. Please rely on CI and the SonarCloud re-analysis to
confirm the S7493 findings close.


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

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

## Summary by CodeRabbit

* **Refactor**
* File uploads and downloads, generated image saving, and
credential-file cleanup now use asynchronous file operations.
* When a local file can’t be read during a video upload, the existing
fallback to downloading from the source URL remains in place.
* Existing handling of file-read, write, and removal errors is
preserved, including ignoring missing credential files.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 18:53:57 +01:00
FredApps c3fa0453a4 desktop: use tokio::fs for Sora and Midjourney file I/O (Sonar S7493) (#2804)
## Summary

Replaces blocking `std::fs` calls inside `async fn`s in the desktop
crate with `tokio::fs`. SonarCloud reports these as **rust:S7493** ("use
an asynchronous file API instead of this blocking file operation"). This
PR resolves 17 of the 26 desktop findings.

| File (under `crates/desktop/artcraft/src/services/`) | Findings |
Change |

|----------------------------------------------------------------|----------|----------------------------------------------------------------------------|
| `sora/state/read_sora_credentials_from_disk_with_upgrade.rs` | 10 |
`read_to_string` and `OpenOptions`+`write_all`+`flush` →
`tokio::fs::{read_to_string, write}` |
| `sora/state/initialize_sora_jwt_bearer_token.rs` | 2 |
`std::fs::{read_to_string, write}` → `tokio::fs` |
| `sora/state/sora_credential_manager.rs` (`call_sentinel_refresh`) | 2
| `OpenOptions`+`write_all`+`flush` → `tokio::fs::write` |
| `midjourney/utils/download_midjourney_image.rs` | 2 |
`File::create`+`write_all` → `tokio::fs::write` |
| `midjourney/threads/upload_midjourney_batch.rs` | 1 |
`std::fs::remove_file` → `tokio::fs::remove_file(..).await` |

`tokio::fs::write` creates or truncates the file and writes everything.
That is the same as the
`OpenOptions::new().create(true).write(true).truncate(true)` +
`write_all` + `flush` sequence it replaces. The synchronous helpers in
`sora_credential_manager.rs` (`persist_*_to_disk`) are not `async`, are
not flagged, and are left unchanged. The `.exists()` guards are also
left as they are. The desktop crate already depends on `tokio` with the
`fs` feature, so no manifest or lockfile changes.

## Not included (overlap with open PRs)

The remaining 9 desktop S7493 findings are in files that open PRs are
rewriting. They are left alone to avoid conflicting changes:

- `core/utils/simple_http_download.rs`,
`sora/.../handle_successful_generations.rs`,
`worldlabs/threads/worldlabs_marble_task_polling.rs`: #2777 (fail
downloads on non-2xx responses).
- `core/threads/.../fal/handle_fal_complete.rs`: #2550, #2373.
- `core/utils/auto_download.rs`: #2785, #2686, #2393.

The api_clients findings are in #2803.

## Testing

- Each changed file parses with `rustfmt --check`. The formatting diffs
it reports already exist on `main`, and no files were reformatted.
- **I could not build the desktop crate locally.** `cargo check -p
artcraft` stops at `libdbus-sys` because the build machine lacks the
GTK, WebKit and D-Bus system libraries Tauri needs. Please rely on CI
for compilation before marking this ready.
- No new tests are added. This is a behaviour-preserving switch to
non-blocking I/O, so no test fails before the change and passes after
it, and these paths have no existing offline tests.


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

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

## Summary by CodeRabbit

* **Chores**
* Updated background handling for saved images, checkpoints, and account
credentials. Existing cleanup, credential processing, and error
reporting behavior remain unchanged.
* These maintenance updates do not change the app’s available features
or how people interact with it.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 18:36:03 +01:00
FredAppsandFredApps 1b99c184ef ci: one macOS and one Windows release build per branch at a time, with job time limits (#2809)
This changes release workflows (house rule 9). It only adds limits, but
it needs a maintainer's look before merging.

## Problem

`artcraft-macos-publish.yml` and `artcraft-windows-publish.yml` have no
`concurrency` and no `timeout-minutes`. `artcraft-linux-publish.yml`
already has both.

- **Overlapping builds:** two pushes to `release` or `artcraft-release`
in quick succession start two builds of the same platform side by side.
Both create or update the same `artcraft-v<version>` draft release
through `tauri-action`. Nothing stops this today. It happens whenever a
second push lands while a 10-36 minute build is still running. (The two
runs on 2026-11-07 were 35 minutes apart and did not overlap.)
- **Hung builds:** a build that hangs runs until GitHub's 6-hour
default. The Windows run on 2026-06-12 did exactly that and was
cancelled at 360 minutes. Successful builds take far less:

| workflow | successful builds, last 15 runs (2026-04 to 2026-11) |
|---|---|
| macOS publish | 10-22 min |
| Windows publish | 17-36 min |

## Change

Two files, limits only. Triggers, steps, secrets, signing, release
settings and action versions are unchanged.

- **Concurrency:** `concurrency: { group: macos-release-${{ github.ref
}}, cancel-in-progress: false }`, and likewise `windows-release-…`. This
is the same pattern as `artcraft-linux-publish.yml` (`linux-release-${{
github.ref }}`).
- A release build that is already running is **never cancelled**, since
it may already have uploaded to the draft release.
- One GitHub behaviour to be aware of: a concurrency group holds one
running and one pending run. If a third push lands while one build runs
and another waits, GitHub replaces the waiting run with the newest. So
the next build is always of the latest commit on that branch, and an
intermediate commit may not get its own build. For a release branch I
think that is what we want. If every commit must get a build, this PR
shouldn't be merged as it is; tell me and I'll change it.
- The groups are per platform and per branch, so macOS, Windows and
Linux still build in parallel with each other.
- **Timeouts:** `timeout-minutes: 60` for macOS and `90` for Windows.
That is about 2.5-3× the slowest successful build, and Windows matches
the Linux build job's 90. A hang now stops after an hour or an hour and
a half instead of six.

Not changed: `dtolnay/rust-toolchain@stable` and
`tauri-apps/tauri-action@v0` are still unpinned tags. Pinning them would
change which action code builds a release, so it should be a separate
decision.

## Verification

- `actionlint` is clean on the three publish workflows.
- These workflows only run on pushes to `release`, `artcraft-release`,
`artcraft-macos` and `artcraft-windows`, so this PR's checks don't
exercise them. The new settings take effect on the next release push.
- I didn't trigger a release build.

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

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

## Summary by CodeRabbit

* **Chores**
* Release builds for macOS and Windows now finish in progress without
being cancelled by newer runs. When multiple builds are queued for the
same branch or ref, only the newest pending build is kept.
* Publishing jobs now have maximum run times of 60 minutes for macOS and
90 minutes for Windows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-10 16:45:35 +01:00
AxylumandCédric Legrand 1510abf563 url_utils: satisfy Clippy pedantic lints (#2681)
## Update — 2026-11-01

The latest revision removes the systematic `#[must_use]` additions and
any product-name backticks introduced solely for optional
`clippy::pedantic` compliance. The useful behavior-preserving cleanups
remain. Earlier summary and verification text below describes the
original revision; the latest PR comment records the current checks.

---

## Summary
- resolve all 54 distinct diagnostics in `url_utils`: 17 pedantic, 36
deprecation warnings, and one Eq/Hash contract issue
- preserve the public compatibility module and nested module paths with
narrow documented expectations
- implement `Hash` against the same bare value as `PartialEq`, with a
regression test
- apply the repository two-space rustfmt rules within `url_utils` only

No public API or dependency changes.

## Verification
- `SQLX_OFFLINE=true cargo clippy -p url_utils --all-targets -- -W
clippy::pedantic -D warnings`
- `SQLX_OFFLINE=true cargo test -p url_utils` — 32 unit tests and 2
doctests passed
- `cargo fmt -p url_utils -- --check`
- Independent read-only review: no Rust findings; the untracked planning
file is excluded from the PR

---------

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