Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 07471d404d ci: verify the actions lockfile in CI
Fails the build when .github/workflows/actions.lock stops matching the
workflows, or when a locked pin no longer verifies upstream, so a stale or
tampered lockfile cannot go unnoticed between dependency updates.
2026-11-12 07:22:42 -05:00
Dylan M. Taylor 78401059b6 ci: lock workflow actions through an actions.lock lockfile
Mutable action tags are a supply-chain risk: a moved tag runs different code
without a diff. Every workflow action is now pinned through
.github/workflows/actions.lock, and CI verifies the lockfile so an out-of-sync
or tampered one fails instead of running.

arch-package was pinned to 9d67905, a commit no longer on any branch or tag of
craft-common-actions, so it is back on main like the other craft actions here.

S7637 wants an inline SHA per action and does not read actions.lock, so the
rule is scoped off for workflow files until Sonar reads lockfiles.
2026-11-12 07:22:16 -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
Dylan M. Taylor 56b3090924 ci: dual-push Flatpak OCI bundles to Docker Hub (#2784)
Mirror Flatpak OCI bundles to Docker Hub
(`docker.io/storytold/artcraft`) alongside GHCR when Docker Hub
credentials (`DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`) are present.
2026-11-08 20:22:46 -05:00
Dylan M. Taylor 79d87b2b54 fix(flatpak): persist data directory, keep accelerated rendering, and enrich metainfo (#2761)
## Summary

Transfers verified improvements and reviewer feedback from the FlatPark
package review to ArtCraft's first-party Flatpak build:

- **Data Persistence:** Adds `--persist=Artcraft` to `finish-args` in
`flatpak.yml` so user settings, credentials, provider tokens, and local
task SQLite queue stored in `~/Artcraft` persist in
`~/.var/app/ai.artcraft.app/Artcraft` across launches instead of being
discarded on exit.
- **Accelerated Rendering:** Adds `artcraft-wrapper` setting
`WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-0}"`
and
`WEBKIT_DISABLE_COMPOSITING_MODE="${WEBKIT_DISABLE_COMPOSITING_MODE:-0}"`.
Keeping WebKitGTK's accelerated rendering path enabled prevents
rendering glitches on `org.gnome.Platform//51` while preserving user
override capability.
- **Dependency Checking:** Updates the Flatpak build runtime
verification to inspect the real binary (`/app/bin/artcraft.real`).
- **Enriched AppStream Metainfo:** Updates
`ai.artcraft.app.metainfo.xml` with CC0-1.0 metadata license,
bugtracker/vcs-browser/contact URLs, bulleted feature description,
search keywords, branding colors, hardware display requirements, input
controls, and OARS-1.1 content rating.

## Verification

- `actionlint .github/workflows/*.yml` passed.
- `shellcheck packaging/linux/*.sh packaging/linux/artcraft-wrapper`
passed.
- `xmllint --noout packaging/linux/ai.artcraft.app.metainfo.xml` passed.
- `appstreamcli validate packaging/linux/ai.artcraft.app.metainfo.xml`
passed with 0 errors and 0 info notices.
2026-11-06 22:48:03 -05:00
dependabot[bot] 1ae54cabc0 ci(deps): bump the actions group across 1 directory with 4 updates (#2760)
Bumps the actions group with 4 updates in the / directory:
[actions/checkout](https://github.com/actions/checkout),
[actions/setup-node](https://github.com/actions/setup-node),
[actions/upload-artifact](https://github.com/actions/upload-artifact)
and
[actions/download-artifact](https://github.com/actions/download-artifact).

Updates `actions/checkout` from 4 to 7
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/releases">actions/checkout's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>block checking out fork pr for pull_request_target and workflow_run
by <a href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2454">actions/checkout#2454</a></li>
<li>Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the
minor-actions-dependencies group across 1 directory by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2458">actions/checkout#2458</a></li>
<li>Bump flatted from 3.3.1 to 3.4.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2460">actions/checkout#2460</a></li>
<li>Bump js-yaml from 4.1.0 to 4.2.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2461">actions/checkout#2461</a></li>
<li>Bump <code>@​actions/core</code> and
<code>@​actions/tool-cache</code> and Remove uuid by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2459">actions/checkout#2459</a></li>
<li>upgrade module to esm and update dependencies by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2463">actions/checkout#2463</a></li>
<li>Bump the minor-npm-dependencies group across 1 directory with 3
updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/checkout/pull/2462">actions/checkout#2462</a></li>
<li>getting ready for checkout v7 release by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2464">actions/checkout#2464</a></li>
<li>update error wording by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2467">actions/checkout#2467</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/checkout/pull/2454">actions/checkout#2454</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6.0.3...v7.0.0">https://github.com/actions/checkout/compare/v6.0.3...v7.0.0</a></p>
<h2>v6.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li><strong>[BREAKING]</strong> backport
<code>allow-unsafe-pr-checkout</code> to v6 by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2500">actions/checkout#2500</a></li>
<li>backport fixes to releases-v6 by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2527">actions/checkout#2527</a></li>
</ul>
<p><a
href="https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/">https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/</a>
for more details about this breaking change</p>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6.0.3...v6.1.0">https://github.com/actions/checkout/compare/v6.0.3...v6.1.0</a></p>
<h2>v6.0.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Update changelog by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2357">actions/checkout#2357</a></li>
<li>fix: expand merge commit SHA regex and add SHA-256 test cases by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
<li>Fix checkout init for SHA-256 repositories by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2439">actions/checkout#2439</a></li>
<li>Update changelog for v6.0.3 by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2446">actions/checkout#2446</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/yaananth"><code>@​yaananth</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6...v6.0.3">https://github.com/actions/checkout/compare/v6...v6.0.3</a></p>
<h2>v6.0.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Add orchestration_id to git user-agent when ACTIONS_ORCHESTRATION_ID
is set by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2355">actions/checkout#2355</a></li>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6.0.1...v6.0.2">https://github.com/actions/checkout/compare/v6.0.1...v6.0.2</a></p>
<h2>v6.0.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Update all references from v5 and v4 to v6 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2314">actions/checkout#2314</a></li>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
<li>Clarify v6 README by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2328">actions/checkout#2328</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/blob/main/CHANGELOG.md">actions/checkout's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>v7.0.1</h2>
<ul>
<li>Skip running unsafe pr check if input is default by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2518">actions/checkout#2518</a></li>
<li>Trim only ascii whitespace for branch by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2521">actions/checkout#2521</a></li>
<li>Escape values passed to --unset by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2530">actions/checkout#2530</a></li>
<li>Various dependency updates</li>
</ul>
<h2>v7.0.0</h2>
<ul>
<li>Block checking out fork PR for pull_request_target and workflow_run
by <a href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2454">actions/checkout#2454</a></li>
<li>Various dependency updates</li>
</ul>
<h2>v6.0.3</h2>
<ul>
<li>Fix checkout init for SHA-256 repositories by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2439">actions/checkout#2439</a></li>
<li>fix: expand merge commit SHA regex and add SHA-256 test cases by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
</ul>
<h2>v6.0.2</h2>
<ul>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<h2>v6.0.1</h2>
<ul>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
</ul>
<h2>v6.0.0</h2>
<ul>
<li>Persist creds to a separate file by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2286">actions/checkout#2286</a></li>
<li>Update README to include Node.js 24 support details and requirements
by <a href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2248">actions/checkout#2248</a></li>
</ul>
<h2>v5.0.1</h2>
<ul>
<li>Port v6 cleanup to v5 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2301">actions/checkout#2301</a></li>
</ul>
<h2>v5.0.0</h2>
<ul>
<li>Update actions checkout to use node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2226">actions/checkout#2226</a></li>
</ul>
<h2>v4.3.1</h2>
<ul>
<li>Port v6 cleanup to v4 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2305">actions/checkout#2305</a></li>
</ul>
<h2>v4.3.0</h2>
<ul>
<li>docs: update README.md by <a
href="https://github.com/motss"><code>@​motss</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1971">actions/checkout#1971</a></li>
<li>Add internal repos for checking out multiple repositories by <a
href="https://github.com/mouismail"><code>@​mouismail</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1977">actions/checkout#1977</a></li>
<li>Documentation update - add recommended permissions to Readme by <a
href="https://github.com/benwells"><code>@​benwells</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2043">actions/checkout#2043</a></li>
<li>Adjust positioning of user email note and permissions heading by <a
href="https://github.com/joshmgross"><code>@​joshmgross</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2044">actions/checkout#2044</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2194">actions/checkout#2194</a></li>
<li>Update CODEOWNERS for actions by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2224">actions/checkout#2224</a></li>
<li>Update package dependencies by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2236">actions/checkout#2236</a></li>
</ul>
<h2>v4.2.2</h2>
<ul>
<li><code>url-helper.ts</code> now leverages well-known environment
variables by <a href="https://github.com/jww3"><code>@​jww3</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/1941">actions/checkout#1941</a></li>
<li>Expand unit test coverage for <code>isGhes</code> by <a
href="https://github.com/jww3"><code>@​jww3</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1946">actions/checkout#1946</a></li>
</ul>
<h2>v4.2.1</h2>
<ul>
<li>Check out other refs/* by commit if provided, fall back to ref by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1924">actions/checkout#1924</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/checkout/commit/3d3c42e5aac5ba805825da76410c181273ba90b1"><code>3d3c42e</code></a>
prep v7.0.1 release (<a
href="https://redirect.github.com/actions/checkout/issues/2531">#2531</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/28802689a136bfcdb721715abd713740beecbe07"><code>2880268</code></a>
escape values passed to --unset (<a
href="https://redirect.github.com/actions/checkout/issues/2530">#2530</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/12cd2235efa0937479335606d7c3ac9f6c0973b1"><code>12cd223</code></a>
trim only ascii whitespace for branch (<a
href="https://redirect.github.com/actions/checkout/issues/2521">#2521</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/62661c4e71a304b2823ed026347b8d34c3eac541"><code>62661c4</code></a>
skip running unsafe pr check if input is default (<a
href="https://redirect.github.com/actions/checkout/issues/2518">#2518</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/e8d4307400f9427dba7cb98e488d6ab85f1cec5f"><code>e8d4307</code></a>
Bump the minor-actions-dependencies group with 2 updates (<a
href="https://redirect.github.com/actions/checkout/issues/2499">#2499</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/631c942040754b6e095e929c1677c07e10ed4f87"><code>631c942</code></a>
eslint 9 (<a
href="https://redirect.github.com/actions/checkout/issues/2474">#2474</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/4f1f4aec02e41874fa0262ea8ff5172d7978ad1e"><code>4f1f4ae</code></a>
Bump actions/upload-artifact from 4 to 7 (<a
href="https://redirect.github.com/actions/checkout/issues/2476">#2476</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/ba097532fb203f7e88c9c3c0b899b49469908a92"><code>ba09753</code></a>
Bump actions/checkout from 6 to 7 (<a
href="https://redirect.github.com/actions/checkout/issues/2488">#2488</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/b9e0990d219a03df7633c93f6f005a8fecbcab22"><code>b9e0990</code></a>
Bump docker/login-action from 3.3.0 to 4.2.0 (<a
href="https://redirect.github.com/actions/checkout/issues/2479">#2479</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/e8cb398be4a550817e382abf69e4c12c76fce1f2"><code>e8cb398</code></a>
Bump docker/build-push-action from 6.5.0 to 7.2.0 (<a
href="https://redirect.github.com/actions/checkout/issues/2478">#2478</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/checkout/compare/v4...v7">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/setup-node` from 4 to 7
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-node/releases">actions/setup-node's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements:</h3>
<ul>
<li>Add cache-primary-key and cache-matched-key as outputs by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1577">actions/setup-node#1577</a></li>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1574">actions/setup-node#1574</a></li>
</ul>
<h3>Bug fixes:</h3>
<ul>
<li>Remove dummy NODE_AUTH_TOKEN export by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1558">actions/setup-node#1558</a></li>
<li>Only use <code>mirrorToken</code> in <code>getManifest</code> if
it's provided by <a
href="https://github.com/deiga"><code>@​deiga</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1548">actions/setup-node#1548</a></li>
</ul>
<h3>Documentation updates:</h3>
<ul>
<li>Add documentation for publishing to npm with Trusted Publisher
(OIDC) by <a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1536">actions/setup-node#1536</a></li>
<li>docs: Update restore-only cache documentation by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1550">actions/setup-node#1550</a></li>
<li>docs: Update caching recommendations to mitigate cache poisoning
risks by <a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1567">actions/setup-node#1567</a></li>
</ul>
<h3>Dependency update:</h3>
<ul>
<li>Upgrade <code>@​actions/cache</code> to 5.1.0, log cache write
denied by <a
href="https://github.com/jasongin"><code>@​jasongin</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1569">actions/setup-node#1569</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1536">actions/setup-node#1536</a></li>
<li><a href="https://github.com/deiga"><code>@​deiga</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1548">actions/setup-node#1548</a></li>
<li><a href="https://github.com/jasongin"><code>@​jasongin</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1569">actions/setup-node#1569</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6...v7.0.0">https://github.com/actions/setup-node/compare/v6...v7.0.0</a></p>
<h2>v6.5.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update <code>@​actions/cache</code> to 5.1.0 and add security
overrides for undici and fast-xml-parser by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1579">actions/setup-node#1579</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0">https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0</a></p>
<h2>v6.4.0</h2>
<h2>What's Changed</h2>
<h3>Dependency updates:</h3>
<ul>
<li>Upgrade <a
href="https://github.com/actions"><code>@​actions</code></a>
dependencies by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1525">actions/setup-node#1525</a></li>
<li>Update Node.js versions in versions.yml and bump package to v6.4.0
by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1533">actions/setup-node#1533</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Copilot"><code>@​Copilot</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1525">actions/setup-node#1525</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6...v6.4.0">https://github.com/actions/setup-node/compare/v6...v6.4.0</a></p>
<h2>v6.3.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements:</h3>
<ul>
<li>Support parsing <code>devEngines</code> field by <a
href="https://github.com/susnux"><code>@​susnux</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1283">actions/setup-node#1283</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-node/commit/949feb2413d6458794dcd2491c4babbbce0c15c1"><code>949feb2</code></a>
Bump eslint from 10.5.0 to 10.11.0 and package.json from 7.0.0 to 7.1.0
(<a
href="https://redirect.github.com/actions/setup-node/issues/1635">#1635</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/7207c4f1189c7ee83ddf20c7c68bd1af6fdd751c"><code>7207c4f</code></a>
chore: update dependencies and add license files (<a
href="https://redirect.github.com/actions/setup-node/issues/1642">#1642</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/54337e0d02de9202d0948aaff36ad556375497b9"><code>54337e0</code></a>
Bump eslint-plugin-n from 18.1.0 to 18.3.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1638">#1638</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/fb54fa8c14c63410cc752f0ae3b7b3ca6af161fc"><code>fb54fa8</code></a>
Bump jest-each from 30.4.1 to 30.5.2 (<a
href="https://redirect.github.com/actions/setup-node/issues/1636">#1636</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/7f84f1d52d0c56afb45c62fee31a93b843cea4d6"><code>7f84f1d</code></a>
Bump pnpm/action-setup from 6.0.10 to 6.1.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1631">#1631</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/0c54c7b07420dcba69a7d832587f55ebdf771c9e"><code>0c54c7b</code></a>
Bump <code>@​typescript-eslint/parser</code> from 8.62.0 to 8.70.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1615">#1615</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/a8294190049a9c296f01b9a432aff41dd83d8284"><code>a829419</code></a>
Bump <code>@​types/semver</code> from 7.7.1 to 7.8.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1616">#1616</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/680d1e489b82f1243de15bc7665c0007e3c9a860"><code>680d1e4</code></a>
Support absolute paths in node-version-file (<a
href="https://redirect.github.com/actions/setup-node/issues/1633">#1633</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/db0fb3a98ed314b69026b6e8c9be26a7e2fe65a8"><code>db0fb3a</code></a>
Bump baseline-browser-mapping from 2.10.38 to 2.11.22 (<a
href="https://redirect.github.com/actions/setup-node/issues/1629">#1629</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/94196ee1d15439c1b6651cd87ef14e88ec435966"><code>94196ee</code></a>
Bump pnpm/action-setup from 6.0.9 to 6.0.10 (<a
href="https://redirect.github.com/actions/setup-node/issues/1602">#1602</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/setup-node/compare/v4...v7">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/upload-artifact` from 4 to 7
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>v7 What's new</h2>
<h3>Direct Uploads</h3>
<p>Adds support for uploading single files directly (unzipped). Callers
can set the new <code>archive</code> parameter to <code>false</code> to
skip zipping the file during upload. Right now, we only support single
files. The action will fail if the glob passed resolves to multiple
files. The <code>name</code> parameter is also ignored with this
setting. Instead, the name of the artifact will be the name of the
uploaded file.</p>
<h3>ESM</h3>
<p>To support new versions of the <code>@actions/*</code> packages,
we've upgraded the package to ESM.</p>
<h2>What's Changed</h2>
<ul>
<li>Add proxy integration test by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li>
<li>Upgrade the module to ESM and bump dependencies by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/762">actions/upload-artifact#762</a></li>
<li>Support direct file uploads by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/764">actions/upload-artifact#764</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Link"><code>@​Link</code></a>- made
their first contribution in <a
href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/upload-artifact/compare/v6...v7.0.0">https://github.com/actions/upload-artifact/compare/v6...v7.0.0</a></p>
<h2>v6.0.0</h2>
<h2>v6 - What's new</h2>
<blockquote>
<p>[!IMPORTANT]
actions/upload-artifact@v6 now runs on Node.js 24 (<code>runs.using:
node24</code>) and requires a minimum Actions Runner version of 2.327.1.
If you are using self-hosted runners, ensure they are updated before
upgrading.</p>
</blockquote>
<h3>Node.js 24</h3>
<p>This release updates the runtime to Node.js 24. v5 had preliminary
support for Node.js 24, however this action was by default still running
on Node.js 20. Now this action by default will run on Node.js 24.</p>
<h2>What's Changed</h2>
<ul>
<li>Upload Artifact Node 24 support by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/719">actions/upload-artifact#719</a></li>
<li>fix: update <code>@​actions/artifact</code> for Node.js 24 punycode
deprecation by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/744">actions/upload-artifact#744</a></li>
<li>prepare release v6.0.0 for Node.js 24 support by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/745">actions/upload-artifact#745</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0">https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0</a></p>
<h2>v5.0.0</h2>
<h2>What's Changed</h2>
<p><strong>BREAKING CHANGE:</strong> this update supports Node
<code>v24.x</code>. This is not a breaking change per-se but we're
treating it as such.</p>
<ul>
<li>Update README.md by <a
href="https://github.com/GhadimiR"><code>@​GhadimiR</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/681">actions/upload-artifact#681</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/712">actions/upload-artifact#712</a></li>
<li>Readme: spell out the first use of GHES by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/727">actions/upload-artifact#727</a></li>
<li>Update GHES guidance to include reference to Node 20 version by <a
href="https://github.com/patrikpolyak"><code>@​patrikpolyak</code></a>
in <a
href="https://redirect.github.com/actions/upload-artifact/pull/725">actions/upload-artifact#725</a></li>
<li>Bump <code>@actions/artifact</code> to <code>v4.0.0</code></li>
<li>Prepare <code>v5.0.0</code> by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/734">actions/upload-artifact#734</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/upload-artifact/commit/cf430e030ddbb5b0abf93d22962f4752f3646cd9"><code>cf430e0</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/822">#822</a>
from actions/tunc-d-prepare-upload-v7-0-2</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/c2acdedd35a8824a1552c7137f8c18288c3ea469"><code>c2acded</code></a>
chore: prepare v7.0.2 release</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/4687c1bbaa0a58abb517d90b4bbade6754c80c1a"><code>4687c1b</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/821">#821</a>
from actions/tunc-d-upload-artifact-package-bump</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/8cbe1bebdb858105d8fdd88f81404d4de4595719"><code>8cbe1be</code></a>
Bump <code>@​actions/artifact</code> to 6.3.1</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"><code>043fb46</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/797">#797</a>
from actions/yacaovsnc/update-dependency</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/634250c1388765ea7ed0f053e636f1f399000b94"><code>634250c</code></a>
Include changes in typespec/ts-http-runtime 0.3.5</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/e454baaac2be505c9450e11b8f3215c6fc023ce8"><code>e454baa</code></a>
Readme: bump all the example versions to v7 (<a
href="https://redirect.github.com/actions/upload-artifact/issues/796">#796</a>)</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/74fad66b98a6d799dc004d3353ccd0e6f6b2530e"><code>74fad66</code></a>
Update the readme with direct upload details (<a
href="https://redirect.github.com/actions/upload-artifact/issues/795">#795</a>)</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/bbbca2ddaa5d8feaa63e36b76fdaad77386f024f"><code>bbbca2d</code></a>
Support direct file uploads (<a
href="https://redirect.github.com/actions/upload-artifact/issues/764">#764</a>)</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/589182c5a4cec8920b8c1bce3e2fab1c97a02296"><code>589182c</code></a>
Upgrade the module to ESM and bump dependencies (<a
href="https://redirect.github.com/actions/upload-artifact/issues/762">#762</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/upload-artifact/compare/v4...v7">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/download-artifact` from 4 to 8
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/download-artifact/releases">actions/download-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v8.0.0</h2>
<h2>v8 - What's new</h2>
<blockquote>
<p>[!IMPORTANT]
actions/download-artifact@v8 has been migrated to an ESM module. This
should be transparent to the caller but forks might need to make
significant changes.</p>
</blockquote>
<blockquote>
<p>[!IMPORTANT]
Hash mismatches will now error by default. Users can override this
behavior with a setting change (see below).</p>
</blockquote>
<h3>Direct downloads</h3>
<p>To support direct uploads in <code>actions/upload-artifact</code>,
the action will no longer attempt to unzip all downloaded files.
Instead, the action checks the <code>Content-Type</code> header ahead of
unzipping and skips non-zipped files. Callers wishing to download a
zipped file as-is can also set the new <code>skip-decompress</code>
parameter to <code>true</code>.</p>
<h3>Enforced checks (breaking)</h3>
<p>A previous release introduced digest checks on the download. If a
download hash didn't match the expected hash from the server, the action
would log a warning. Callers can now configure the behavior on mismatch
with the <code>digest-mismatch</code> parameter. To be secure by
default, we are now defaulting the behavior to <code>error</code> which
will fail the workflow run.</p>
<h3>ESM</h3>
<p>To support new versions of the @actions/* packages, we've upgraded
the package to ESM.</p>
<h2>What's Changed</h2>
<ul>
<li>Don't attempt to un-zip non-zipped downloads by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/download-artifact/pull/460">actions/download-artifact#460</a></li>
<li>Add a setting to specify what to do on hash mismatch and default it
to <code>error</code> by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/download-artifact/pull/461">actions/download-artifact#461</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/download-artifact/compare/v7...v8.0.0">https://github.com/actions/download-artifact/compare/v7...v8.0.0</a></p>
<h2>v7.0.0</h2>
<h2>v7 - What's new</h2>
<blockquote>
<p>[!IMPORTANT]
actions/download-artifact@v7 now runs on Node.js 24 (<code>runs.using:
node24</code>) and requires a minimum Actions Runner version of 2.327.1.
If you are using self-hosted runners, ensure they are updated before
upgrading.</p>
</blockquote>
<h3>Node.js 24</h3>
<p>This release updates the runtime to Node.js 24. v6 had preliminary
support for Node 24, however this action was by default still running on
Node.js 20. Now this action by default will run on Node.js 24.</p>
<h2>What's Changed</h2>
<ul>
<li>Update GHES guidance to include reference to Node 20 version by <a
href="https://github.com/patrikpolyak"><code>@​patrikpolyak</code></a>
in <a
href="https://redirect.github.com/actions/download-artifact/pull/440">actions/download-artifact#440</a></li>
<li>Download Artifact Node24 support by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/download-artifact/pull/415">actions/download-artifact#415</a></li>
<li>fix: update <code>@​actions/artifact</code> to fix Node.js 24
punycode deprecation by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/download-artifact/pull/451">actions/download-artifact#451</a></li>
<li>prepare release v7.0.0 for Node.js 24 support by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/download-artifact/pull/452">actions/download-artifact#452</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/patrikpolyak"><code>@​patrikpolyak</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/download-artifact/pull/440">actions/download-artifact#440</a></li>
<li><a href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/download-artifact/pull/415">actions/download-artifact#415</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/download-artifact/compare/v6.0.0...v7.0.0">https://github.com/actions/download-artifact/compare/v6.0.0...v7.0.0</a></p>
<h2>v6.0.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/download-artifact/commit/9000827ccba6bdab643e8b6fd33ac0654aef8333"><code>9000827</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/493">#493</a>
from actions/tunc-d-prepare-download-v8-0-2</li>
<li><a
href="https://github.com/actions/download-artifact/commit/e455989ae00f5bdd4054f05d3e972c6bf8a62dc5"><code>e455989</code></a>
Prepare download-artifact v8.0.2</li>
<li><a
href="https://github.com/actions/download-artifact/commit/a00b90c4a2f01d73a8c89552caabca6ea3b43137"><code>a00b90c</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/492">#492</a>
from actions/tunc-d-download-artifact-package-bump</li>
<li><a
href="https://github.com/actions/download-artifact/commit/29bb7ce6af5345aeab5bb4774ce7249ee3ccc27f"><code>29bb7ce</code></a>
Update <code>@​actions/artifact</code> to 6.3.1</li>
<li><a
href="https://github.com/actions/download-artifact/commit/484a0b528fb4d7bd804637ccb632e47a0e638317"><code>484a0b5</code></a>
Update the readme (<a
href="https://redirect.github.com/actions/download-artifact/issues/473">#473</a>)</li>
<li><a
href="https://github.com/actions/download-artifact/commit/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"><code>3e5f45b</code></a>
Add regression tests for CJK characters (<a
href="https://redirect.github.com/actions/download-artifact/issues/471">#471</a>)</li>
<li><a
href="https://github.com/actions/download-artifact/commit/e6d03f67377d4412c7aa56a8e2e4988e6ec479dd"><code>e6d03f6</code></a>
Add a regression test for artifact name + content-type mismatches (<a
href="https://redirect.github.com/actions/download-artifact/issues/472">#472</a>)</li>
<li><a
href="https://github.com/actions/download-artifact/commit/70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3"><code>70fc10c</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/461">#461</a>
from actions/danwkennedy/digest-mismatch-behavior</li>
<li><a
href="https://github.com/actions/download-artifact/commit/f258da9a506b755b84a09a531814700b86ccfc62"><code>f258da9</code></a>
Add change docs</li>
<li><a
href="https://github.com/actions/download-artifact/commit/ccc058e5fbb0bb2352213eaec3491e117cbc4a5c"><code>ccc058e</code></a>
Fix linting issues</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/download-artifact/compare/v4...v8">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-06 21:31:01 -05:00
Dylan M. Taylor 4bf1b20a75 ci: add signed Linux release matrix, packaging lint, and FreeBSD CI (#2758)
Adds the complete signed Linux release matrix, FreeBSD CI, and packaging
lint to ArtCraft Studio, aligned with the Craft suite standards in
`craft-common-actions`.

| Architecture | Formats |

|:-------------|:------------------------------------------------------------------------|
| x86_64 | AppImage and zsync, DEB, RPM, tar.gz, Arch package, Flatpak
bundle, OCI |
| aarch64 | AppImage and zsync, DEB, RPM, tar.gz, Arch package, Flatpak
bundle, OCI |
| riscv64 | DEB, RPM, tar.gz |

### Changes

- **Linux Packaging Matrix**: Builds and packages ArtCraft Studio across
x86_64, aarch64, and riscv64 using `storytold/craft-common-actions`.
Produces DEB, RPM, AppImage, tarball, Arch package, and Flatpak
bundle/OCI artifacts. Cross-compiles riscv64 DEB and RPM via BoringSSL
CMake configuration. Signed Linux artifacts join existing
`artcraft-v<version>` release drafts while retaining macOS and Windows
assets (storytold/craft-common-actions#160 and #161).
- **Release Trigger Alignment**: Like macOS and Windows release
workflows, Linux release builds run on release branches (`release`,
`artcraft-release`, `artcraft-linux`) and `workflow_dispatch` rather
than on PRs.
- **Documentation**: Updated `README.md` to include Linux under download
options and reference `packaging/linux/README.md`. Added
`packaging/windows/README.md` placeholder.
- **Packaging Lint**: Added `.github/workflows/packaging-lint.yml`
calling `craft-common-actions/packaging-lint.yml@main`. Fixed
pre-existing actionlint and shellcheck issues in macOS and Windows
publish workflows (`shell: pwsh` defaults and variable quoting).
- **FreeBSD CI**: Added `.github/workflows/freebsd.yml` calling
`craft-common-actions/freebsd.yml@main` on scheduled nightly runs,
release branches, and manual dispatch (not on pull requests).
- **SQLx Build Fallback**: Updated
`crates/schema/database/sqlite_tasks/build.rs` to fall back to
`SQLX_OFFLINE=true` using committed `.sqlx/` query data when no live
`/tmp/tasks.sqlite` database file exists, fixing nextest coverage
archives under CI.

### Validation

- `actionlint` passes cleanly across all `.github/workflows/*.yml`
files.
- `shellcheck` passes across all scripts in `packaging/linux/*.sh`.
- `xmllint` validates `packaging/linux/ai.artcraft.app.metainfo.xml`.
- `cargo check -p sqlite_tasks`, `cargo test -p sqlite_tasks`, and
`cargo test --no-run -p sqlite_tasks` pass locally without database
environment variables.
- Packaging lint passes 100% green on CI.
- SonarCloud / nextest instrumented test archive build passes on CI.
- Linux package scripts (`package.sh`, `package-test.sh`, `flatpak.sh`)
verified locally and on CI runners.
2026-11-06 20:02:06 -05:00
FredApps dc6006f625 Copy the shared house rules into AGENTS.md (#2749)
## Summary
- Puts the shared house-rules blocks at the top of `AGENTS.md`, copied
from craft-contrib `dde09ea31e2e457f5b4653a6e76f7cdf55944bdf`.
- Adds `.github/workflows/house-rules.yml`, which calls the shared
check.

Artcraft is the Tauri product, so it gets the core block only. The Rust
rules are for native Craft apps and shared Rust libraries.
- `CLAUDE.md` was a symlink to `AGENTS.md` and is now `@AGENTS.md`.
- No local paragraph was removed. The Tauri and TypeScript description
stays.

## Sizes
- Core block: 3,052 bytes.
- Root `AGENTS.md`: 6,934 bytes.
2026-11-06 18:44:42 +01:00
Dylan Taylor 58f5944fe6 fix: align rfd backend with GTK dialog feature 2026-11-04 10:09:58 -05:00
Dylan M. Taylor cfdbbd1a3f ci: allow manual SonarCloud scans 2026-11-04 09:51:32 -05:00
Dylan M. Taylor 1f969a9224 ci(sonar): stop cancelling main-branch runs so coverage is published (#2574)
SonarCloud coverage upload began on 2026-10-23
(craft-common-actions#74), but `cancel-in-progress: true` cancels
superseded runs on `main` before the llvm-cov build finishes, so
coverage may never be published. Cancel superseded runs only on PR refs;
main runs queue and complete. Same change as storytold/vectorcraft#3881.
2026-10-23 17:01:44 -04:00
Dylan M. Taylor 800217ed04 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:53 -04:00
Dylan M. Taylor 1790f9d562 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:11 -04:00
Brandon ThomasandClaude Opus 5.5 40e04793b5 Skip CodeRabbit reviews on Dependabot PRs (#2510)
Adds `.coderabbit.yaml` with `reviews.auto_review.ignore_usernames:
["dependabot[bot]"]` so CodeRabbit stops reviewing and commenting on
Dependabot PRs. Config-only change; same file is landing across the
craft repos.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:40:52 -04:00
Dylan M. Taylor 7a2935cf9e ci: configure Dependabot for Cargo and GitHub Actions (#2460)
Configures Dependabot to keep Rust / Cargo dependencies and GitHub
Actions workflows up to date.

- Schedules daily dependency checks at 06:00 America/New_York
- Groups Cargo updates into a single consolidated PR with `chore(deps)`
prefix
- Groups GitHub Actions updates into a single consolidated PR with
`ci(deps)` prefix
2026-10-18 15:10:55 -04:00
BFlat d4e4ac69bb artcraft-native: generate with Midjourney or fal (provider choice) (#2115)
Images can run on Midjourney (own account, via a sign-in window) or fal (own API key), chosen from the model selector's Select Provider submenu. Jobs finish into the library. The Midjourney credential manager and the router prompt helper move into shared crates used by both desktop apps.
2026-10-11 20:47:59 +07:00
290 changed files with 5332 additions and 1974 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+27 -8
View File
@@ -1,11 +1,30 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "cargo" # See documentation for possible values
directory: "/" # Location of package manifests
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+93
View File
@@ -0,0 +1,93 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/artcraft-linux-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/download-artifact@v8.0.2'
- 'actions/setup-node@v7.1.0'
- 'actions/upload-artifact@v7.0.2'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/rust-cache@v2.9.2'
- 'step-security/tauri-action@v1.0.0'
- 'storytold/craft-common-actions@main'
'.github/workflows/artcraft-macos-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/tauri-action@v1.0.0'
'.github/workflows/artcraft-windows-publish.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/tauri-action@v1.0.0'
'.github/workflows/freebsd.yml': []
'.github/workflows/house-rules.yml': []
'.github/workflows/lockfile.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/packaging-lint.yml': []
'.github/workflows/sonarcloud.yml': []
dependencies:
'actions/cache@caa296126883cff596d87d8935842f9db880ef25':
ref: 'v5.1.0'
commit: 'sha1-caa296126883cff596d87d8935842f9db880ef25'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/download-artifact@v8.0.2':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/setup-node@v7.1.0':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/upload-artifact@v7.0.2':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'step-security/dtolnay-rust-toolchain@v1.0.0':
ref: 'v1.0.0'
commit: 'sha1-8dd607977d06adf4ed49b190381e74c17a9dd25f'
owner_id: 88700172
repo_id: 1249865941
'step-security/harden-runner@v2.22.4':
ref: 'v2.22.4'
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
owner_id: 88700172
repo_id: 422287306
'step-security/rust-cache@v2.9.2':
ref: 'v2.9.2'
commit: 'sha1-8871c8ba063416da7c32246932ea6ae08700453e'
owner_id: 88700172
repo_id: 822636713
'step-security/tauri-action@v1.0.0':
ref: 'v1.0.0'
commit: 'sha1-dc662d498edbd35ec8ed564b45d44e955444d1bc'
owner_id: 88700172
repo_id: 1283860856
'storytold/craft-common-actions@main':
ref: 'main'
commit: 'sha1-293de72429d3ed5d9fa92aded5e500544c0fa28f'
owner_id: 76897702
repo_id: 1414429108
uses:
- 'actions/cache@caa296126883cff596d87d8935842f9db880ef25'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
@@ -0,0 +1,215 @@
name: ArtCraft Linux release
on:
push:
branches: [release, artcraft-release, artcraft-linux]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: linux-release-${{ github.ref }}
cancel-in-progress: false
jobs:
packaging-check:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: rpm jq binutils zsync
- run: packaging/linux/package-test.sh
build:
strategy:
fail-fast: false
matrix:
include:
- {arch: x86_64, runner: ubuntu-22.04, target: x86_64-unknown-linux-gnu, bundles: "deb,rpm,appimage"}
- {arch: aarch64, runner: ubuntu-22.04-arm, target: aarch64-unknown-linux-gnu, bundles: "deb,rpm,appimage"}
- {arch: riscv64, runner: ubuntu-24.04, target: riscv64gc-unknown-linux-gnu, bundles: "deb,rpm"}
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
env:
SQLX_OFFLINE: 'true'
VITE_ENVIRONMENT_TYPE: production
TAURI_FRONTEND_PATH: ${{ github.workspace }}/frontend
TAURI_APP_PATH: ${{ github.workspace }}/crates/desktop/artcraft
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libclang-dev libasound2-dev libxdo-dev libdbus-1-dev build-essential clang file jq perl cmake nasm rpm zsync gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav
- uses: storytold/craft-common-actions/actions/install-riscv64-cross@main
if: matrix.arch == 'riscv64'
- name: Remove conflicting host Pango development files
if: matrix.arch == 'riscv64'
# Ubuntu's Pango GIR differs by architecture despite being in a shared path.
# Only the target GTK development packages are needed for this cross build.
run: sudo apt-get remove -y libpango1.0-dev:amd64
- name: Install RISC-V WebKitGTK dependencies
if: matrix.arch == 'riscv64'
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: libwebkit2gtk-4.1-dev:riscv64 libappindicator3-dev:riscv64 libssl-dev:riscv64 libxdo-dev:riscv64 libdbus-1-dev:riscv64 g++-riscv64-linux-gnu
- name: Configure RISC-V cross compilation
if: matrix.arch == 'riscv64'
run: |
cat >> "$GITHUB_ENV" <<'ENV'
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER=riscv64-linux-gnu-gcc
CC_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-gcc
CXX_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-g++
AR_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-ar
PKG_CONFIG_ALLOW_CROSS=1
PKG_CONFIG_PATH_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_LIBDIR_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
ENV
echo "CMAKE_TOOLCHAIN_FILE_riscv64gc_unknown_linux_gnu=$GITHUB_WORKSPACE/packaging/linux/riscv64.cmake" >> "$GITHUB_ENV"
- uses: actions/setup-node@v7.1.0
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: step-security/rust-cache@v2.9.2
with:
key: tauri-linux-${{ matrix.arch }}
cache-on-failure: true
- run: npm ci --ignore-scripts
working-directory: frontend
- run: ./node_modules/.bin/nx run artcraft:build
working-directory: frontend
- uses: step-security/tauri-action@v1.0.0
with:
projectPath: crates/desktop/artcraft
args: --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config ${{ github.workspace }}/packaging/linux/tauri.conf.json
- name: Package and verify Linux outputs
env:
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: packaging/linux/package.sh "$ARCH" "$TARGET"
- uses: actions/upload-artifact@v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
arch:
if: always() && !cancelled()
needs: build
runs-on: ubuntu-24.04
strategy:
matrix:
arch: [x86_64, aarch64]
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: actions/download-artifact@v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Find tarball
id: tarball
env:
ARCH: ${{ matrix.arch }}
run: |
shopt -s nullglob
files=(dist/linux/artcraft-*-linux-"$ARCH".tar.gz)
test "${#files[@]}" -eq 1
echo "path=${files[0]}" >> "$GITHUB_OUTPUT"
- uses: storytold/craft-common-actions/actions/arch-package@main # extra-depends support (#161)
with:
tarball: ${{ steps.tarball.outputs.path }}
arch: ${{ matrix.arch }}
extra-depends: webkit2gtk-4.1 gtk3 openssl
- name: Verify Arch runtime dependencies
run: |
for package in dist/release/*.pkg.tar.zst; do
tar --zstd -xOf "$package" .PKGINFO | grep -Fx 'depend = webkit2gtk-4.1'
done
- uses: actions/upload-artifact@v7.0.2
with:
name: release-arch-${{ matrix.arch }}
path: dist/release/*.pkg.tar.zst
if-no-files-found: error
flatpak:
if: always() && !cancelled()
needs: build
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- {arch: x86_64, runner: ubuntu-24.04}
- {arch: aarch64, runner: ubuntu-24.04-arm}
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- uses: actions/download-artifact@v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: flatpak flatpak-builder elfutils librsvg2-common skopeo
- name: Allow Flatpak build sandboxes
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- uses: storytold/craft-common-actions/actions/import-signing-key@main
with:
key: ${{ secrets.LINUX_SIGNING_KEY }}
- name: Build Flatpak and publish OCI
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: packaging/linux/flatpak.sh
- uses: actions/upload-artifact@v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: |
dist/release/*.flatpak
dist/release/*.oci-manifest.json
if-no-files-found: error
release:
needs: [packaging-check, build, arch, flatpak]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- name: Read version
id: version
run: echo "value=$(jq -r .version crates/desktop/artcraft/tauri.conf.json)" >> "$GITHUB_OUTPUT"
- name: Require signing key
env:
SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
run: test -n "$SIGNING_KEY"
- uses: storytold/craft-common-actions/actions/draft-release@main
with:
version: ${{ steps.version.outputs.value }}
app-title: ArtCraft
tag-prefix: artcraft-v
retain: "true"
github-token: ${{ secrets.GITHUB_TOKEN }}
signing-key: ${{ secrets.LINUX_SIGNING_KEY }}
+31 -16
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,17 +35,24 @@ jobs:
# args: ''
runs-on: ${{ matrix.platform }}
# Successful builds took 10-22 min (2026-04 to 2026-11).
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v7.1.0
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
@@ -63,7 +77,7 @@ jobs:
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci
npm ci --ignore-scripts
echo "=== Version Information ==="
echo "Node version:"
@@ -84,7 +98,7 @@ jobs:
echo "Starting certificate import process..."
# Create certificate file
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
# Debug: Check if certificate file exists and has content
echo "Certificate file info:"
@@ -93,25 +107,25 @@ jobs:
# Debug: Check certificate contents without password (just structure)
echo "Certificate structure (may show error if password protected):"
openssl pkcs12 -info -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -noout || echo "Could not read certificate structure"
openssl pkcs12 -info -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -noout || echo "Could not read certificate structure"
# Debug: Try to list what's in the p12 file
echo "Attempting to list certificate contents:"
security cms -D -i certificate.p12 || echo "Not a CMS file, trying PKCS12..."
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
# Debug: Extract certificate details to verify type and expiration
echo "Extracting certificate details..."
# Extract just the certificate portion and examine it
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
# Also try to see what type of certificate this is
echo "Certificate type check:"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
# Debug: Check if private key exists
echo "Checking for private key..."
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
@@ -139,6 +153,7 @@ jobs:
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Add the keychain to the search list
# shellcheck disable=SC2046
security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed 's/^[[:space:]]*"//;s/"$//')
# List all certificates to debug
@@ -181,11 +196,11 @@ jobs:
security find-certificate -c "Learning Machines" build.keychain -p | openssl x509 -text -noout | grep -E "(Subject:|CN=)" || echo "Could not parse certificate"
# Set the correct signing identity
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> $GITHUB_ENV
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> "$GITHUB_ENV"
echo "Proceeding with identity: Developer ID Application: Learning Machines LLC (DJ6XS33FX8)"
# Also set the team ID
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> $GITHUB_ENV
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> "$GITHUB_ENV"
- name: Pre-build frontend to avoid Tauri build issues
run: |
@@ -195,11 +210,11 @@ jobs:
# Increase Node.js memory limit for the build
export NODE_OPTIONS="--max-old-space-size=8192"
# Use npx to ensure we use the local nx version
echo "Building with npx nx..."
npx nx run artcraft:build
echo "Building with nx..."
./node_modules/.bin/nx run artcraft:build
echo "Checking build output..."
# shellcheck disable=SC2012
ls -la apps/artcraft/dist/ | head -10
# Update Tauri config to skip the build (since we already built)
@@ -215,7 +230,7 @@ jobs:
- name: build and publish
uses: tauri-apps/tauri-action@v0
uses: step-security/tauri-action@v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
+22 -5
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:
- uses: actions/checkout@v4
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
- name: install wreq dependencies
run: |
@@ -39,11 +54,12 @@ jobs:
shell: cmd
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v7.1.0
with:
node-version: lts/*
- name: install frontend dependencies
shell: pwsh
run: |
# Navigate to the frontend directory to install dependencies
cd ./frontend
@@ -51,7 +67,7 @@ jobs:
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci
npm ci --ignore-scripts
echo "=== Version Information ==="
echo "Node version:"
@@ -63,6 +79,7 @@ jobs:
npm list --depth=0 | findstr nx
- name: Pre-build frontend to avoid Tauri build issues
shell: pwsh
run: |
# Build the frontend app from the correct directory
cd ./frontend
@@ -90,7 +107,7 @@ jobs:
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
- name: build and publish
uses: tauri-apps/tauri-action@v0
uses: step-security/tauri-action@v1.0.0
env:
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
RUSTFLAGS: "-C target-feature=+crt-static"
+24
View File
@@ -0,0 +1,24 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners).
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on pull requests.
on:
schedule:
- cron: "27 4 * * *"
push:
branches: [release]
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: false
+34
View File
@@ -0,0 +1,34 @@
name: Lockfile
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
# Docs: https://gh.io/actions-lockfile
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
jobs:
lockfile:
name: Lockfile is in sync
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
run: gh extension install github/gh-actions-lock --pin v0.4.1
- name: Verify the lockfile
run: gh actions-lock --verify
+23
View File
@@ -0,0 +1,23 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
push:
branches: [main, release]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
+22
View File
@@ -0,0 +1,22 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+22
View File
@@ -1,3 +1,25 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
# AGENTS.md
This file provides guidance for coding agents when working with the Artcraft monorepo.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
Generated
+187 -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",
@@ -487,8 +488,10 @@ dependencies = [
"arboard",
"artcraft_api_defs",
"artcraft_client",
"artcraft_router",
"chrono",
"cookie",
"cookie_store_wrapper",
"directories",
"eframe",
"egui",
@@ -496,8 +499,10 @@ dependencies = [
"egui_kittest",
"enums",
"env_logger",
"fal_client",
"image",
"log",
"midjourney_client",
"open",
"reqwest 0.12.23",
"resvg",
@@ -505,9 +510,12 @@ dependencies = [
"rodio",
"serde",
"serde_json",
"tempfile",
"tokens",
"tokio",
"url",
"uuid 1.22.0",
"wry",
]
[[package]]
@@ -546,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"
@@ -631,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"
@@ -671,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"
@@ -875,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"
@@ -1461,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",
@@ -1805,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"
@@ -1822,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",
]
@@ -2129,7 +2124,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2501,7 +2496,7 @@ dependencies = [
"rustc_version",
"toml 0.8.23",
"vswhom",
"winreg",
"winreg 0.52.0",
]
[[package]]
@@ -2691,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]]
@@ -2855,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"
@@ -4761,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]]
@@ -5009,7 +5014,9 @@ name = "midjourney_client"
version = "0.0.1"
dependencies = [
"anyhow",
"base64 0.22.1",
"browser_emulation",
"chrono",
"ciborium",
"cloudflare_errors",
"cookie_store_wrapper",
@@ -5020,10 +5027,13 @@ dependencies = [
"log",
"serde",
"serde_json",
"tempfile",
"test_data",
"test_utils",
"tokio",
"toml 1.1.8+spec-1.1.0",
"url",
"uuid_utils",
"wreq",
"wreq-util",
]
@@ -5105,7 +5115,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6260,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"
@@ -6976,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",
@@ -6988,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",
@@ -7160,7 +7161,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7180,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",
@@ -7230,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"
@@ -8063,6 +8092,7 @@ dependencies = [
"sqlx",
"tempfile",
"tokens",
"tokio",
]
[[package]]
@@ -8690,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"
@@ -9074,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]]
@@ -9584,7 +9624,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9648,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"
@@ -9677,7 +9723,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9829,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"
@@ -9918,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"
@@ -10033,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"
@@ -11368,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"
@@ -11930,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"
@@ -11997,7 +12117,6 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.0",
"zcheapstr",
"zvariant_derive",
+9 -2
View File
@@ -1,4 +1,9 @@
<h1 align="center">ArtCraft</h1>
<p align="center">
<a href="https://sonarcloud.io/project/overview?id=storytold_artcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/artcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center"><strong>The IDE for artists.</strong></p>
<!-- Main video: https://www.youtube.com/watch?v=kzvQMdg66Go
@@ -126,13 +131,15 @@ Additional provider integrations are planned for Kling, Google, Runway, and Luma
## Downloads
- [Stable releases for Windows and macOS](https://getartcraft.com/)
- [Latest builds on GitHub](https://github.com/storytold/artcraft/releases)
- [Stable releases for Windows, macOS, and Linux](https://getartcraft.com/)
- [Latest builds on GitHub](https://github.com/storytold/artcraft/releases) (including AppImage, DEB, RPM, Arch, and Flatpak packages)
- [Linux packaging notes](./packaging/linux/README.md)
- [Build from source](./_docs/dev_setup.md), including Linux
## Documentation
- [Development setup](./_docs/dev_setup.md)
- [Linux packaging](./packaging/linux/README.md)
- [Developer documentation](./_docs)
- [Desktop build and development scripts](./script/artcraft)
- [Roadmap](./ROADMAP.md)
+10 -1
View File
@@ -13,6 +13,10 @@ path = "src/main.rs"
# Internal (Tauri-free) API crates shared with the Tauri app.
artcraft_api_defs.workspace = true
artcraft_client.workspace = true
artcraft_router.workspace = true
cookie_store_wrapper.workspace = true
fal_client.workspace = true
midjourney_client.workspace = true
tokens.workspace = true
# External
@@ -30,15 +34,20 @@ log.workspace = true
open = "5"
reqwest = { workspace = true, features = ["multipart"] }
resvg = "0.45"
rfd = "0.16"
rfd = { version = "0.16", default-features = false, features = ["gtk3"] }
rodio = { version = "0.20", default-features = false, features = ["symphonia-all"] }
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "sync", "time", "fs"] }
url.workspace = true
uuid = { workspace = true, features = ["v4"] }
# Pasting images from the clipboard (egui's own clipboard support is text only).
arboard = { version = "3", default-features = false, features = ["image-data"] }
# The Midjourney sign-in window (its login cookies can only be captured inside a browser).
wry = "0.55"
[dev-dependencies]
egui_kittest = "0.36"
+13 -5
View File
@@ -1,7 +1,8 @@
# ArtCraft Native
ArtCraft's desktop app in pure Rust: an [egui](https://github.com/emilk/egui) UI over the same
ArtCraft API client crates the Tauri app uses. There is no Tauri, no webview and no JavaScript.
ArtCraft API client crates the Tauri app uses. There is no Tauri and no JavaScript; the only
webview is the Midjourney sign-in window, which exists to capture Midjourney's login cookies.
The look follows the brutalist webapp (`artcraft-services/frontend/apps/artcraft-webapp`). That
means near-black surfaces with white/15 hairlines and 3 px corners on controls. Type is Archivo for
@@ -33,11 +34,12 @@ The workspace's default member is the Tauri app, so pass `-p artcraft_native`.
| `backend::session` | Credentials: own file, Tauri cookie import, env config |
| `backend::media_cache` | Thumbnails and previews: downloaded and decoded off-thread, evicted when unused |
| `backend::audio` | One clip at a time: reference previews, feed tracks (pause, seek, volume), waveforms |
| `backend::providers` | Midjourney and fal: accounts, submitting, and a worker that finishes jobs into the library |
| `models` | Model catalog from OmniGen listings, plus names, families and limits |
| `prompt_box` | The shared prompt box: editor, reference deck, keyframes, pickers, model selector |
| `pages` | Create Image, Video and Audio, Home, Library, and the shared page shell |
| `feed` | Generation feed: running, failed and finished jobs, library paging, grid and list |
| `overlays` | Lightbox, library picker, sign-in and settings dialogs |
| `overlays` | Lightbox, library picker, sign-in, settings, provider setup and the Midjourney sign-in window |
| `shell` | Custom title bar, sidebar flush to the window edge, content panel header |
| `theme`, `ui` | Tokens, Lucide-style icons, widgets, toasts, window chrome (drag, resize, caption buttons) |
@@ -52,6 +54,11 @@ How the backend works:
with lenient structs.
- **Identity headers.** Every request sends the desktop identity headers: `Origin` and
`User-Agent`.
- **Other providers.** Images can also run on Midjourney (your own account) or fal (your own API
key), through `artcraft_router` like the Tauri app. The accounts live in the same files
(`~/Artcraft/credentials`), so connecting in either app works in both. Those jobs are tracked in
`~/Artcraft/state/native_provider_jobs.json` and their results uploaded into your library, resuming
after a restart.
## Prompt box parity
@@ -72,7 +79,7 @@ How the backend works:
| Validation: starting frame, text-only banner, limits, uploads | Done |
| Clear all (confirms when references are attached) | Done |
| `@Character` mentions and the Characters modal (create/edit/delete) | Done |
| Provider choice (Midjourney direct, fal) | Not yet; everything runs via ArtCraft |
| Provider choice (Midjourney direct, fal), "Select Provider" | Done (images; video runs on ArtCraft) |
| In-app video playback | Done with FFmpeg on the PATH; otherwise opens in the system player |
| Audio reference playback (click to play/stop) | Done |
| Animated video previews in the feed (with a stills toggle) | Done |
@@ -93,8 +100,9 @@ Feed and lightbox:
## Diagnostics
Set `ARTCRAFT_SCREENSHOT=<file.png>` to save a screenshot once the window has settled.
`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video`, `create-audio` or `library`) picks the page, and
`ARTCRAFT_SCREENSHOT_EXIT=1` closes the app afterwards.
`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video`, `create-audio` or `library`) picks the page,
`ARTCRAFT_OPEN` (`settings` or `midjourney-login`) opens a dialog, and `ARTCRAFT_SCREENSHOT_EXIT=1`
closes the app afterwards. The screenshot shows egui only, not the sign-in webview.
## Tests
+92 -6
View File
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::backend::audio::AudioPlayer;
use crate::backend::media_cache::MediaCache;
use crate::backend::wire::{Prompt, SessionUser};
use crate::backend::providers::Provider;
use crate::backend::{Backend, Event, Modality, SHARE_URL_BASE};
use crate::feed::grid::{RatioCache, ViewMode};
use crate::feed::store::{FeedNotice, FeedStore};
@@ -20,6 +21,7 @@ use crate::overlays::account::{LoginAction, LoginDialog, SettingsAction, setting
use crate::overlays::characters::{CharactersAction, CharactersModal};
use crate::overlays::library_picker::{LibraryPicker, PickerAction};
use crate::overlays::lightbox::{Lightbox, LightboxAction, fit_inside};
use crate::overlays::providers::{AccountAction, AccountsView, LoginEvent, MidjourneyLogin, SetupAction, setup_dialog};
use crate::pages::common::{self, AppRequest, Env, FeedKey};
use crate::pages::create_audio::{AudioSettings, CreateAudioPage};
use crate::pages::create_image::{CreateImagePage, ImageSettings};
@@ -102,6 +104,11 @@ pub struct ArtcraftApp {
/// Characters being made: (job token, name).
pending_characters: Vec<(String, String)>,
settings_open: bool,
/// "Set up {provider}", after generating needed an account that isn't connected.
provider_setup: Option<Provider>,
midjourney_login: Option<MidjourneyLogin>,
/// The fal key being typed (setup dialog and Settings).
fal_key_input: String,
preview: Option<String>,
requests: Vec<AppRequest>,
screenshot: Option<ScreenshotRequest>,
@@ -125,7 +132,20 @@ impl ArtcraftApp {
backend.load_models(modality);
}
backend.refresh_session();
Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }
let mut app = Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, provider_setup: None, midjourney_login: None, fal_key_input: String::new(), preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend };
app.restore_provider_jobs();
// Diagnostics: `ARTCRAFT_OPEN=settings` or `midjourney-login` opens that dialog at launch.
match std::env::var("ARTCRAFT_OPEN").as_deref() {
Ok("settings") => app.settings_open = true,
Ok("midjourney-login") => app.account_action(AccountAction::ConnectMidjourney),
_ => {},
}
app
}
/// Pending cards for the Midjourney and fal jobs still running.
fn restore_provider_jobs(&mut self) {
self.image.feed.restore_local(&self.backend.provider_jobs(), &self.catalog);
}
// --- Events from the backend -------------------------------------------------------------
@@ -139,6 +159,7 @@ impl ArtcraftApp {
for feed in self.feeds_mut() {
feed.reset();
}
self.restore_provider_jobs();
self.next_poll = 0.0;
}
if self.user.is_some() {
@@ -295,6 +316,23 @@ impl ArtcraftApp {
self.next_poll = 0.0;
},
Event::CharacterChanged => self.backend.load_characters(),
Event::ProviderSetupNeeded(provider) => {
self.image.generating = false;
self.provider_setup = Some(provider);
},
Event::ProviderJobFinished { job_id, batch_token } => {
if self.image.feed.finish_local(&job_id) {
self.toasts.success("Image generation complete!");
}
// The batch brings the images into the feed.
self.batch_kinds.insert(job_id.clone(), MediaKind::Image);
self.backend.load_batch(job_id, batch_token);
},
Event::ProviderJobFailed { job_id, reason } => {
self.image.feed.fail_local(&job_id, &reason);
self.toasts.error(reason);
},
Event::ProviderAccountsChanged => {},
Event::Deleted(token) => {
for feed in self.feeds_mut() {
feed.remove_item(&token);
@@ -624,7 +662,7 @@ impl ArtcraftApp {
}
}
fn overlays(&mut self, ctx: &egui::Context) {
fn overlays(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
if let Some((kind, token)) = self.lightbox.as_ref().map(|lb| (lb.kind, lb.token.clone())) {
let key = if self.page == Page::Library { FeedKey::Library } else { FeedKey::of(kind) };
let order = self.feed(key).ordered_tokens();
@@ -706,9 +744,11 @@ impl ArtcraftApp {
}
}
}
if self.settings_open {
if self.settings_open && self.midjourney_login.is_none() {
let username = self.user.as_ref().map(|u| u.username.clone());
match settings_dialog(ctx, &mut self.enter_to_generate, username.as_deref(), self.credits, self.backend.data_root()) {
let accounts = self.backend.accounts();
let view = AccountsView { midjourney: accounts.midjourney_account(), fal_key_hint: accounts.fal_key_hint() };
match settings_dialog(ctx, &mut self.enter_to_generate, username.as_deref(), self.credits, &view, &mut self.fal_key_input, self.backend.data_root()) {
Some(SettingsAction::Close) => self.settings_open = false,
Some(SettingsAction::SignOut) => {
self.settings_open = false;
@@ -718,6 +758,32 @@ impl ArtcraftApp {
self.settings_open = false;
self.open_login();
},
Some(SettingsAction::Account(action)) => self.account_action(action),
None => {},
}
}
if let Some(provider) = self.provider_setup.filter(|_| self.midjourney_login.is_none()) {
match setup_dialog(ctx, provider, &mut self.fal_key_input) {
Some(SetupAction::Close) => self.provider_setup = None,
Some(SetupAction::ConnectMidjourney) => {
self.provider_setup = None;
self.account_action(AccountAction::ConnectMidjourney);
},
Some(SetupAction::SaveFalKey) => {
self.provider_setup = None;
self.account_action(AccountAction::SaveFalKey);
},
None => {},
}
}
if let Some(login) = &mut self.midjourney_login {
match login.show(ctx, frame) {
Some(LoginEvent::Captured(cookies)) => {
self.midjourney_login = None;
self.backend.save_midjourney_login(cookies);
self.toasts.success("Midjourney connected");
},
Some(LoginEvent::Closed) => self.midjourney_login = None,
None => {},
}
}
@@ -740,6 +806,26 @@ impl ArtcraftApp {
}
}
/// Connects or disconnects a provider account.
fn account_action(&mut self, action: AccountAction) {
match action {
AccountAction::ConnectMidjourney => self.midjourney_login = Some(MidjourneyLogin::new(self.backend.data_root())),
AccountAction::DisconnectMidjourney => self.backend.disconnect_midjourney(),
AccountAction::SaveFalKey => match self.backend.accounts().set_fal_key(&self.fal_key_input) {
Ok(()) => {
self.fal_key_input.clear();
self.toasts.success("FAL API key saved");
},
Err(err) => self.toasts.error(format!("Failed to save API key: {err}")),
},
AccountAction::RemoveFalKey => {
if let Err(err) = self.backend.accounts().clear_fal_key() {
self.toasts.error(format!("Failed to clear provider credential: {err}"));
}
},
}
}
fn lightbox_action(&mut self, ctx: &egui::Context, action: LightboxAction, item: &FeedItem) {
match action {
LightboxAction::Close => self.lightbox = None,
@@ -782,7 +868,7 @@ impl eframe::App for ArtcraftApp {
self.timers(ctx);
}
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.cache.poll(&ctx);
if let Some(err) = self.player.poll() {
@@ -795,7 +881,7 @@ impl eframe::App for ArtcraftApp {
for request in std::mem::take(&mut self.requests) {
self.handle_request(&ctx, request);
}
self.overlays(&ctx);
self.overlays(&ctx, frame);
self.toasts.show(&ctx);
window::window_edges(&ctx);
if let Some(shot) = &mut self.screenshot {
+122 -1
View File
@@ -3,6 +3,7 @@
pub mod audio;
pub mod media_cache;
pub mod providers;
pub mod session;
pub mod video;
pub mod wire;
@@ -29,6 +30,7 @@ use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_client::utils::api_host::ApiHost;
use artcraft_client::utils::basic_json_post_request::basic_json_post_request;
use artcraft_client::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use cookie_store_wrapper::cookie_store::CookieStore;
use enums::common::payments_namespace::PaymentsNamespace;
use log::{error, warn};
use serde::de::DeserializeOwned;
@@ -37,6 +39,11 @@ use tokens::tokens::media_files::MediaFileToken;
use crate::feed::types::MediaKind;
use crate::models::{self, ModelInfo};
use providers::Provider;
use providers::accounts::ProviderAccounts;
use providers::jobs::{JobStore, ProviderJob};
use providers::submit::SubmitError;
use providers::worker::Worker;
use session::Credentials;
use wire::{AudioModels, BatchMedia, CharactersPage, MediaFile, PromptResponse, SessionInfo, SessionJobs, SessionUser, UploadResponse, UserMediaList};
@@ -47,6 +54,8 @@ const MODEL_LIST_ATTEMPTS: u32 = 3;
const MODEL_LIST_BACKOFF: Duration = Duration::from_millis(250);
/// The share link base (`SHARE_URL_BASE`).
pub const SHARE_URL_BASE: &str = "https://getartcraft.com/media/";
/// fal's cost estimate (the provider-aware legacy endpoint; OmniGen only prices ArtCraft).
const PROVIDER_COST_PATH: &str = "/v1/generate/cost_estimate/image";
/// Characters are made for this model (the Tauri app's `CharactersModal`).
const CHARACTER_MODEL: &str = "seedance_2p0";
/// Sent on every request (`artcraft_client_identity::ARTCRAFT_DESKTOP_USER_AGENT`).
@@ -136,6 +145,19 @@ pub enum Event {
name: String,
},
CharacterChanged,
/// Generating with `Provider` needs its account connected first.
ProviderSetupNeeded(Provider),
/// A Midjourney or fal job's results are in the library, as `batch_token`.
ProviderJobFinished {
job_id: String,
batch_token: String,
},
ProviderJobFailed {
job_id: String,
reason: String,
},
/// A provider account was connected or disconnected.
ProviderAccountsChanged,
Deleted(String),
Saved(PathBuf),
Toast {
@@ -151,6 +173,8 @@ pub struct EnqueueMeta {
pub model_id: String,
pub batch_count: u32,
pub ref_image: Option<String>,
/// Who runs it (other providers' jobs are tracked locally).
pub provider: Provider,
}
/// A finished upload: the media token, plus what the server knows about the file.
@@ -172,6 +196,8 @@ pub struct Backend {
creds: Arc<RwLock<Option<Credentials>>>,
http: reqwest::Client,
device_login_cancel: Arc<AtomicBool>,
accounts: ProviderAccounts,
jobs: JobStore,
}
impl Backend {
@@ -182,7 +208,10 @@ impl Backend {
let host = session::api_host(&root);
let creds = session::load(&root);
let http = reqwest::Client::builder().user_agent(USER_AGENT).gzip(true).timeout(Duration::from_secs(60)).build().unwrap_or_default();
Self { rt, ctx, tx, rx, host, root, creds: Arc::new(RwLock::new(creds)), http, device_login_cancel: Arc::new(AtomicBool::new(false)) }
let (accounts, jobs) = (ProviderAccounts::load(&root), JobStore::load(&root));
let creds = Arc::new(RwLock::new(creds));
rt.spawn(Worker { host: host.clone(), creds: creds.clone(), http: http.clone(), accounts: accounts.clone(), jobs: jobs.clone(), tx: tx.clone(), ctx: ctx.clone() }.run());
Self { rt, ctx, tx, rx, host, root, creds, http, device_login_cancel: Arc::new(AtomicBool::new(false)), accounts, jobs }
}
pub fn runtime(&self) -> tokio::runtime::Handle {
@@ -408,6 +437,82 @@ impl Backend {
});
}
// --- Other providers ---------------------------------------------------------------------
/// Starts an image generation at Midjourney or fal; `image_urls` are the references' links.
pub fn generate_with_provider(&self, provider: Provider, fields: Map<String, Value>, image_urls: Vec<String>, meta: EnqueueMeta) {
let (api, accounts, jobs) = (self.api(), self.accounts.clone(), self.jobs.clone());
self.spawn(move |tx| async move {
match providers::submit::submit(&api.host, api.creds.as_ref(), &accounts, provider, &fields, image_urls).await {
Ok(job) => {
let id = job.id.clone();
jobs.put(job);
let _ = tx.send(Event::Enqueued { modality: Modality::Image, job_tokens: vec![id], meta });
},
Err(SubmitError::NeedsAccount(provider)) => {
let _ = tx.send(Event::ProviderSetupNeeded(provider));
},
Err(SubmitError::Message(message)) => {
let _ = tx.send(Event::EnqueueFailed { modality: Modality::Image, message, status: None });
},
}
});
}
/// What an image generation costs at `provider`: nothing extra for direct Midjourney (it uses
/// the user's subscription), fal's price from the server.
pub fn estimate_provider_cost(&self, provider: Provider, key: String, fields: Map<String, Value>) {
match provider {
Provider::Artcraft => self.estimate_cost(Modality::Image, key, fields),
Provider::Midjourney => {
let _ = self.tx.send(Event::Cost { key, credits: Some(0) });
},
Provider::Fal => {
let api = self.api();
self.spawn(move |tx| async move {
let credits = match basic_json_post_request::<_, Value>(&api.host, PROVIDER_COST_PATH, None, fal_cost_body(&fields)).await {
Ok(response) => response.get("cost_in_credits").and_then(Value::as_u64),
Err(err) => {
warn!("FAL cost estimate failed: {}", api_message(&err));
None
},
};
let _ = tx.send(Event::Cost { key, credits });
});
},
}
}
pub fn accounts(&self) -> &ProviderAccounts {
&self.accounts
}
/// Midjourney and fal jobs still running (they get pending cards at startup).
pub fn provider_jobs(&self) -> Vec<ProviderJob> {
self.jobs.all()
}
/// Keeps the Midjourney login captured by the sign-in window.
pub fn save_midjourney_login(&self, cookies: CookieStore) {
let accounts = self.accounts.clone();
self.spawn(move |tx| async move {
if let Err(err) = accounts.midjourney.save_login(cookies).await {
let _ = tx.send(Event::Toast { error: true, message: format!("Couldn't save the Midjourney login: {err}") });
}
let _ = tx.send(Event::ProviderAccountsChanged);
});
}
pub fn disconnect_midjourney(&self) {
let accounts = self.accounts.clone();
self.spawn(move |tx| async move {
if let Err(err) = accounts.midjourney.clear_credentials().await {
let _ = tx.send(Event::Toast { error: true, message: format!("Couldn't disconnect Midjourney: {err}") });
}
let _ = tx.send(Event::ProviderAccountsChanged);
});
}
// --- Uploads -----------------------------------------------------------------------------
/// Uploads a reference file from disk; images, MP4 videos and audio.
@@ -775,6 +880,15 @@ fn session_from_set_cookie(header: &str) -> Option<String> {
cookie::Cookie::parse(header).ok().filter(|c| c.name() == "session").map(|c| c.value().to_owned())
}
/// The fal cost request (`estimate_image_cost_command`): the page's fields, its references
/// counted as the generation mode.
fn fal_cost_body(fields: &Map<String, Value>) -> Value {
let images = fields.get("image_media_tokens").and_then(Value::as_array).map_or(0, Vec::len);
let mode = if images == 0 { serde_json::json!({ "type": "text_to_image" }) } else { serde_json::json!({ "type": "image_edit", "count": images }) };
let field = |name: &str| fields.get(name).cloned().unwrap_or(Value::Null);
serde_json::json!({ "model": field("model"), "provider": "fal", "generation_mode": mode, "aspect_ratio": field("aspect_ratio"), "resolution": field("resolution"), "quality": field("quality"), "image_batch_count": field("image_batch_count") })
}
/// All job tokens a generate call created (`inference_job_token` plus `all_job_tokens`).
fn job_tokens(response: &Value) -> Vec<String> {
let mut tokens: Vec<String> = response.get("inference_job_token").and_then(Value::as_str).map(str::to_owned).into_iter().collect();
@@ -849,6 +963,13 @@ mod tests {
assert_eq!(job_tokens(&serde_json::json!({ "inference_job_token": "only" })), ["only"]);
}
#[test]
fn fal_costs_count_references_as_the_mode() {
let fields = serde_json::json!({ "model": "flux_1_dev", "prompt": "x", "aspect_ratio": "square", "image_batch_count": 2, "image_media_tokens": ["mf_estimate_0"] });
let body = fal_cost_body(fields.as_object().unwrap());
assert_eq!(body, serde_json::json!({ "model": "flux_1_dev", "provider": "fal", "generation_mode": { "type": "image_edit", "count": 1 }, "aspect_ratio": "square", "resolution": null, "quality": null, "image_batch_count": 2 }));
}
#[test]
fn reads_the_servers_error_message() {
assert_eq!(body_message(r#"{"success":false,"error_message":"Not enough credits"}"#).as_deref(), Some("Not enough credits"));
@@ -0,0 +1,93 @@
//! The provider accounts, in the same files as the Tauri app (`~/Artcraft/credentials`), so an
//! account connected in either app works in both: the Midjourney login
//! (`midjourney_cookies.toml`) and the fal API key (`fal.api_key.txt`).
use std::path::{Path, PathBuf};
use std::sync::Arc;
use log::warn;
use midjourney_client::credentials::credential_manager::MidjourneyCredentialManager;
const MIDJOURNEY_FILENAME: &str = "midjourney_cookies.toml";
/// The Tauri app's pre-TOML Midjourney state, removed on disconnect so it can't come back.
const MIDJOURNEY_LEGACY_FILENAME: &str = "midjourney_state.json";
const FAL_KEY_FILENAME: &str = "fal.api_key.txt";
#[derive(Clone)]
pub struct ProviderAccounts {
pub midjourney: MidjourneyCredentialManager,
fal_key_path: Arc<PathBuf>,
}
impl ProviderAccounts {
pub fn load(data_root: &Path) -> Self {
let dir = data_root.join("credentials");
if let Err(err) = std::fs::create_dir_all(&dir) {
warn!("Couldn't create {}: {err}", dir.display());
}
let (path, legacy) = (dir.join(MIDJOURNEY_FILENAME), vec![dir.join(MIDJOURNEY_LEGACY_FILENAME)]);
let midjourney = MidjourneyCredentialManager::load(path.clone(), legacy.clone()).unwrap_or_else(|err| {
warn!("Couldn't load the Midjourney login: {err}");
MidjourneyCredentialManager::new(path, legacy, None)
});
Self { midjourney, fal_key_path: Arc::new(dir.join(FAL_KEY_FILENAME)) }
}
/// The connected Midjourney account's email ("" when it's connected but unknown), or `None`.
pub fn midjourney_account(&self) -> Option<String> {
match (self.midjourney.maybe_copy_cookie_store(), self.midjourney.maybe_account_info()) {
(Ok(None), Ok(None)) | (Err(_), _) | (_, Err(_)) => None,
(_, Ok(info)) => Some(info.and_then(|i| i.maybe_email).unwrap_or_default()),
}
}
pub fn fal_key(&self) -> Option<String> {
std::fs::read_to_string(&*self.fal_key_path).ok().map(|k| k.trim().to_owned()).filter(|k| !k.is_empty())
}
/// The key as Settings shows it (`first6********`).
pub fn fal_key_hint(&self) -> Option<String> {
self.fal_key().map(|k| mask_key(&k))
}
pub fn set_fal_key(&self, key: &str) -> std::io::Result<()> {
std::fs::write(&*self.fal_key_path, key.trim())
}
pub fn clear_fal_key(&self) -> std::io::Result<()> {
match std::fs::remove_file(&*self.fal_key_path) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => Err(err),
_ => Ok(()),
}
}
}
/// `first6********`, or just the stars for short keys (`provider_list_command`).
fn mask_key(key: &str) -> String {
if key.chars().count() > 6 {
format!("{}********", key.chars().take(6).collect::<String>())
} else {
"********".to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fal_keys_are_trimmed_masked_and_cleared() {
let dir = std::env::temp_dir().join(format!("artcraft-native-accounts-{}", uuid::Uuid::new_v4()));
let accounts = ProviderAccounts::load(&dir);
assert_eq!(accounts.fal_key(), None);
accounts.set_fal_key(" abcdef123456:secret \n").unwrap();
assert_eq!(accounts.fal_key().as_deref(), Some("abcdef123456:secret"));
assert_eq!(accounts.fal_key_hint().as_deref(), Some("abcdef********"));
accounts.clear_fal_key().unwrap();
accounts.clear_fal_key().unwrap();
assert_eq!(accounts.fal_key(), None);
assert_eq!(mask_key("short"), "********");
assert_eq!(accounts.midjourney_account(), None);
let _ = std::fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,122 @@
//! Generations running at Midjourney or fal, saved (`state/native_provider_jobs.json`) so they
//! finish even after a restart. Each also checkpoints its upload into the user's library: the
//! batch it goes into, one idempotency token per result, and what's uploaded so far.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use log::warn;
use serde::{Deserialize, Serialize};
use super::Provider;
const FILENAME: &str = "native_provider_jobs.json";
/// One generation at another provider.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProviderJob {
/// Ours (`local_<uuid>`): the feed's pending card is keyed by it.
pub id: String,
pub provider: Provider,
pub model_id: String,
pub prompt: String,
/// Unix seconds.
pub created_at: i64,
pub batch_count: u32,
/// The provider's job: Midjourney's job id, or fal's request id.
pub provider_job_id: String,
/// fal's queue URLs.
pub maybe_status_url: Option<String>,
pub maybe_response_url: Option<String>,
/// The ArtCraft prompt the results are attributed to.
pub maybe_prompt_token: Option<String>,
/// The library batch the results go into.
pub batch_token: String,
/// Upload idempotency tokens, one per result (made as needed).
pub upload_tokens: Vec<String>,
/// Media tokens uploaded so far, in result order.
pub uploaded: Vec<String>,
}
impl ProviderJob {
/// The idempotency token for result `index`, making (and keeping) new ones as needed.
pub fn upload_token(&mut self, index: usize) -> String {
while self.upload_tokens.len() <= index {
self.upload_tokens.push(uuid::Uuid::new_v4().to_string());
}
self.upload_tokens[index].clone()
}
}
/// The saved jobs, shared by the submitter, the worker and the UI.
#[derive(Clone)]
pub struct JobStore {
path: Arc<PathBuf>,
jobs: Arc<Mutex<Vec<ProviderJob>>>,
}
impl JobStore {
pub fn load(data_root: &Path) -> Self {
let path = data_root.join("state").join(FILENAME);
let jobs = std::fs::read_to_string(&path).ok().and_then(|text| serde_json::from_str(&text).map_err(|err| warn!("Ignoring unreadable {}: {err}", path.display())).ok()).unwrap_or_default();
Self { path: Arc::new(path), jobs: Arc::new(Mutex::new(jobs)) }
}
pub fn all(&self) -> Vec<ProviderJob> {
self.jobs.lock().map(|j| j.clone()).unwrap_or_default()
}
/// Adds or replaces (by id) a job, then saves.
pub fn put(&self, job: ProviderJob) {
self.mutate(|jobs| match jobs.iter_mut().find(|j| j.id == job.id) {
Some(existing) => *existing = job,
None => jobs.push(job),
});
}
pub fn remove(&self, id: &str) {
self.mutate(|jobs| jobs.retain(|j| j.id != id));
}
fn mutate(&self, change: impl FnOnce(&mut Vec<ProviderJob>)) {
let Ok(mut jobs) = self.jobs.lock() else {
return;
};
change(&mut jobs);
if let Err(err) = save(&self.path, &jobs) {
warn!("Couldn't save {}: {err}", self.path.display());
}
}
}
/// Writes atomically, so a crash can't truncate the list.
fn save(path: &Path, jobs: &[ProviderJob]) -> anyhow::Result<()> {
let dir = path.parent().ok_or_else(|| anyhow::anyhow!("no state directory"))?;
std::fs::create_dir_all(dir)?;
let mut file = tempfile::NamedTempFile::new_in(dir)?;
file.write_all(serde_json::to_string_pretty(jobs)?.as_bytes())?;
file.persist(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jobs_survive_a_restart_and_keep_their_upload_tokens() {
let dir = std::env::temp_dir().join(format!("artcraft-native-jobs-{}", uuid::Uuid::new_v4()));
let store = JobStore::load(&dir);
let mut job = ProviderJob { id: "local_1".into(), provider: Provider::Midjourney, model_id: "midjourney_7".into(), prompt: "a fox".into(), created_at: 1, batch_count: 4, provider_job_id: "mj".into(), maybe_status_url: None, maybe_response_url: None, maybe_prompt_token: None, batch_token: "batch_1".into(), upload_tokens: Vec::new(), uploaded: Vec::new() };
let first = job.upload_token(2);
assert_eq!(job.upload_tokens.len(), 3);
assert_eq!(job.upload_token(2), first, "a retried upload reuses its token");
store.put(job.clone());
let reloaded = JobStore::load(&dir);
assert_eq!(reloaded.all(), vec![job]);
reloaded.remove("local_1");
assert!(JobStore::load(&dir).all().is_empty());
let _ = std::fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,110 @@
//! Generating through another provider (`ClassyModelSelector`'s "Select Provider"): Midjourney
//! with the user's own account, or fal with their API key, instead of ArtCraft. Images only;
//! video always runs on ArtCraft. Jobs are tracked here (they aren't ArtCraft jobs) and their
//! results uploaded into the user's library, like the Tauri app.
pub mod accounts;
pub mod jobs;
pub mod submit;
pub mod worker;
use serde::{Deserialize, Serialize};
use crate::models::ModelInfo;
/// Midjourney's models; direct jobs always make four images and take no references.
const MIDJOURNEY_MODELS: [&str; 4] = ["midjourney", "midjourney_7", "midjourney_7_niji", "midjourney_8"];
const MIDJOURNEY_BATCH: u16 = 4;
/// Who runs a generation.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Provider {
#[default]
Artcraft,
Midjourney,
Fal,
}
impl Provider {
/// The picker's name (`getProviderDisplayName`).
pub fn label(self) -> &'static str {
match self {
Provider::Artcraft => "ArtCraft",
Provider::Midjourney => "Midjourney",
Provider::Fal => "FAL",
}
}
/// The logo's key in the creator icons.
pub fn icon(self) -> &'static str {
match self {
Provider::Artcraft => "artcraft",
Provider::Midjourney => "midjourney",
Provider::Fal => "fal",
}
}
/// The providers `model_id` can run on, the default first (`ImageModels.ts`,
/// `buildModelsFromListing.ts`): fal for four ArtCraft models, Midjourney directly for
/// Midjourney's.
pub fn for_model(model_id: &str) -> &'static [Provider] {
match model_id {
"flux_1_dev" | "flux_1_schnell" | "nano_banana_2" | "nano_banana_pro" => &[Provider::Artcraft, Provider::Fal],
"midjourney" => &[Provider::Midjourney, Provider::Artcraft],
"midjourney_7" | "midjourney_7_niji" | "midjourney_8" => &[Provider::Artcraft, Provider::Midjourney],
_ => &[Provider::Artcraft],
}
}
/// `picked` if `model_id` runs there, else the model's default.
pub fn resolve(model_id: &str, picked: Option<Provider>) -> Provider {
let allowed = Provider::for_model(model_id);
picked.filter(|p| allowed.contains(p)).unwrap_or(allowed[0])
}
/// What the model can do on this provider (`imageModelForProvider`): direct Midjourney makes
/// four images, without references, resolution or quality choices.
pub fn adapt(self, model: &ModelInfo) -> ModelInfo {
let mut m = model.clone();
if self == Provider::Midjourney && is_midjourney(&model.id) {
m.batch_sizes = vec![MIDJOURNEY_BATCH];
m.batch_default = MIDJOURNEY_BATCH;
m.image_refs_max = 0;
m.resolutions.clear();
m.resolution_default = None;
m.qualities.clear();
m.quality_default = None;
}
m
}
}
pub fn is_midjourney(model_id: &str) -> bool {
MIDJOURNEY_MODELS.contains(&model_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn models_offer_their_providers_default_first() {
assert_eq!(Provider::for_model("nano_banana_pro"), [Provider::Artcraft, Provider::Fal]);
assert_eq!(Provider::for_model("midjourney"), [Provider::Midjourney, Provider::Artcraft]);
assert_eq!(Provider::for_model("midjourney_8"), [Provider::Artcraft, Provider::Midjourney]);
assert_eq!(Provider::for_model("seedream_4"), [Provider::Artcraft]);
assert_eq!(Provider::resolve("seedream_4", Some(Provider::Fal)), Provider::Artcraft, "a pick the model can't use falls back");
assert_eq!(Provider::resolve("flux_1_dev", Some(Provider::Fal)), Provider::Fal);
}
#[test]
fn direct_midjourney_makes_four_images_without_options() {
let model = ModelInfo { id: "midjourney_7".into(), batch_sizes: vec![1, 2, 4], batch_default: 1, image_refs_max: 1, resolutions: vec!["one_k".into()], aspect_ratios: vec!["square".into()], ..Default::default() };
let direct = Provider::Midjourney.adapt(&model);
assert_eq!((direct.batch_sizes.as_slice(), direct.batch_default, direct.image_refs_max), ([4].as_slice(), 4, 0));
assert!(direct.resolutions.is_empty());
assert_eq!(direct.aspect_ratios, model.aspect_ratios, "aspect ratios stay");
assert_eq!(Provider::Artcraft.adapt(&model).batch_sizes, model.batch_sizes);
}
}
@@ -0,0 +1,184 @@
//! Starting an image generation at Midjourney or fal (the Tauri app's `handle_midjourney` and
//! `handle_fal`): the ArtCraft prompt record, then the provider's request through the router.
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::prompts::create_prompt::create_prompt;
use artcraft_client::utils::api_host::ApiHost;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::router_image_model::RouterImageModel;
use artcraft_router::api::router_provider::RouterProvider;
use artcraft_router::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::client::router_midjourney_client::RouterMidjourneyClient;
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use artcraft_router::generate::generate_image::router_image_request_to_artcraft_prompt::router_image_request_to_artcraft_prompt;
use log::warn;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use tokens::tokens::batch_generations::BatchGenerationToken;
use super::accounts::ProviderAccounts;
use super::jobs::ProviderJob;
use super::{Provider, is_midjourney};
use crate::backend::api_message;
/// Direct Midjourney jobs always make this many images.
const MIDJOURNEY_BATCH: u16 = 4;
/// Why a generation couldn't start.
#[derive(Debug, PartialEq)]
pub enum SubmitError {
/// The provider's account isn't connected (or its login expired).
NeedsAccount(Provider),
Message(String),
}
impl From<String> for SubmitError {
fn from(message: String) -> Self {
SubmitError::Message(message)
}
}
impl From<&str> for SubmitError {
fn from(message: &str) -> Self {
SubmitError::Message(message.to_owned())
}
}
/// Starts `fields` (the image page's OmniGen request) at `provider`; `image_urls` are the
/// references' CDN links.
pub async fn submit(host: &ApiHost, creds: Option<&StorytellerCredentialSet>, accounts: &ProviderAccounts, provider: Provider, fields: &Map<String, Value>, image_urls: Vec<String>) -> Result<ProviderJob, SubmitError> {
match provider {
Provider::Midjourney => submit_midjourney(host, creds, accounts, fields, image_urls).await,
Provider::Fal => submit_fal(host, creds, accounts, fields, image_urls).await,
Provider::Artcraft => Err("ArtCraft generations go through OmniGen".into()),
}
}
async fn submit_midjourney(host: &ApiHost, creds: Option<&StorytellerCredentialSet>, accounts: &ProviderAccounts, fields: &Map<String, Value>, image_urls: Vec<String>) -> Result<ProviderJob, SubmitError> {
let model_id = model_id(fields);
if !is_midjourney(model_id) {
return Err("This model cannot use a Midjourney account".into());
}
if !image_urls.is_empty() {
return Err("Direct Midjourney does not support image references yet".into());
}
if fields.get("image_batch_count").and_then(Value::as_u64).is_some_and(|n| n != u64::from(MIDJOURNEY_BATCH)) {
return Err("Direct Midjourney generates four images per job".into());
}
let mut builder = request_builder(RouterProvider::Midjourney, fields, Vec::new())?;
builder.resolution = None;
builder.quality = None;
builder.image_batch_count = Some(MIDJOURNEY_BATCH);
let prompt_request = router_image_request_to_artcraft_prompt(&builder);
let Ok(ImageGenerationDraftOrRequest::Request(generation)) = builder.build2() else {
return Err("Unexpected Midjourney request draft".into());
};
let session = accounts.midjourney.session().await.map_err(|err| err.to_string())?.ok_or(SubmitError::NeedsAccount(Provider::Midjourney))?;
// The prompt comes first, so the results can be attributed to it after a restart.
let creds = creds.ok_or("Please sign in to generate.")?;
let prompt = create_prompt(host, Some(creds), prompt_request).await.map_err(|err| api_message(&err))?;
let client = RouterClient::Midjourney(RouterMidjourneyClient::new(session.cookie_header, session.user_id, session.browser));
let GenerateImageResponse::Midjourney(response) = generation.send_request(&client).await.map_err(router_message(Provider::Midjourney))? else {
return Err("Unexpected Midjourney response".into());
};
Ok(new_job(Provider::Midjourney, fields, u32::from(MIDJOURNEY_BATCH), response.job_id, None, None, Some(prompt.prompt_token.to_string())))
}
async fn submit_fal(host: &ApiHost, creds: Option<&StorytellerCredentialSet>, accounts: &ProviderAccounts, fields: &Map<String, Value>, image_urls: Vec<String>) -> Result<ProviderJob, SubmitError> {
let key = accounts.fal_key().ok_or(SubmitError::NeedsAccount(Provider::Fal))?;
let builder = request_builder(RouterProvider::Fal, fields, image_urls)?;
// The prompt record is a nicety here: the generation goes ahead without it.
let maybe_prompt_token = match creds {
Some(creds) => create_prompt(host, Some(creds), router_image_request_to_artcraft_prompt(&builder)).await.map(|p| p.prompt_token.to_string()).map_err(|err| warn!("Couldn't create the prompt record: {}", api_message(&err))).ok(),
None => None,
};
let generation = match builder.build2() {
Ok(ImageGenerationDraftOrRequest::Request(generation)) => generation,
Ok(ImageGenerationDraftOrRequest::Draft(_)) => return Err("Fal should not be sending draft requests".into()),
Err(err) => return Err(err.to_string().into()),
};
let client = RouterClient::Fal(RouterFalClient::new_polling_only_from_raw_key(&key));
let GenerateImageResponse::Fal(response) = generation.send_request(&client).await.map_err(router_message(Provider::Fal))? else {
return Err("Unexpected FAL response".into());
};
let request_id = response.request_id.or(response.gateway_request_id).ok_or("FAL didn't return a request id")?;
let count = fields.get("image_batch_count").and_then(Value::as_u64).map_or(1, |n| n.max(1) as u32);
Ok(new_job(Provider::Fal, fields, count, request_id, response.maybe_status_url, response.maybe_response_url, maybe_prompt_token))
}
/// The router's request for the page's fields. The setting values are the router's own names
/// (`wide_sixteen_by_nine`, `one_k`, `high`).
fn request_builder(provider: RouterProvider, fields: &Map<String, Value>, image_urls: Vec<String>) -> Result<GenerateImageRequestBuilder, String> {
let model = match (parse::<RouterImageModel>(fields.get("model")), model_id(fields)) {
(Some(model), _) => model,
// The generic Midjourney model is the newest version.
(None, "midjourney") => RouterImageModel::Midjourney8,
(None, id) => return Err(format!("{id} isn't available on this provider")),
};
let mismatch = if matches!(provider, RouterProvider::Midjourney) { GenerationModeMismatchStrategy::AbortGeneration } else { GenerationModeMismatchStrategy::GenerateAnyway };
Ok(GenerateImageRequestBuilder { model, provider, prompt: fields.get("prompt").and_then(Value::as_str).map(str::to_owned), image_inputs: (!image_urls.is_empty()).then_some(ImageListRef::Urls(image_urls)), resolution: parse(fields.get("resolution")), aspect_ratio: parse(fields.get("aspect_ratio")), quality: parse(fields.get("quality")), image_batch_count: fields.get("image_batch_count").and_then(Value::as_u64).map(|n| n as u16), horizontal_angle: None, vertical_angle: None, zoom: None, request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, generation_mode_mismatch_strategy: Some(mismatch), idempotency_token: None })
}
fn new_job(provider: Provider, fields: &Map<String, Value>, batch_count: u32, provider_job_id: String, maybe_status_url: Option<String>, maybe_response_url: Option<String>, maybe_prompt_token: Option<String>) -> ProviderJob {
ProviderJob { id: format!("local_{}", uuid::Uuid::new_v4()), provider, model_id: model_id(fields).to_owned(), prompt: fields.get("prompt").and_then(Value::as_str).unwrap_or_default().to_owned(), created_at: chrono::Utc::now().timestamp(), batch_count, provider_job_id, maybe_status_url, maybe_response_url, maybe_prompt_token, batch_token: BatchGenerationToken::generate().to_string(), upload_tokens: Vec::new(), uploaded: Vec::new() }
}
/// What the user sees for a router error: billing problems point at the provider's website
/// (`GenericProviderBillingBlock`).
fn router_message(provider: Provider) -> impl Fn(ArtcraftRouterError) -> SubmitError {
move |err| match err {
ArtcraftRouterError::ProviderBillingError(_) => SubmitError::Message(format!("Please set up {} on their website to use it with ArtCraft.", provider.label())),
other => SubmitError::Message(other.to_string()),
}
}
fn model_id(fields: &Map<String, Value>) -> &str {
fields.get("model").and_then(Value::as_str).unwrap_or_default()
}
fn parse<T: DeserializeOwned>(value: Option<&Value>) -> Option<T> {
value.cloned().and_then(|v| serde_json::from_value(v).ok())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn page_fields_become_the_routers_request() {
let fields = json!({ "model": "nano_banana_pro", "prompt": "a fox", "aspect_ratio": "wide_sixteen_by_nine", "resolution": "two_k", "quality": "high", "image_batch_count": 2 });
let b = request_builder(RouterProvider::Fal, fields.as_object().unwrap(), vec!["https://cdn/x.png".into()]).unwrap();
assert_eq!(b.model, RouterImageModel::NanoBananaPro);
assert_eq!((b.prompt.as_deref(), b.image_batch_count), (Some("a fox"), Some(2)));
assert!(b.aspect_ratio.is_some() && b.resolution.is_some() && b.quality.is_some() && b.image_inputs.is_some());
let generic = request_builder(RouterProvider::Midjourney, json!({ "model": "midjourney" }).as_object().unwrap(), Vec::new()).unwrap();
assert_eq!(generic.model, RouterImageModel::Midjourney8);
assert!(request_builder(RouterProvider::Fal, json!({ "model": "seedance_2p0" }).as_object().unwrap(), Vec::new()).is_err());
}
#[tokio::test]
async fn direct_midjourney_checks_its_limits_before_anything_else() {
let dir = std::env::temp_dir().join(format!("artcraft-native-submit-{}", uuid::Uuid::new_v4()));
let accounts = ProviderAccounts::load(&dir);
let host = ApiHost::Storyteller;
let run = |fields: Value, urls: Vec<String>| {
let (host, accounts) = (&host, &accounts);
async move { submit(host, None, accounts, Provider::Midjourney, fields.as_object().unwrap(), urls).await }
};
assert_eq!(run(json!({ "model": "flux_1_dev" }), Vec::new()).await.unwrap_err(), SubmitError::Message("This model cannot use a Midjourney account".into()));
assert_eq!(run(json!({ "model": "midjourney_7" }), vec!["u".into()]).await.unwrap_err(), SubmitError::Message("Direct Midjourney does not support image references yet".into()));
assert_eq!(run(json!({ "model": "midjourney_7", "image_batch_count": 2 }), Vec::new()).await.unwrap_err(), SubmitError::Message("Direct Midjourney generates four images per job".into()));
assert_eq!(run(json!({ "model": "midjourney_7", "prompt": "a fox", "image_batch_count": 4 }), Vec::new()).await.unwrap_err(), SubmitError::NeedsAccount(Provider::Midjourney), "no login yet");
let fal = submit(&host, None, &accounts, Provider::Fal, json!({ "model": "flux_1_dev" }).as_object().unwrap(), Vec::new()).await;
assert_eq!(fal.unwrap_err(), SubmitError::NeedsAccount(Provider::Fal));
let _ = std::fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,335 @@
//! Finishing provider jobs in the background (the Tauri app's `midjourney_long_polling_thread`
//! and `poll_fal_tasks`): fal's queue is polled; Midjourney's feed is read every 15 s and its
//! websocket reports completions sooner. Finished results are downloaded and uploaded into the
//! user's library as one batch, resuming where they left off after a failure or restart.
use std::collections::HashSet;
use std::sync::mpsc::Sender;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{UploadImageFromFileArgs, upload_image_media_file_from_file_with_idempotency_token};
use artcraft_client::utils::api_host::ApiHost;
use enums::common::generation_provider::GenerationProvider;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::polling::poll_job_response::poll_job_response::{PollJobResponseArgs, poll_job_response};
use fal_client::polling::poll_job_response::success_case_extractors::PollResponseExtractedContents;
use fal_client::polling::poll_job_status::poll_job_status::{FalJobStatus, PollJobStatusArgs, poll_job_status};
use log::{info, warn};
use midjourney_client::client::websocket::midjourney_websocket::MidjourneyWebSocket;
use midjourney_client::client::websocket::midjourney_ws_event::MidjourneyWsEvent;
use midjourney_client::client::websocket::open_midjourney_websocket::{OpenMidjourneyWebSocketRequest, open_midjourney_websocket};
use midjourney_client::credentials::credential_manager::MidjourneySession;
use midjourney_client::endpoints::imagine::{ImagineArgs, ImagineRequest, imagine};
use midjourney_client::recipes::get_user_info::{GetUserInfoArgs, get_user_info};
use midjourney_client::utils::image_downloader_client::ImageDownloaderClient;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::prompts::PromptToken;
use tokio::sync::broadcast::Receiver;
use tokio::sync::broadcast::error::TryRecvError;
use tokio::time::timeout;
use super::Provider;
use super::accounts::ProviderAccounts;
use super::jobs::{JobStore, ProviderJob};
use crate::backend::Event;
use crate::backend::session::Credentials;
/// How often jobs are checked (fal's poll interval).
const TICK: Duration = Duration::from_secs(2);
const FEED_INTERVAL: Duration = Duration::from_secs(15);
const SOCKET_RETRY_INTERVAL: Duration = Duration::from_secs(60);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
/// Each download and upload of a result.
const TRANSFER_TIMEOUT: Duration = Duration::from_secs(60);
/// A Midjourney job not seen finished by then is given up on.
const MIDJOURNEY_GIVE_UP_SECS: i64 = 60 * 60;
/// Direct Midjourney jobs make four images.
const MIDJOURNEY_RESULTS: usize = 4;
pub struct Worker {
pub host: ApiHost,
pub creds: Arc<RwLock<Option<Credentials>>>,
pub http: reqwest::Client,
pub accounts: ProviderAccounts,
pub jobs: JobStore,
pub tx: Sender<Event>,
pub ctx: egui::Context,
}
/// Where a job's results come from.
enum Results<'a> {
Urls(Vec<String>),
Midjourney(&'a ImageDownloaderClient),
}
impl Results<'_> {
fn count(&self) -> usize {
match self {
Results::Urls(urls) => urls.len(),
Results::Midjourney(_) => MIDJOURNEY_RESULTS,
}
}
}
/// What the Midjourney side remembers between ticks.
#[derive(Default)]
struct MidjourneyWatch {
socket: Option<Socket>,
/// Jobs seen finished (by the websocket or the feed).
finished: HashSet<String>,
next_feed: Option<Instant>,
next_socket_attempt: Option<Instant>,
}
struct Socket {
credential_id: String,
socket: MidjourneyWebSocket,
events: Receiver<Arc<MidjourneyWsEvent>>,
subscribed: HashSet<String>,
}
impl Worker {
pub async fn run(self) {
let mut watch = MidjourneyWatch::default();
loop {
self.tick(&mut watch).await;
tokio::time::sleep(TICK).await;
}
}
async fn tick(&self, watch: &mut MidjourneyWatch) {
let jobs = self.jobs.all();
let (midjourney, fal): (Vec<ProviderJob>, Vec<ProviderJob>) = jobs.into_iter().partition(|j| j.provider == Provider::Midjourney);
if midjourney.is_empty() {
watch.close();
}
// Results go into the library: nothing can finish until someone's signed in.
let Some(creds) = self.creds.read().ok().and_then(|c| c.as_ref().map(Credentials::to_set)) else {
return;
};
if !fal.is_empty() {
if let Some(key) = self.accounts.fal_key().map(|k| FalApiKey::from_str(&k)) {
for job in fal {
self.poll_fal(job, &key, &creds).await;
}
}
}
if !midjourney.is_empty() {
self.poll_midjourney(watch, midjourney, &creds).await;
}
}
async fn poll_fal(&self, job: ProviderJob, key: &FalApiKey, creds: &StorytellerCredentialSet) {
let Some(status_url) = job.maybe_status_url.clone() else {
return self.fail(&job, "No queue status URL to check");
};
let status = match timeout(REQUEST_TIMEOUT, poll_job_status(PollJobStatusArgs { status_url: &status_url, api_key: key })).await {
Ok(Ok(status)) => status,
other => return warn!("Polling FAL job {} failed: {other:?}", job.provider_job_id),
};
if status.status != FalJobStatus::Completed {
return;
}
let Some(response_url) = job.maybe_response_url.clone().or(status.response_url) else {
return self.fail(&job, "Job completed but no response URL available");
};
let response = match timeout(REQUEST_TIMEOUT, poll_job_response(PollJobResponseArgs { response_url: &response_url, api_key: key })).await {
Ok(Ok(response)) => response,
other => return warn!("Fetching FAL results for {} failed (will retry): {other:?}", job.provider_job_id),
};
let urls = image_urls(response.extracted_contents.as_ref());
if urls.is_empty() {
return self.fail(&job, "FAL returned no images");
}
if let Err(err) = self.finish(job, Results::Urls(urls), creds).await {
warn!("Uploading FAL results failed (will retry): {err}");
}
}
async fn poll_midjourney(&self, watch: &mut MidjourneyWatch, jobs: Vec<ProviderJob>, creds: &StorytellerCredentialSet) {
let session = match self.accounts.midjourney.session().await {
Ok(Some(session)) => session,
Ok(None) => return watch.close(),
Err(err) => return warn!("Midjourney login check failed: {err}"),
};
let now = chrono::Utc::now().timestamp();
for job in jobs.iter().filter(|j| now - j.created_at > MIDJOURNEY_GIVE_UP_SECS && !watch.finished.contains(&j.provider_job_id)) {
self.fail(job, "Midjourney didn't finish this job");
}
let pending: HashSet<String> = jobs.iter().filter(|j| now - j.created_at <= MIDJOURNEY_GIVE_UP_SECS || watch.finished.contains(&j.provider_job_id)).map(|j| j.provider_job_id.clone()).collect();
watch.finished.retain(|id| pending.contains(id));
watch.refresh(&session, &pending).await;
let downloader = match ImageDownloaderClient::create(Some(session.browser.clone())) {
Ok(downloader) => downloader,
Err(err) => return warn!("Midjourney downloader unavailable: {err:?}"),
};
let done: Vec<ProviderJob> = jobs.into_iter().filter(|j| watch.finished.contains(&j.provider_job_id)).collect();
for job in done {
let id = job.provider_job_id.clone();
match self.finish(job, Results::Midjourney(&downloader), creds).await {
Ok(()) => {
watch.finished.remove(&id);
},
Err(err) => warn!("Uploading Midjourney results for {id} failed (will retry): {err}"),
}
}
}
/// Uploads the results not uploaded yet, then reports the job done.
async fn finish(&self, mut job: ProviderJob, results: Results<'_>, creds: &StorytellerCredentialSet) -> anyhow::Result<()> {
let provider = match job.provider {
Provider::Midjourney => GenerationProvider::Midjourney,
Provider::Fal => GenerationProvider::Fal,
Provider::Artcraft => GenerationProvider::Artcraft,
};
let prompt = job.maybe_prompt_token.as_deref().map(PromptToken::new_from_str);
let batch = BatchGenerationToken::new_from_str(&job.batch_token);
for index in job.uploaded.len()..results.count() {
let (bytes, ext) = timeout(TRANSFER_TIMEOUT, self.download(&results, &job, index)).await??;
let mut file = tempfile::Builder::new().prefix("artcraft-provider-").suffix(&format!(".{ext}")).tempfile()?;
std::io::Write::write_all(&mut file, &bytes)?;
// The token is saved before the upload, so a retry can't upload the same result twice.
let token = job.upload_token(index);
self.jobs.put(job.clone());
let args = UploadImageFromFileArgs { api_host: &self.host, maybe_creds: Some(creds), path: file.path(), is_intermediate_system_file: false, maybe_prompt_token: prompt.as_ref(), maybe_generation_provider: Some(provider), maybe_batch_token: Some(&batch) };
let uploaded = timeout(TRANSFER_TIMEOUT, upload_image_media_file_from_file_with_idempotency_token(args, &token)).await??;
job.uploaded.push(uploaded.media_file_token.to_string());
self.jobs.put(job.clone());
}
info!("{:?} job {} finished with {} results", job.provider, job.provider_job_id, job.uploaded.len());
self.jobs.remove(&job.id);
self.send(Event::ProviderJobFinished { job_id: job.id, batch_token: job.batch_token });
Ok(())
}
async fn download(&self, results: &Results<'_>, job: &ProviderJob, index: usize) -> anyhow::Result<(Vec<u8>, &'static str)> {
match results {
Results::Urls(urls) => {
let url = &urls[index];
let bytes = self.http.get(url).send().await?.error_for_status()?.bytes().await?;
Ok((bytes.to_vec(), image_extension(url)))
},
Results::Midjourney(downloader) => Ok((downloader.download_image(&job.provider_job_id, index as u8).await?, "png")),
}
}
fn fail(&self, job: &ProviderJob, reason: &str) {
warn!("{:?} job {} failed: {reason}", job.provider, job.provider_job_id);
self.jobs.remove(&job.id);
self.send(Event::ProviderJobFailed { job_id: job.id.clone(), reason: reason.to_owned() });
}
fn send(&self, event: Event) {
let _ = self.tx.send(event);
self.ctx.request_repaint();
}
}
impl MidjourneyWatch {
/// Notes which pending jobs have finished: websocket events as they come, and the feed every
/// so often (it also covers frames the socket missed, and jobs from before a restart).
async fn refresh(&mut self, session: &MidjourneySession, pending: &HashSet<String>) {
if self.socket.as_ref().is_some_and(|s| s.credential_id != session.credential_id || !s.socket.is_connected()) {
self.close();
}
self.drain(pending);
let now = Instant::now();
if self.next_feed.is_none_or(|t| now >= t) {
self.next_feed = Some(now + FEED_INTERVAL);
match timeout(REQUEST_TIMEOUT, imagine(ImagineArgs { request: ImagineRequest { user_id: &session.user_id, page_size: None }, cookie_header: &session.cookie_header, hostname: None, browser: Some(session.browser.clone()) })).await {
Ok(Ok(feed)) => self.finished.extend(feed.items.into_iter().filter_map(|item| item.id).filter(|id| pending.contains(id))),
other => warn!("Reading the Midjourney feed failed: {other:?}"),
}
}
if self.socket.is_none() && self.next_socket_attempt.is_none_or(|t| now >= t) {
self.next_socket_attempt = Some(now + SOCKET_RETRY_INTERVAL);
match timeout(REQUEST_TIMEOUT, open_socket(session)).await {
Ok(Ok(socket)) => self.socket = Some(socket),
_ => log::debug!("Midjourney websocket unavailable; the feed still reports completions"),
}
}
if let Some(socket) = self.socket.as_mut() {
socket.subscribed.retain(|id| pending.contains(id));
for id in pending {
if !socket.subscribed.contains(id) && socket.socket.subscribe_to_job(id).is_ok() {
socket.subscribed.insert(id.clone());
}
}
}
}
fn drain(&mut self, pending: &HashSet<String>) {
let Some(socket) = self.socket.as_mut() else {
return;
};
loop {
match socket.events.try_recv() {
Ok(event) => {
if let MidjourneyWsEvent::Completed { job_id, .. } = event.as_ref() {
if pending.contains(job_id) {
self.finished.insert(job_id.clone());
}
}
},
// Missed frames: read the feed now.
Err(TryRecvError::Lagged(_)) => self.next_feed = None,
Err(TryRecvError::Empty | TryRecvError::Closed) => break,
}
}
}
fn close(&mut self) {
if let Some(socket) = self.socket.take() {
socket.socket.close();
}
}
}
async fn open_socket(session: &MidjourneySession) -> anyhow::Result<Socket> {
let info = get_user_info(GetUserInfoArgs { cookie_header: &session.cookie_header, hostname: None, browser: Some(session.browser.clone()) }).await?;
let token = info.websocket_token.ok_or_else(|| anyhow::anyhow!("No Midjourney websocket token"))?;
let socket = open_midjourney_websocket(OpenMidjourneyWebSocketRequest { websocket_token: &token, user_id: session.user_id.clone(), hostname: None, browser: Some(session.browser.clone()) }).await?;
let events = socket.events();
Ok(Socket { credential_id: session.credential_id.clone(), socket, events, subscribed: HashSet::new() })
}
/// A fal job's image links: the batch, else the single image.
fn image_urls(extracted: Option<&PollResponseExtractedContents>) -> Vec<String> {
let Some(extracted) = extracted else {
return Vec::new();
};
let batch: Vec<String> = extracted.images.iter().flatten().filter_map(|i| i.url.clone()).collect();
if !batch.is_empty() {
return batch;
}
extracted.image.as_ref().and_then(|i| i.url.clone()).into_iter().collect()
}
/// The file extension a result URL ends in (PNG when it doesn't say).
fn image_extension(url: &str) -> &'static str {
let path = url.split(['?', '#']).next().unwrap_or_default().to_ascii_lowercase();
["jpg", "jpeg", "webp", "png"].into_iter().find(|ext| path.ends_with(&format!(".{ext}"))).unwrap_or("png")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn results_keep_their_file_type() {
assert_eq!(image_extension("https://fal.media/files/a/b.jpeg?x=1"), "jpeg");
assert_eq!(image_extension("https://fal.media/files/a/b.webp"), "webp");
assert_eq!(image_extension("https://fal.media/files/a/b"), "png");
}
#[test]
fn fal_results_prefer_the_batch() {
let contents = fal_client::polling::poll_job_response::success_case_extractors::extract_contents_from_response(&serde_json::json!({ "images": [{ "url": "https://fal/1.png" }, { "url": "https://fal/2.png" }] }));
assert_eq!(image_urls(contents.as_ref()), ["https://fal/1.png", "https://fal/2.png"]);
let single = fal_client::polling::poll_job_response::success_case_extractors::extract_contents_from_response(&serde_json::json!({ "image": { "url": "https://fal/one.png" } }));
assert_eq!(image_urls(single.as_ref()), ["https://fal/one.png"]);
assert!(image_urls(None).is_empty());
}
}
+58 -7
View File
@@ -3,8 +3,10 @@
use std::collections::{HashMap, HashSet};
use crate::backend::wire::{Job, JobPhase, MediaFile, unix_secs};
use crate::backend::EnqueueMeta;
use crate::backend::providers::Provider;
use crate::backend::providers::jobs::ProviderJob;
use crate::backend::wire::{Job, JobPhase, MediaFile, unix_secs};
use crate::feed::types::{FailedJob, FeedItem, MediaKind, PendingJob};
use crate::models::{self, Catalog};
@@ -61,7 +63,7 @@ impl FeedStore {
self.meta.insert(token.clone(), meta.clone());
self.seen_running.insert(token.clone());
if !self.pending.iter().any(|p| &p.job_token == token) {
self.pending.insert(0, PendingJob { job_token: token.clone(), kind, prompt: meta.prompt.clone(), model_id: Some(meta.model_id.clone()), created_at: now, server_progress: None, expected_secs: expected, batch_count: meta.batch_count });
self.pending.insert(0, PendingJob { job_token: token.clone(), kind, prompt: meta.prompt.clone(), model_id: Some(meta.model_id.clone()), created_at: now, server_progress: None, expected_secs: expected, batch_count: meta.batch_count, local: meta.provider != Provider::Artcraft });
}
}
}
@@ -82,7 +84,7 @@ impl FeedStore {
JobPhase::Running => {
self.seen_running.insert(job.job_token.clone());
let expected = model_id.as_deref().and_then(|id| catalog.find(id)).map_or_else(|| models::expected_secs(model_id.as_deref().unwrap_or_default(), kind), |m| m.expected_secs);
pending.push(PendingJob { job_token: job.job_token.clone(), kind, prompt, model_id, created_at: unix_secs(&job.created_at), server_progress: Some(job.status.progress_percentage), expected_secs: expected, batch_count: meta.map_or(1, |m| m.batch_count) });
pending.push(PendingJob { job_token: job.job_token.clone(), kind, prompt, model_id, created_at: unix_secs(&job.created_at), server_progress: Some(job.status.progress_percentage), expected_secs: expected, batch_count: meta.map_or(1, |m| m.batch_count), local: false });
},
JobPhase::Failed => {
let updated = unix_secs(&job.updated_at);
@@ -90,7 +92,7 @@ impl FeedStore {
notices.push(FeedNotice::Failed(job.status.maybe_failure_message.clone().unwrap_or_else(|| job.status.failure_reason())));
}
if now - updated <= FAILURE_WINDOW_SECS {
failed.push(FailedJob { job_token: job.job_token.clone(), prompt, model_id, updated_at: updated, reason: job.status.failure_reason(), message: job.status.maybe_failure_message.clone().filter(|_| job.status.maybe_failure_category_updated.as_deref() != Some("unknown")), ref_image: meta.and_then(|m| m.ref_image.clone()) });
failed.push(FailedJob { job_token: job.job_token.clone(), prompt, model_id, updated_at: updated, reason: job.status.failure_reason(), message: job.status.maybe_failure_message.clone().filter(|_| job.status.maybe_failure_category_updated.as_deref() != Some("unknown")), ref_image: meta.and_then(|m| m.ref_image.clone()), local: false });
}
},
JobPhase::Succeeded => {
@@ -113,13 +115,15 @@ impl FeedStore {
},
}
}
// Jobs started a moment ago may not be in the list yet: keep them.
// Jobs started a moment ago may not be in the list yet, and other providers' never are: keep
// them.
for p in self.pending.drain(..) {
let listed = jobs.iter().any(|j| j.job_token == p.job_token);
if !listed && now - p.created_at < 60 && !pending.iter().any(|q: &PendingJob| q.job_token == p.job_token) {
if (p.local || (!listed && now - p.created_at < 60)) && !pending.iter().any(|q: &PendingJob| q.job_token == p.job_token) {
pending.push(p);
}
}
failed.extend(self.failed.drain(..).filter(|f| f.local));
// Everything finished before the first poll comes from the library instead.
self.first_poll_done = true;
pending.sort_by_key(|p| std::cmp::Reverse(p.created_at));
@@ -140,6 +144,35 @@ impl FeedStore {
}
}
/// Pending cards for the Midjourney and fal jobs still running (after a restart).
pub fn restore_local(&mut self, jobs: &[ProviderJob], catalog: &Catalog) {
for job in jobs {
if !self.pending.iter().any(|p| p.job_token == job.id) {
let expected = catalog.find(&job.model_id).map_or_else(|| models::expected_secs(&job.model_id, MediaKind::Image), |m| m.expected_secs);
self.pending.push(PendingJob { job_token: job.id.clone(), kind: MediaKind::Image, prompt: job.prompt.clone(), model_id: Some(job.model_id.clone()), created_at: job.created_at, server_progress: None, expected_secs: expected, batch_count: job.batch_count, local: true });
}
}
self.pending.sort_by_key(|p| std::cmp::Reverse(p.created_at));
}
/// A Midjourney or fal job's results are in: its card goes (the batch brings the images).
/// Returns whether it was this feed's.
pub fn finish_local(&mut self, job_id: &str) -> bool {
let before = self.pending.len();
self.pending.retain(|p| p.job_token != job_id);
self.pending.len() != before
}
/// A Midjourney or fal job failed: its card shows the reason until dismissed.
pub fn fail_local(&mut self, job_id: &str, reason: &str) -> bool {
let Some(i) = self.pending.iter().position(|p| p.job_token == job_id) else {
return false;
};
let p = self.pending.remove(i);
self.failed.insert(0, FailedJob { job_token: p.job_token, prompt: p.prompt, model_id: p.model_id, updated_at: chrono::Utc::now().timestamp(), reason: "Generation failed".to_owned(), message: Some(reason.to_owned()), ref_image: self.meta.get(job_id).and_then(|m| m.ref_image.clone()), local: true });
true
}
pub fn library_started(&self) -> bool {
self.library_started
}
@@ -254,6 +287,7 @@ fn job_kind(job: &Job, catalog: &Catalog) -> Option<MediaKind> {
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::providers::Provider;
use crate::models::ModelInfo;
fn catalog() -> Catalog {
@@ -326,10 +360,27 @@ mod tests {
assert_eq!(store.pending.len(), 1);
}
#[test]
fn provider_jobs_outlive_polls_until_they_finish_or_fail() {
let (c, mut store) = (catalog(), FeedStore::default());
let meta = EnqueueMeta { prompt: "p".into(), model_id: "midjourney_7".into(), batch_count: 4, ref_image: None, provider: Provider::Midjourney };
store.add_enqueued(MediaKind::Image, &["local_a".into(), "local_b".into()], &meta, &c);
store.pending.iter_mut().for_each(|p| p.created_at -= 3600);
store.apply_jobs(MediaKind::Image, &[], &c);
assert_eq!(store.pending.len(), 2, "the server never lists them");
assert!(store.finish_local("local_a"));
assert!(store.fail_local("local_b", "Midjourney didn't finish this job"));
store.apply_jobs(MediaKind::Image, &[], &c);
assert!(store.pending.is_empty());
assert_eq!(store.failed[0].message.as_deref(), Some("Midjourney didn't finish this job"), "the failure stays until dismissed");
store.dismiss("local_b");
assert!(store.failed.is_empty());
}
#[test]
fn just_enqueued_jobs_survive_a_poll_that_predates_them() {
let (c, mut store) = (catalog(), FeedStore::default());
let meta = EnqueueMeta { prompt: "p".into(), model_id: "nano_banana_pro".into(), batch_count: 2, ref_image: None };
let meta = EnqueueMeta { prompt: "p".into(), model_id: "nano_banana_pro".into(), batch_count: 2, ref_image: None, provider: Provider::Artcraft };
store.add_enqueued(MediaKind::Image, &["fresh".into()], &meta, &c);
store.apply_jobs(MediaKind::Image, &[], &c);
assert_eq!(store.pending.len(), 1);
+4
View File
@@ -62,6 +62,8 @@ pub struct PendingJob {
/// How long this model usually takes, for the estimated progress bar.
pub expected_secs: f32,
pub batch_count: u32,
/// Running at Midjourney or fal (tracked here, not in the server's job list).
pub local: bool,
}
/// A generation that failed or was cancelled.
@@ -75,6 +77,8 @@ pub struct FailedJob {
pub message: Option<String>,
/// The first reference image, shown faintly behind the error.
pub ref_image: Option<String>,
/// A Midjourney or fal job (tracked here, not in the server's job list).
pub local: bool,
}
/// The progress and time-left labels for a pending job (`derivePendingStatus`).
+12 -3
View File
@@ -2,6 +2,7 @@
use egui::{Color32, Id, Sense, Stroke, Ui, pos2, vec2};
use crate::overlays::providers::{AccountAction, AccountsView, accounts_section};
use crate::shell::{WEB_APP_URL, group_thousands};
use crate::theme;
use crate::ui::icons::Icon;
@@ -105,10 +106,12 @@ pub enum SettingsAction {
Close,
SignOut,
SignIn,
Account(AccountAction),
}
/// The settings dialog (Settings → Misc and Account in the webapp).
pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, username: Option<&str>, credits: Option<u64>, data_root: &std::path::Path) -> Option<SettingsAction> {
/// The settings dialog (Settings → Misc and Accounts in the webapp). `fal_key` is the key being
/// typed.
pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, username: Option<&str>, credits: Option<u64>, accounts: &AccountsView, fal_key: &mut String, data_root: &std::path::Path) -> Option<SettingsAction> {
let mut action = None;
let close = widgets::modal(ctx, Id::new("settings"), 520.0, |ui| {
ui.horizontal(|ui| {
@@ -160,6 +163,12 @@ pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, userna
},
}
ui.add_space(18.0);
widgets::hud(ui, "Provider accounts", theme::FAINT);
ui.add_space(6.0);
if let Some(account) = accounts_section(ui, accounts, fal_key) {
action = Some(SettingsAction::Account(account));
}
ui.add_space(18.0);
widgets::hud(ui, "About", theme::FAINT);
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("ArtCraft native {}", env!("CARGO_PKG_VERSION"))).size(13.0).color(theme::MUTED));
@@ -176,7 +185,7 @@ pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, userna
}
/// A single-line input on the sunken surface with a hairline (and a blue line while focused).
fn text_field(ui: &mut Ui, value: &mut String, password: bool, hint: &str) -> egui::Response {
pub(crate) fn text_field(ui: &mut Ui, value: &mut String, password: bool, hint: &str) -> egui::Response {
let id = ui.next_auto_id();
let focused = ui.memory(|m| m.has_focus(id));
egui::Frame::new().fill(theme::SUNKEN).stroke(Stroke::new(1.0, if focused { theme::ACCENT } else { theme::LINE })).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| ui.add(egui::TextEdit::singleline(value).id(id).password(password).frame(egui::Frame::NONE).desired_width(f32::INFINITY).hint_text(egui::RichText::new(hint).color(theme::FAINT)))).inner
+1
View File
@@ -2,3 +2,4 @@ pub mod account;
pub mod characters;
pub mod library_picker;
pub mod lightbox;
pub mod providers;
+261
View File
@@ -0,0 +1,261 @@
//! Connecting provider accounts: the "Set up {provider}" dialog (`ProviderSetupModal`), the
//! Midjourney sign-in window (a system webview, used only to capture the login cookies, like
//! the Tauri app's), and the accounts rows in Settings.
use std::path::Path;
use cookie_store_wrapper::cookie_store::CookieStore;
use egui::{Id, Rect, Sense, Ui, vec2};
use log::warn;
use midjourney_client::credentials::cookie_store_has_auth_cookies::cookie_store_has_auth_cookies;
use midjourney_client::utils::midjourney_browser_profile::MIDJOURNEY_USER_AGENT;
use url::Url;
use wry::raw_window_handle::HasWindowHandle;
use wry::{WebContext, WebView, WebViewBuilder};
use crate::backend::providers::Provider;
use crate::overlays::account::text_field;
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
const MIDJOURNEY_URL: &str = "https://www.midjourney.com/";
/// The login cookies live on both hosts.
const MIDJOURNEY_ORIGINS: [&str; 2] = ["https://www.midjourney.com/", "https://midjourney.com/"];
/// How often the sign-in window is checked for the login cookies.
const COOKIE_CHECK_SECS: f64 = 2.0;
const LOGIN_WIDTH: f32 = 560.0;
/// What the setup dialog asks for.
#[derive(Debug, PartialEq)]
pub enum SetupAction {
Close,
ConnectMidjourney,
SaveFalKey,
}
/// "Set up {provider}": Midjourney signs in through its window; fal takes an API key here (the
/// Tauri app has no place for it yet).
pub fn setup_dialog(ctx: &egui::Context, provider: Provider, fal_key: &mut String) -> Option<SetupAction> {
let name = provider.label();
let mut action = None;
let close = widgets::modal(ctx, Id::new("provider-setup"), 460.0, |ui| {
ui.horizontal(|ui| {
widgets::hud(ui, "Connect account", theme::FAINT);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
action = Some(SetupAction::Close);
}
});
});
ui.add_space(6.0);
ui.label(widgets::heading(&format!("Set up {name}"), 26.0));
ui.add_space(4.0);
ui.label(egui::RichText::new(format!("Add your {name} account to ArtCraft!")).size(14.0).family(theme::medium()));
ui.add_space(10.0);
let description = match provider {
Provider::Fal => format!("Paste your {name} API key to generate with your own {name} account. You can add all of your AI accounts to ArtCraft to use them all in one place and build the ultimate AI art tool."),
_ => format!("You can add your {name} account to ArtCraft by simply logging in. Use your credits and account directly within ArtCraft. You can add all of your AI accounts to ArtCraft to use them all in one place and build the ultimate AI art tool."),
};
ui.label(egui::RichText::new(description).size(13.0).color(theme::MUTED));
ui.add_space(16.0);
if provider == Provider::Fal {
text_field(ui, fal_key, true, "FAL API key");
ui.add_space(10.0);
}
let ready = provider != Provider::Fal || !fal_key.trim().is_empty();
if ui.add_enabled_ui(ready, |ui| widgets::button(ui, Some(Icon::ChevronRight), &format!("Set up {name}"), widgets::Kind::Primary, ui.available_width(), 40.0)).inner.clicked() {
action = Some(if provider == Provider::Fal { SetupAction::SaveFalKey } else { SetupAction::ConnectMidjourney });
}
});
if close && action.is_none() {
action = Some(SetupAction::Close);
}
action
}
/// What the accounts rows in Settings show.
pub struct AccountsView {
/// The Midjourney account's email ("" when unknown), when one is connected.
pub midjourney: Option<String>,
/// The saved fal key, masked.
pub fal_key_hint: Option<String>,
}
#[derive(Debug, PartialEq)]
pub enum AccountAction {
ConnectMidjourney,
DisconnectMidjourney,
SaveFalKey,
RemoveFalKey,
}
/// The provider accounts in Settings (`MidjourneyAccountBlock`, plus the fal key).
pub fn accounts_section(ui: &mut Ui, view: &AccountsView, fal_key: &mut String) -> Option<AccountAction> {
let mut action = None;
let detail = match view.midjourney.as_deref() {
Some("") => "Connected",
Some(email) => email,
None => "Not logged in",
};
let (label, kind) = if view.midjourney.is_some() { ("Disconnect", widgets::Kind::Danger) } else { ("Connect", widgets::Kind::Secondary) };
if account_row(ui, "Midjourney account", detail, |ui| widgets::button(ui, None, label, kind, 0.0, theme::CONTROL_H).clicked()) {
action = Some(if view.midjourney.is_some() { AccountAction::DisconnectMidjourney } else { AccountAction::ConnectMidjourney });
}
ui.add_space(10.0);
match &view.fal_key_hint {
Some(hint) => {
if account_row(ui, "FAL API key", hint, |ui| widgets::button(ui, None, "Remove", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked()) {
action = Some(AccountAction::RemoveFalKey);
}
},
None => {
account_row(ui, "FAL API key", "Not set", |_| false);
ui.add_space(6.0);
ui.horizontal(|ui| {
let save_w = 90.0;
ui.allocate_ui(vec2(ui.available_width() - save_w - 8.0, theme::CONTROL_H), |ui| text_field(ui, fal_key, true, "Paste your FAL API key"));
let enabled = !fal_key.trim().is_empty();
if ui.add_enabled_ui(enabled, |ui| widgets::button(ui, None, "Save", widgets::Kind::Secondary, save_w, theme::CONTROL_H)).inner.clicked() {
action = Some(AccountAction::SaveFalKey);
}
});
},
}
action
}
/// A mono label over a detail line, with its button at the right. Returns the button's click.
fn account_row(ui: &mut Ui, label: &str, detail: &str, button: impl FnOnce(&mut Ui) -> bool) -> bool {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(300.0);
widgets::hud(ui, label, theme::MUTED);
ui.label(egui::RichText::new(detail).size(13.0));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), button).inner
})
.inner
}
/// The Midjourney sign-in window: a webview over a modal, checked every two seconds for the
/// login cookies (both auth tokens), which it hands back once present.
pub struct MidjourneyLogin {
context: WebContext,
webview: Option<WebView>,
error: Option<String>,
next_check: f64,
}
/// What came of the sign-in window.
pub enum LoginEvent {
Captured(CookieStore),
Closed,
}
impl MidjourneyLogin {
/// The window keeps its own browser profile under the data folder.
pub fn new(data_root: &Path) -> Self {
Self { context: WebContext::new(Some(data_root.join("webview").join("midjourney"))), webview: None, error: None, next_check: 0.0 }
}
pub fn show(&mut self, ctx: &egui::Context, window: &impl HasWindowHandle) -> Option<LoginEvent> {
let mut closed = false;
let mut slot = Rect::NOTHING;
let height = (ctx.content_rect().height() - 120.0).clamp(420.0, 800.0);
let dismissed = widgets::modal(ctx, Id::new("midjourney-login"), LOGIN_WIDTH, |ui| {
ui.horizontal(|ui| {
ui.label(widgets::heading("Login to Midjourney", 22.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
closed = true;
}
});
});
ui.add_space(10.0);
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
ui.painter().rect_filled(rect, 0.0, theme::SUNKEN);
match &self.error {
Some(err) => {
let mut note = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(24.0)).layout(egui::Layout::top_down(egui::Align::Center)));
note.add_space(rect.height() / 2.0 - 48.0);
note.label(egui::RichText::new(format!("Couldn't open the sign-in window: {err}")).size(13.0).color(theme::BAD));
},
None => icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(28.0, 28.0)), theme::MUTED),
}
slot = rect;
});
if closed || dismissed {
self.close();
return Some(LoginEvent::Closed);
}
if self.error.is_some() {
return None;
}
let bounds = physical_bounds(slot, ctx.pixels_per_point());
match &self.webview {
Some(webview) => {
if let Err(err) = webview.set_bounds(bounds) {
warn!("Couldn't move the sign-in window: {err}");
}
},
None => match self.open(window, bounds) {
Ok(webview) => self.webview = Some(webview),
Err(err) => self.error = Some(err.to_string()),
},
}
let now = ctx.input(|i| i.time);
ctx.request_repaint_after(std::time::Duration::from_secs_f64(COOKIE_CHECK_SECS));
if now < self.next_check {
return None;
}
self.next_check = now + COOKIE_CHECK_SECS;
let cookies = self.webview.as_ref().and_then(login_cookies)?;
self.close();
Some(LoginEvent::Captured(cookies))
}
/// Opens on a blank page, clears what an earlier sign-in left behind, then goes to Midjourney
/// (with the user agent the saved cookies will be replayed with).
fn open(&mut self, window: &impl HasWindowHandle, bounds: wry::Rect) -> wry::Result<WebView> {
let webview = WebViewBuilder::new_with_web_context(&mut self.context).with_user_agent(MIDJOURNEY_USER_AGENT).with_url("about:blank").with_bounds(bounds).build_as_child(window)?;
webview.clear_all_browsing_data()?;
webview.load_url(MIDJOURNEY_URL)?;
webview.focus()?;
Ok(webview)
}
fn close(&mut self) {
if let Some(webview) = self.webview.take() {
let _ = webview.focus_parent();
}
}
}
/// The login cookies, once the page is on Midjourney and both auth tokens are set.
fn login_cookies(webview: &WebView) -> Option<CookieStore> {
let host = webview.url().ok().and_then(|u| Url::parse(&u).ok()).and_then(|u| u.host_str().map(str::to_owned))?;
log::debug!("The Midjourney sign-in window is on {host}");
if host != "www.midjourney.com" && host != "midjourney.com" {
return None;
}
let mut store = CookieStore::empty();
for origin in MIDJOURNEY_ORIGINS {
let Ok(origin) = Url::parse(origin) else {
continue;
};
match webview.cookies_for_url(origin.as_str()) {
// The cookie's own text keeps its path, domain, Secure, HttpOnly, SameSite and expiry.
Ok(cookies) => cookies.iter().for_each(|c| {
store.apply_set_cookie_header(&c.to_string(), &origin);
}),
Err(err) => warn!("Couldn't read the sign-in cookies: {err}"),
}
}
cookie_store_has_auth_cookies(&store).then_some(store)
}
fn physical_bounds(rect: Rect, pixels_per_point: f32) -> wry::Rect {
let r = rect * pixels_per_point;
wry::Rect { position: wry::dpi::PhysicalPosition::new(r.left().round() as i32, r.top().round() as i32).into(), size: wry::dpi::PhysicalSize::new(r.width().round().max(1.0) as u32, r.height().round().max(1.0) as u32).into() }
}
+6 -1
View File
@@ -367,6 +367,11 @@ pub struct CostState {
impl CostState {
/// Requests an estimate for `fields` once they've been stable for a moment.
pub fn update(&mut self, ui: &Ui, backend: &Backend, modality: Modality, fields: &Map<String, Value>) {
self.update_with(ui, fields, |key, fields| backend.estimate_cost(modality, key, fields));
}
/// [`update`](Self::update), asking `request(key, fields)` for the estimate.
pub fn update_with(&mut self, ui: &Ui, fields: &Map<String, Value>, request: impl FnOnce(String, Map<String, Value>)) {
let key = Value::Object(fields.clone()).to_string();
if key == self.key {
return;
@@ -378,7 +383,7 @@ impl CostState {
self.key = key.clone();
self.credits = None;
self.pending_key = None;
backend.estimate_cost(modality, key, fields.clone());
request(key, fields.clone());
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(COST_DEBOUNCE_SECS));
}
+3 -2
View File
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::backend::media_cache::Lookup;
use crate::backend::providers::Provider;
use crate::backend::{EnqueueMeta, Modality};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
@@ -125,7 +126,7 @@ impl CreateAudioPage {
let mut row_actions = Vec::new();
let mut slots = |ui: &mut Ui, slot: Slot| match (slot, model) {
(Slot::ToolbarLeft, _) => {
if let Some(id) = model_selector(ui, Id::new("audio-model"), models, model, false) {
if let Some((id, _)) = model_selector(ui, Id::new("audio-model"), models, model, false, None) {
picked_model = Some(id);
}
if let Some(m) = model {
@@ -318,7 +319,7 @@ impl CreateAudioPage {
return;
}
self.generating = true;
let meta = EnqueueMeta { prompt: self.settings.prompt.trim().to_owned(), model_id: m.id.clone(), batch_count: 1, ref_image: None };
let meta = EnqueueMeta { prompt: self.settings.prompt.trim().to_owned(), model_id: m.id.clone(), batch_count: 1, ref_image: None, provider: Provider::Artcraft };
env.backend.generate(Modality::Audio, self.fields(m, false), meta);
}
+54 -18
View File
@@ -1,10 +1,14 @@
//! Create Image (`TextToImage` + `PromptBoxImage`): prompt, reference images, model, aspect
//! ratio, resolution, quality and count; the cost; and the image feed.
//! Create Image (`TextToImage` + `PromptBoxImage`): prompt, reference images, model (and the
//! provider it runs on), aspect ratio, resolution, quality and count; the cost; and the image
//! feed.
use std::collections::HashMap;
use egui::{Id, Ui};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::backend::providers::Provider;
use crate::backend::wire::Prompt;
use crate::backend::{EnqueueMeta, Modality};
use crate::feed::store::FeedStore;
@@ -29,6 +33,8 @@ pub struct ImageSettings {
pub resolution: Option<String>,
pub quality: Option<String>,
pub count: u16,
/// The provider picked for each model (the rest run on their default).
pub providers: HashMap<String, Provider>,
}
#[derive(Default)]
@@ -51,20 +57,32 @@ impl CreateImagePage {
pub fn ui(&mut self, ui: &mut Ui, env: &mut Env<'_>) {
let models = &env.catalog.image_page;
let model = common::selected_model(models, self.settings.model.as_deref(), DEFAULT_IMAGE_MODEL).cloned();
if let Some(m) = &model {
self.reconcile(m);
let fields = self.fields(m, true);
self.cost.update(ui, env.backend, Modality::Image, &fields);
let base = common::selected_model(models, self.settings.model.as_deref(), DEFAULT_IMAGE_MODEL).cloned();
let provider = base.as_ref().map_or(Provider::Artcraft, |m| self.provider(m));
// What the model can do on its provider (direct Midjourney: four images, no references).
let model = base.as_ref().map(|m| provider.adapt(m));
if let (Some(base), Some(m)) = (&base, &model) {
self.reconcile(base);
let mut fields = self.fields(m, true);
if provider != Provider::Artcraft {
fields.insert("provider".into(), json!(provider));
}
let backend = env.backend;
self.cost.update_with(ui, &fields, |key, fields| backend.estimate_provider_cost(provider, key, fields));
}
let mut feed = std::mem::take(&mut self.feed);
let mut box_height = self.box_height;
common::create_shell(ui, env, MediaKind::Image, "Create Image", "Add a prompt, then generate", &mut feed, &mut box_height, |ui, env| self.prompt_box(ui, env, model.as_ref()));
common::create_shell(ui, env, MediaKind::Image, "Create Image", "Add a prompt, then generate", &mut feed, &mut box_height, |ui, env| self.prompt_box(ui, env, model.as_ref(), provider));
self.feed = feed;
self.box_height = box_height;
}
fn prompt_box(&mut self, ui: &mut Ui, env: &mut Env<'_>, model: Option<&ModelInfo>) {
/// The provider `model` runs on: the user's pick, else its default.
fn provider(&self, model: &ModelInfo) -> Provider {
Provider::resolve(&model.id, self.settings.providers.get(&model.id).copied())
}
fn prompt_box(&mut self, ui: &mut Ui, env: &mut Env<'_>, model: Option<&ModelInfo>, provider: Provider) {
let max_images = model.map_or(0, |m| m.image_refs_max);
let deck = if max_images > 0 { DeckMode::References(DeckLimits { max_images, max_videos: 0, max_video_secs: None, max_audios: 0, max_audio_secs: None, library: env.signed_in }) } else { DeckMode::None };
let accepts: &[RefKind] = if max_images > 0 { &[RefKind::Image] } else { &[] };
@@ -77,8 +95,8 @@ impl CreateImagePage {
let mut picked_count = None;
let mut slots = |ui: &mut Ui, slot: Slot| match (slot, model) {
(Slot::ToolbarLeft, _) => {
if let Some(id) = model_selector(ui, Id::new("image-model"), models, model, false) {
picked_model = Some(id);
if let Some(pick) = model_selector(ui, Id::new("image-model"), models, model, false, Some(&settings.providers)) {
picked_model = Some(pick);
}
let Some(m) = model else {
return;
@@ -104,20 +122,23 @@ impl CreateImagePage {
_ => {},
};
let actions = prompt_box::show(ui, &mut settings.prompt, &self.refs, &mut self.box_state, &props, env.cache, &mut slots);
if let Some(id) = picked_model {
if let Some((id, picked_provider)) = picked_model {
if let Some(p) = picked_provider {
self.settings.providers.insert(id.clone(), p);
}
self.settings.model = Some(id);
}
if let Some(n) = picked_count {
self.settings.count = n;
}
for action in actions {
self.handle(action, env, model, max_images);
self.handle(action, env, model, max_images, provider);
}
}
fn handle(&mut self, action: PromptBoxAction, env: &mut Env<'_>, model: Option<&ModelInfo>, max_images: usize) {
fn handle(&mut self, action: PromptBoxAction, env: &mut Env<'_>, model: Option<&ModelInfo>, max_images: usize, provider: Provider) {
match action {
PromptBoxAction::Generate => self.generate(env, model),
PromptBoxAction::Generate => self.generate(env, model, provider),
PromptBoxAction::ClearAll => {
self.settings.prompt.clear();
self.refs.clear();
@@ -186,7 +207,7 @@ impl CreateImagePage {
}
}
fn generate(&mut self, env: &mut Env<'_>, model: Option<&ModelInfo>) {
fn generate(&mut self, env: &mut Env<'_>, model: Option<&ModelInfo>, provider: Provider) {
if self.generating || self.settings.prompt.trim().is_empty() {
return;
}
@@ -206,9 +227,24 @@ impl CreateImagePage {
env.toasts.error("Please wait for your references to finish uploading");
return;
}
// Other providers take the references as links (when the model takes references there).
let image_urls = if provider == Provider::Artcraft || model.image_refs_max == 0 {
Vec::new()
} else {
match self.refs.images.iter().map(|r| r.full_url.clone()).collect::<Option<Vec<String>>>() {
Some(urls) => urls,
None => {
env.toasts.error("A reference image is missing its link. Please add it again.");
return;
},
}
};
self.generating = true;
let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(self.settings.count.max(1)), ref_image: self.refs.images.first().and_then(|r| r.preview.clone()) };
env.backend.generate(Modality::Image, self.fields(model, false), meta);
let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(model.valid_batch(self.settings.count)), ref_image: self.refs.images.first().and_then(|r| r.preview.clone()), provider };
match provider {
Provider::Artcraft => env.backend.generate(Modality::Image, self.fields(model, false), meta),
_ => env.backend.generate_with_provider(provider, self.fields(model, false), image_urls, meta),
}
}
/// The OmniGen request (`GenerateImageRequest` after `api_fields`). `estimate` swaps
+3 -2
View File
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::backend::wire::Prompt;
use crate::backend::providers::Provider;
use crate::backend::{EnqueueMeta, Modality};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
@@ -126,7 +127,7 @@ impl CreateVideoPage {
let mut open_characters = false;
let mut slots = |ui: &mut Ui, slot: Slot| match (slot, model) {
(Slot::ToolbarLeft, _) => {
if let Some(id) = model_selector(ui, Id::new("video-model"), models, model, true) {
if let Some((id, _)) = model_selector(ui, Id::new("video-model"), models, model, true, None) {
picked_model = Some(id);
}
if let Some(m) = model {
@@ -316,7 +317,7 @@ impl CreateVideoPage {
}
self.generating = true;
let ref_image = self.refs.first_frame.as_ref().or(self.refs.images.first()).and_then(|r| r.preview.clone());
let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(model.valid_batch(self.settings.count)), ref_image };
let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(model.valid_batch(self.settings.count)), ref_image, provider: Provider::Artcraft };
let mut fields = self.fields(model, false);
if supports_characters(model) {
let tokens = mentioned_characters(&self.settings.prompt, env.characters, &self.character_picks);
+188 -38
View File
@@ -1,19 +1,31 @@
//! The embedded model selector (`ClassyModelSelector variant="embedded"`): a pill with the
//! maker's logo and the model's name, opening "Select Model". Long lists group by family, each
//! family opening a flyout of its variants on hover.
//! family opening a flyout of its variants on hover. Models that run on more than one provider
//! show the provider's logo, and hovering them opens "Select Provider".
use egui::{Align2, Id, Order, Popup, Rect, Ui, pos2};
use std::collections::HashMap;
use egui::{Align2, Id, Order, Popup, Rect, Stroke, Ui, pos2, vec2};
use crate::backend::providers::Provider;
use crate::models::{self, ModelInfo};
use crate::prompt_box::pickers;
use crate::theme;
use crate::ui::widgets::{self, Leading, RowLead};
use crate::ui::{creator_icons, icons};
const PANEL_WIDTH: f32 = 280.0;
const FLYOUT_WIDTH: f32 = 300.0;
const PROVIDER_FLYOUT_WIDTH: f32 = 220.0;
/// Hovering a model this long opens its providers (`providerTooltipDelayMs`).
const PROVIDER_DELAY_SECS: f64 = 0.3;
/// Draws the selector; returns the id of a newly picked model.
pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Option<&ModelInfo>, show_badges: bool) -> Option<String> {
/// What was picked: a model, and the provider when one was chosen with it.
pub type Pick = (String, Option<Provider>);
/// Draws the selector; returns a newly picked model (and provider). `providers` is the page's
/// provider choice per model (`None` where providers can't be chosen).
pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Option<&ModelInfo>, show_badges: bool, providers: Option<&HashMap<String, Provider>>) -> Option<Pick> {
let popup_id = ui.make_persistent_id(id).with("popup");
let open = Popup::is_id_open(ui.ctx(), popup_id);
let label = selected.map_or("Select model", |m| m.name.as_str());
@@ -26,6 +38,7 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio
}
});
let resp = resp.inner.on_hover_text("Model");
let rows = Rows { id, selected, show_badges, providers };
let mut picked = None;
let hovered_family_id = id.with("family");
pickers::popover(&resp, popup_id, |ui| {
@@ -42,12 +55,12 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio
let subtitle = if contains_selected { selected.map(|s| s.name.as_str()) } else { None };
let is_open = hovered.as_deref() == Some(family.name.as_str());
if family.models.len() == 1 {
let row = widgets::menu_row(ui, RowLead::Logo(&tex), &first.name, non_empty(&first.description), contains_selected, false, false);
if row.hovered() {
let (row_hovered, pick) = rows.row(ui, first);
if row_hovered {
hovered = None;
}
if row.clicked() {
picked = Some(first.id.clone());
if pick.is_some() {
picked = pick;
}
continue;
}
@@ -56,8 +69,8 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio
hovered = Some(family.name.clone());
}
if hovered.as_deref() == Some(family.name.as_str()) {
if let Some(id) = flyout(ui, id, row.rect, &family.name, &family.models, selected, show_badges) {
picked = Some(id);
if let Some(pick) = rows.family_flyout(ui, row.rect, &family.name, &family.models) {
picked = Some(pick);
}
}
}
@@ -65,8 +78,8 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio
},
None => {
for m in models {
if model_row(ui, m, selected, show_badges) {
picked = Some(m.id.clone());
if let (_, Some(pick)) = rows.row(ui, m) {
picked = Some(pick);
}
}
},
@@ -74,21 +87,109 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio
});
if picked.is_some() {
Popup::close_id(ui.ctx(), popup_id);
ui.data_mut(|d| d.remove::<Option<String>>(hovered_family_id));
ui.data_mut(|d| {
d.remove::<Option<String>>(hovered_family_id);
d.remove::<Option<(String, f64)>>(id.with("provider-hover"));
});
}
picked.filter(|p| selected.is_none_or(|s| &s.id != p))
picked.filter(|(model, provider)| provider.is_some() || selected.is_none_or(|s| &s.id != model))
}
/// A family's variants, to the right of its row.
fn flyout(ui: &Ui, id: Id, row: Rect, family: &str, list: &[&ModelInfo], selected: Option<&ModelInfo>, show_badges: bool) -> Option<String> {
/// What every row needs to know.
struct Rows<'a> {
id: Id,
selected: Option<&'a ModelInfo>,
show_badges: bool,
providers: Option<&'a HashMap<String, Provider>>,
}
impl Rows<'_> {
/// A family's variants, to the right of its row.
fn family_flyout(&self, ui: &Ui, row: Rect, family: &str, list: &[&ModelInfo]) -> Option<Pick> {
let mut picked = None;
egui::Area::new(self.id.with(("flyout", family))).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| {
widgets::popover_frame().show(ui, |ui| {
ui.set_width(FLYOUT_WIDTH);
widgets::menu_header(ui, family);
for m in list {
if let (_, Some(pick)) = self.row(ui, m) {
picked = Some(pick);
}
}
});
});
picked
}
/// One model: logo tile, name, description (and capability badges for video), and the
/// provider chip and flyout for models with a choice. Returns whether the row is hovered, and
/// a pick.
fn row(&self, ui: &mut Ui, m: &ModelInfo) -> (bool, Option<Pick>) {
let tex = creator_icons::texture(ui.ctx(), &m.creator);
let is_selected = self.selected.is_some_and(|s| s.id == m.id);
let badges = if self.show_badges { capability_badges(m) } else { String::new() };
let subtitle = match (m.description.is_empty(), badges.is_empty()) {
(true, true) => None,
(false, true) => Some(m.description.clone()),
(true, false) => Some(badges),
(false, false) => Some(format!("{} \u{b7} {badges}", m.description)),
};
let allowed = Provider::for_model(&m.id);
let Some(picks) = self.providers.filter(|_| allowed.len() > 1) else {
let row = widgets::menu_row(ui, RowLead::Logo(&tex), &m.name, subtitle.as_deref(), is_selected, false, false);
if row.hovered() {
ui.data_mut(|d| d.remove::<Option<(String, f64)>>(self.id.with("provider-hover")));
}
return (row.hovered(), row.clicked().then(|| (m.id.clone(), None)));
};
let current = Provider::resolve(&m.id, picks.get(&m.id).copied());
let hover_id = self.id.with("provider-hover");
let now = ui.input(|i| i.time);
let hovered: Option<(String, f64)> = ui.data(|d| d.get_temp(hover_id)).flatten();
let open = hovered.as_ref().is_some_and(|(id, since)| *id == m.id && now - since >= PROVIDER_DELAY_SECS);
let row = widgets::menu_row(ui, RowLead::Logo(&tex), &m.name, subtitle.as_deref(), false, false, open);
provider_chip(ui, row.rect, current, is_selected);
if row.hovered() && hovered.as_ref().is_none_or(|(id, _)| *id != m.id) {
ui.data_mut(|d| d.insert_temp(hover_id, Some((m.id.clone(), now))));
}
if row.hovered() && !open {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(PROVIDER_DELAY_SECS));
}
let mut picked = row.clicked().then(|| (m.id.clone(), None));
if open {
if let Some(provider) = provider_flyout(ui, self.id.with(("providers", &m.id)), row.rect, allowed, current) {
picked = Some((m.id.clone(), Some(provider)));
}
}
(row.hovered(), picked)
}
}
/// The provider's logo on a chip at the row's right; on the brand blue for the selected model.
fn provider_chip(ui: &Ui, row: Rect, provider: Provider, selected: bool) {
let chip = Rect::from_center_size(pos2(row.right() - 22.0, row.center().y), vec2(26.0, 20.0));
let fill = if selected { theme::fade(theme::ACCENT, 0.6) } else { theme::WASH };
ui.painter().rect_filled(chip, theme::RADIUS, fill);
let tex = creator_icons::texture(ui.ctx(), provider.icon());
egui::Image::new(&tex).tint(theme::INK).paint_at(ui, Rect::from_center_size(chip.center(), vec2(12.0, 12.0)));
}
/// "Select Provider" (`ProviderTooltipContent`), to the right of a model's row.
fn provider_flyout(ui: &Ui, id: Id, row: Rect, allowed: &[Provider], current: Provider) -> Option<Provider> {
let mut picked = None;
egui::Area::new(id.with(("flyout", family))).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| {
egui::Area::new(id).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| {
widgets::popover_frame().show(ui, |ui| {
ui.set_width(FLYOUT_WIDTH);
widgets::menu_header(ui, family);
for m in list {
if model_row(ui, m, selected, show_badges) {
picked = Some(m.id.clone());
ui.set_width(PROVIDER_FLYOUT_WIDTH);
widgets::menu_header(ui, "Select Provider");
for &provider in allowed {
let tex = creator_icons::texture(ui.ctx(), provider.icon());
let selected = provider == current;
let resp = widgets::menu_row(ui, RowLead::SmallLogo(&tex), provider.label(), None, selected, false, false);
if selected {
ui.painter().vline(resp.rect.left() + 1.5, resp.rect.y_range(), Stroke::new(3.0, theme::ACCENT));
}
if resp.clicked() {
picked = Some(provider);
}
}
});
@@ -96,20 +197,6 @@ fn flyout(ui: &Ui, id: Id, row: Rect, family: &str, list: &[&ModelInfo], selecte
picked
}
/// One model: logo tile, name, description (and capability badges for video).
fn model_row(ui: &mut Ui, m: &ModelInfo, selected: Option<&ModelInfo>, show_badges: bool) -> bool {
let tex = creator_icons::texture(ui.ctx(), &m.creator);
let is_selected = selected.is_some_and(|s| s.id == m.id);
let badges = if show_badges { capability_badges(m) } else { String::new() };
let subtitle = match (m.description.is_empty(), badges.is_empty()) {
(true, true) => None,
(false, true) => Some(m.description.clone()),
(true, false) => Some(badges),
(false, false) => Some(format!("{} \u{b7} {badges}", m.description)),
};
widgets::menu_row(ui, RowLead::Logo(&tex), &m.name, subtitle.as_deref(), is_selected, false, false).clicked()
}
/// The video picker's capability badges ("Audio Support", "Start/End", "Reference").
fn capability_badges(m: &ModelInfo) -> String {
let mut badges = Vec::new();
@@ -125,6 +212,69 @@ fn capability_badges(m: &ModelInfo) -> String {
badges.join(" \u{b7} ")
}
fn non_empty(s: &str) -> Option<&str> {
(!s.is_empty()).then_some(s)
/// The provider submenu headlessly (egui_kittest).
#[cfg(test)]
mod tests {
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use super::*;
struct State {
models: Vec<ModelInfo>,
picks: HashMap<String, Provider>,
picked: Vec<Pick>,
fonts_ready: bool,
}
#[test]
fn hovering_a_model_offers_its_providers() {
let mut harness = build();
harness.get_by_label("Seedream 4").click();
harness.run();
let row = harness.get_all_by_label("Nano Banana Pro").last().expect("the model's row");
row.hover();
for _ in 0..4 {
harness.step();
}
harness.run();
assert!(harness.query_by_label("ArtCraft").is_some(), "the submenu lists the providers");
harness.get_by_label("FAL").click();
harness.run();
assert_eq!(harness.state().picked, vec![("nano_banana_pro".to_owned(), Some(Provider::Fal))]);
}
#[test]
fn models_without_a_choice_have_no_submenu() {
let mut harness = build();
harness.get_by_label("Seedream 4").click();
harness.run();
harness.get_all_by_label("Seedream 4").last().expect("row").hover();
for _ in 0..4 {
harness.step();
}
harness.run();
assert!(harness.query_by_label("FAL").is_none(), "no provider list");
}
fn build() -> Harness<'static, State> {
let models = ["seedream_4", "nano_banana_pro"].iter().map(|id| ModelInfo { id: (*id).into(), name: models::display_name(id), creator: models::creator_for(id).into(), ..Default::default() }).collect();
let state = State { models, picks: HashMap::new(), picked: Vec::new(), fonts_ready: false };
let mut harness = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, s: &mut State| {
if !s.fonts_ready {
theme::apply(ui.ctx());
s.fonts_ready = true;
return;
}
ui.add_space(500.0);
if let Some(pick) = model_selector(ui, Id::new("test-model"), &s.models, s.models.first(), false, Some(&s.picks)) {
s.picked.push(pick);
}
},
state,
);
harness.run();
harness
}
}
+6
View File
@@ -252,6 +252,8 @@ pub enum RowLead<'a> {
Aspect(Option<(f32, f32)>),
/// A 36 px tile with a model maker's logo (white texture, tinted).
Logo(&'a egui::TextureHandle),
/// A logo at icon size (the provider list).
SmallLogo(&'a egui::TextureHandle),
}
/// One option in a popover: a lead, a title, an optional muted subtitle, and a check when
@@ -284,6 +286,10 @@ pub fn menu_row(ui: &mut Ui, lead: RowLead<'_>, title: &str, subtitle: Option<&s
egui::Image::new(texture).tint(ink).paint_at(ui, Rect::from_center_size(tile.center(), vec2(18.0, 18.0)));
x += 46.0;
},
RowLead::SmallLogo(texture) => {
egui::Image::new(texture).tint(ink).paint_at(ui, Rect::from_center_size(pos2(x + 8.0, rect.center().y), vec2(15.0, 15.0)));
x += 26.0;
},
}
let title_font = FontId::new(13.5, theme::medium());
match subtitle {
@@ -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]
@@ -6,3 +6,4 @@ pub mod image_generation_draft_context;
pub mod image_generation_draft_or_request;
pub mod image_generation_request;
pub mod providers;
pub mod router_image_request_to_artcraft_prompt;
@@ -1,21 +1,24 @@
//! The ArtCraft prompt record for an image request sent to another provider (Midjourney, fal),
//! created first so the results can be attributed to it.
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
use artcraft_router::api::router_image_model::RouterImageModel;
use artcraft_router::api::router_resolution::RouterResolution;
use artcraft_router::api::router_provider::RouterProvider;
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as EnumsAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_resolution::CommonResolution as EnumsResolution;
use enums::common::generation_provider::GenerationProvider;
use uuid_utils::uuid::generate_random_uuid;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
pub fn router_image_request_to_artcraft_prompt(
request: &GenerateImageRequestBuilder,
) -> CreatePromptRequest {
CreatePromptRequest {
uuid_idempotency_token: generate_random_uuid(),
uuid_idempotency_token: uuid::Uuid::new_v4().to_string(),
positive_prompt: request.prompt.clone(),
negative_prompt: None,
model_type: image_model_to_common_model_type(request.model),
@@ -120,7 +123,7 @@ fn router_resolution_to_enums(res: RouterResolution) -> EnumsResolution {
#[cfg(test)]
mod tests {
use super::*;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
@@ -165,7 +168,7 @@ mod tests {
#[test]
fn edit_mode_when_images_present() {
use artcraft_router::api::image_list_ref::ImageListRef;
use crate::api::image_list_ref::ImageListRef;
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
..base_builder()
@@ -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
@@ -14,14 +14,19 @@ browser_emulation.workspace = true
cloudflare_errors.workspace = true
cookie_store_wrapper = { workspace = true, features = ["wreq"] }
errors.workspace = true
uuid_utils.workspace = true
# External
anyhow.workspace = true
base64.workspace = true
chrono.workspace = true
futures.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
tokio = { version = "1", features = ["full"] }
toml.workspace = true
url.workspace = true
wreq-util.workspace = true
wreq.workspace = true
@@ -0,0 +1,77 @@
//! Reading Midjourney's Firebase auth token (the `__Host-Midjourney.AuthUserTokenV3_i` cookie).
//! Its JWT payload is decoded WITHOUT verifying the signature: it's only used to learn the user
//! id, the email and when the token expires, never to trust anything.
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use serde_json::Value;
use crate::credentials::cookie_names::AUTH_COOKIE_NAME_I;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
/// The claim holding the Midjourney user id (the `singleplayer_{...}` channel id is built from
/// it).
const MIDJOURNEY_ID_CLAIM: &str = "midjourney_id";
/// Refresh this long before the token expires (seconds).
const REFRESH_MARGIN_SECS: i64 = 120;
/// The (unverified) payload of a `header.payload.signature` JWT.
pub fn jwt_claims(token: &str) -> Option<Value> {
let mut parts = token.split('.');
let (_header, payload, _signature) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None;
}
serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(payload).ok()?).ok()
}
/// Whether the token is missing, unreadable, expired or about to expire.
pub fn token_needs_refresh(maybe_token: Option<&str>, now: i64) -> bool {
maybe_token.and_then(jwt_claims).and_then(|claims| claims.get("exp")?.as_i64()).is_none_or(|expires| expires <= now + REFRESH_MARGIN_SECS)
}
/// The Midjourney user id from the auth token's claims.
pub fn user_id_from_token(token: &str) -> Option<MidjourneyUserId> {
let claims = jwt_claims(token)?;
claims.get(MIDJOURNEY_ID_CLAIM).and_then(Value::as_str).filter(|id| !id.is_empty()).map(MidjourneyUserId::from_str)
}
/// The Midjourney user id from a raw `Cookie:` header (`"a=1; b=2"`), without hitting the
/// Cloudflare-gated index page.
pub fn extract_midjourney_user_id_from_cookie_header(cookie_header: &str) -> Option<MidjourneyUserId> {
let token = cookie_header.split(';').filter_map(|pair| pair.trim().split_once('=')).find(|(name, _)| *name == AUTH_COOKIE_NAME_I).map(|(_, value)| value)?;
user_id_from_token(token)
}
#[cfg(test)]
mod tests {
use super::*;
// Payload: {"midjourney_id":"26c9d38d-1f71-49c7-a356-b29dac58b54c","name":"u1"}
const JWT: &str = "eyJhbGciOiJIUzI1NiJ9.eyJtaWRqb3VybmV5X2lkIjoiMjZjOWQzOGQtMWY3MS00OWM3LWEzNTYtYjI5ZGFjNThiNTRjIiwibmFtZSI6InUxIn0.sig";
#[test]
fn extracts_user_id_from_auth_cookie() {
let header = format!("_fbp=abc; {}={}; other=x", AUTH_COOKIE_NAME_I, JWT);
let user_id = extract_midjourney_user_id_from_cookie_header(&header).unwrap();
assert_eq!(user_id.as_str(), "26c9d38d-1f71-49c7-a356-b29dac58b54c");
}
#[test]
fn returns_none_without_auth_cookie() {
assert!(extract_midjourney_user_id_from_cookie_header("_fbp=abc; other=x").is_none());
}
#[test]
fn refreshes_before_expiry_but_does_not_refresh_a_fresh_token() {
assert!(!token_needs_refresh(Some(&token(5000)), 1000));
assert!(token_needs_refresh(Some(&token(1120)), 1000));
assert!(token_needs_refresh(Some(&token(999)), 1000));
assert!(token_needs_refresh(Some("invalid"), 1000));
assert!(token_needs_refresh(None, 1000));
}
fn token(exp: i64) -> String {
format!("header.{}.signature", BASE64_URL_SAFE_NO_PAD.encode(serde_json::json!({ "exp": exp }).to_string()))
}
}
@@ -1,13 +1,17 @@
//! The saved Midjourney login (`midjourney_cookies.toml`): the browser cookies captured at
//! sign-in, the account they belong to, and when they were last refreshed or failed.
use std::io::Write;
use std::path::Path;
use browser_emulation::browser_profile::BrowserProfile;
use chrono::{DateTime, Utc};
use cookie_store_wrapper::cookie_store::CookieStore;
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::Path;
use tempfile::NamedTempFile;
use uuid_utils::uuid::generate_random_uuid;
use crate::services::midjourney::utils::midjourney_browser_profile::MIDJOURNEY_USER_AGENT;
use crate::utils::midjourney_browser_profile::MIDJOURNEY_USER_AGENT;
/// Midjourney's slice of ArtCraftX's credential format. Other accounts retain
/// their existing storage. Websocket tokens are deliberately never persisted.
@@ -0,0 +1,287 @@
//! The Midjourney login shared by the desktop apps: kept in one file, refreshed before it
//! expires, and turned into a [`MidjourneySession`] for each submit or poll.
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use anyhow::{anyhow, bail};
use browser_emulation::browser_profile::BrowserProfile;
use chrono::Utc;
use cookie_store_wrapper::cookie_store::CookieStore;
use serde_json::Value;
use tokio::sync::Mutex;
use url::Url;
use crate::credentials::auth_token::{extract_midjourney_user_id_from_cookie_header, jwt_claims, token_needs_refresh};
use crate::credentials::cookie_names::{AUTH_COOKIE_NAME_I, AUTH_COOKIE_NAME_R};
use crate::credentials::cookie_store_has_auth_cookies::cookie_store_has_auth_cookies;
use crate::credentials::credential_file::{MidjourneyAccountInfo, MidjourneyCredential};
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::endpoints::refresh_token::{DEFAULT_FIREBASE_API_KEY, RefreshTokenArgs, RefreshTokenRequest, refresh_token};
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_error::MidjourneyError;
use crate::recipes::get_user_info::{GetUserInfoArgs, get_user_info};
/// Where the auth cookies are scoped.
pub const MIDJOURNEY_ORIGIN: &str = "https://www.midjourney.com/";
/// Token refresh and account lookups give up after this long.
const NETWORK_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct MidjourneyCredentialManager {
credential: Arc<RwLock<Option<MidjourneyCredential>>>,
// Serialize refresh, login, and logout so an old refresh cannot resurrect a
// signed-out session or overwrite credentials captured by a new login.
mutation: Arc<Mutex<()>>,
credential_path: PathBuf,
/// Older formats, removed on logout so they can't be migrated back.
legacy_paths: Vec<PathBuf>,
}
/// What a submit or poll needs: the cookies to send, whose account it is, and the browser to
/// present (the one the cookies were captured with).
pub struct MidjourneySession {
pub credential_id: String,
pub cookie_header: String,
pub user_id: MidjourneyUserId,
pub browser: BrowserProfile,
}
impl MidjourneyCredentialManager {
/// A manager that saves to `credential_path`, starting from `credential`.
pub fn new(credential_path: PathBuf, legacy_paths: Vec<PathBuf>, credential: Option<MidjourneyCredential>) -> Self {
Self { credential: Arc::new(RwLock::new(credential)), mutation: Arc::new(Mutex::new(())), credential_path, legacy_paths }
}
/// A manager for the login saved at `credential_path`, if there is one.
pub fn load(credential_path: PathBuf, legacy_paths: Vec<PathBuf>) -> anyhow::Result<Self> {
let credential = if credential_path.exists() {
let credential = MidjourneyCredential::load(&credential_path)?;
credential.save(&credential_path)?; // Persist a stable ID for hand-written files.
Some(credential)
} else {
None
};
Ok(Self::new(credential_path, legacy_paths, credential))
}
pub fn credential_path(&self) -> &PathBuf {
&self.credential_path
}
pub fn maybe_copy_cookie_store(&self) -> anyhow::Result<Option<CookieStore>> {
Ok(self.snapshot()?.map(|credential| credential.cookie.cookies))
}
pub fn maybe_account_info(&self) -> anyhow::Result<Option<MidjourneyAccountInfo>> {
Ok(self.snapshot()?.and_then(|credential| credential.maybe_user_info))
}
pub fn session_appears_active(&self) -> anyhow::Result<bool> {
Ok(self.snapshot()?.is_some_and(|credential| credential.cookie.maybe_relogin_required_since.is_none() && (cookie_store_has_auth_cookies(&credential.cookie.cookies) || !token_needs_refresh(credential.cookie.cookies.get_cookie_value(AUTH_COOKIE_NAME_I), Utc::now().timestamp()))))
}
pub async fn save_login(&self, cookies: CookieStore) -> anyhow::Result<()> {
let _guard = self.mutation.lock().await;
let mut credential = MidjourneyCredential::new(cookies);
populate_account_info(&mut credential);
self.store(credential)
}
pub async fn clear_credentials(&self) -> anyhow::Result<()> {
let _guard = self.mutation.lock().await;
// Remove every format, so a later startup cannot migrate a logged-out account.
for path in self.legacy_paths.iter().chain([&self.credential_path]) {
match tokio::fs::remove_file(path).await {
Ok(()) => {},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {},
Err(err) => return Err(err.into()),
}
}
*self.credential.write().map_err(|_| anyhow!("Midjourney credential lock poisoned"))? = None;
Ok(())
}
/// Resolve identity and renew expiring Firebase credentials before sending
/// generation or polling requests. The refresh token can outlive the ID JWT.
pub async fn session(&self) -> anyhow::Result<Option<MidjourneySession>> {
let _guard = self.mutation.lock().await;
let Some(mut credential) = self.snapshot()? else {
return Ok(None);
};
if credential.cookie.maybe_relogin_required_since.is_some() {
return Ok(None);
}
if token_needs_refresh(credential.cookie.cookies.get_cookie_value(AUTH_COOKIE_NAME_I), Utc::now().timestamp()) {
let Some(refresh) = credential.cookie.cookies.get_cookie_value(AUTH_COOKIE_NAME_R).map(str::to_owned) else {
self.mark_relogin(&mut credential)?;
return Ok(None);
};
let refreshed = tokio::time::timeout(NETWORK_TIMEOUT, refresh_token(RefreshTokenArgs { request: RefreshTokenRequest { refresh_token: &refresh, api_key: DEFAULT_FIREBASE_API_KEY }, browser: Some(credential.browser()) })).await?;
match refreshed {
Ok(response) => {
let origin = Url::parse(MIDJOURNEY_ORIGIN)?;
// Preserve the host-only Secure scope required by __Host- cookies.
for (name, value) in [(AUTH_COOKIE_NAME_I, response.id_token), (AUTH_COOKIE_NAME_R, response.refresh_token)] {
credential.cookie.cookies.apply_set_cookie_header(&format!("{name}={value}; Path=/; Secure; HttpOnly"), &origin);
}
credential.cookie.maybe_updated_at = Some(Utc::now());
credential.cookie.maybe_succeeded_at = Some(Utc::now());
populate_account_info(&mut credential);
self.store(credential.clone())?;
},
Err(err) if is_revoked_refresh(&err) => {
self.mark_relogin(&mut credential)?;
return Ok(None);
},
Err(err) => {
credential.cookie.maybe_failed_at = Some(Utc::now());
self.store(credential)?;
return Err(err.into());
},
}
}
populate_account_info(&mut credential);
let cookie_header = credential.cookie.cookies.cookie_header_for_url(&Url::parse(MIDJOURNEY_ORIGIN)?).unwrap_or_default();
let browser = credential.browser();
let mut maybe_user_id = extract_midjourney_user_id_from_cookie_header(&cookie_header).or_else(|| credential.maybe_user_info.as_ref()?.maybe_user_id.as_deref().map(MidjourneyUserId::from_str));
if maybe_user_id.is_none() {
let info = tokio::time::timeout(NETWORK_TIMEOUT, get_user_info(GetUserInfoArgs { cookie_header: &cookie_header, hostname: None, browser: Some(browser.clone()) })).await??;
maybe_user_id = info.user_id;
credential.maybe_user_info = Some(MidjourneyAccountInfo { maybe_user_id: maybe_user_id.as_ref().map(|id| id.as_str().to_string()), maybe_email: info.email, maybe_username: None });
self.store(credential.clone())?;
}
let Some(user_id) = maybe_user_id else {
bail!("Could not resolve the Midjourney account; please log in again")
};
Ok(Some(MidjourneySession { credential_id: credential.id, cookie_header, user_id, browser }))
}
fn snapshot(&self) -> anyhow::Result<Option<MidjourneyCredential>> {
Ok(self.credential.read().map_err(|_| anyhow!("Midjourney credential lock poisoned"))?.clone())
}
fn store(&self, credential: MidjourneyCredential) -> anyhow::Result<()> {
credential.save(&self.credential_path)?;
*self.credential.write().map_err(|_| anyhow!("Midjourney credential lock poisoned"))? = Some(credential);
Ok(())
}
fn mark_relogin(&self, credential: &mut MidjourneyCredential) -> anyhow::Result<()> {
let now = Utc::now();
credential.cookie.maybe_failed_at = Some(now);
credential.cookie.maybe_relogin_required_since.get_or_insert(now);
self.store(credential.clone())
}
}
/// Fills the account's id and email from the auth token's claims.
pub fn populate_account_info(credential: &mut MidjourneyCredential) {
let Some(claims) = credential.cookie.cookies.get_cookie_value(AUTH_COOKIE_NAME_I).and_then(jwt_claims) else {
return;
};
let info = credential.maybe_user_info.get_or_insert_with(MidjourneyAccountInfo::default);
if let Some(id) = claims.get("midjourney_id").and_then(Value::as_str).filter(|id| !id.is_empty()) {
info.maybe_user_id = Some(id.to_string());
}
if let Some(email) = claims.get("email").and_then(Value::as_str) {
info.maybe_email = Some(email.to_string());
}
}
fn is_revoked_refresh(error: &MidjourneyError) -> bool {
let MidjourneyError::Api(MidjourneyApiError::InvalidRequest(body) | MidjourneyApiError::Unauthorized(body)) = error else {
return false;
};
["TOKEN_EXPIRED", "INVALID_REFRESH_TOKEN", "USER_DISABLED", "USER_NOT_FOUND"].iter().any(|code| body.contains(code))
}
#[cfg(test)]
mod tests {
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use tempfile::TempDir;
use super::*;
const TEST_USER_ID: &str = "00000000-0000-4000-8000-000000000001";
mod persistence {
use super::*;
#[tokio::test]
async fn expired_session_without_refresh_requires_login_and_new_login_clears_it() {
let (_dir, manager) = manager();
manager.save_login(cookies(10)).await.unwrap();
assert!(manager.session().await.unwrap().is_none());
assert!(!manager.session_appears_active().unwrap());
let dead = MidjourneyCredential::load(manager.credential_path()).unwrap();
assert!(dead.cookie.maybe_relogin_required_since.is_some());
manager.save_login(cookies(Utc::now().timestamp() + 3600)).await.unwrap();
assert!(manager.session().await.unwrap().is_some());
assert!(manager.session_appears_active().unwrap());
assert!(manager.snapshot().unwrap().unwrap().cookie.maybe_relogin_required_since.is_none());
}
#[tokio::test]
async fn loads_a_saved_login_and_logout_removes_every_format() {
let (dir, manager) = manager();
manager.save_login(cookies(Utc::now().timestamp() + 3600)).await.unwrap();
let legacy = dir.path().join("midjourney_state.json");
std::fs::write(&legacy, "{}").unwrap();
let reloaded = MidjourneyCredentialManager::load(manager.credential_path().clone(), vec![legacy.clone()]).unwrap();
let session = reloaded.session().await.unwrap().unwrap();
assert_eq!(session.user_id.as_str(), TEST_USER_ID);
reloaded.clear_credentials().await.unwrap();
assert!(!legacy.exists());
assert!(!manager.credential_path().exists());
assert!(reloaded.session().await.unwrap().is_none());
}
#[test]
fn cookie_attributes_and_user_agent_survive_toml_round_trip() {
let (_dir, manager) = manager();
let origin = Url::parse(MIDJOURNEY_ORIGIN).unwrap();
let mut cookies = CookieStore::empty();
cookies.apply_set_cookie_header("session=host; Path=/; Secure; HttpOnly; SameSite=Lax", &origin);
cookies.apply_set_cookie_header("session=domain; Domain=midjourney.com; Path=/api; Secure", &origin);
let credential = MidjourneyCredential::new(cookies);
let path = manager.credential_path();
credential.save(path).unwrap();
let loaded = MidjourneyCredential::load(path).unwrap();
assert_eq!(loaded.cookie.cookies, credential.cookie.cookies);
assert_eq!(loaded.browser(), credential.browser());
assert_eq!(loaded.cookie.cookies.cookie_header_for_url(&origin).as_deref(), Some("session=host"));
assert!(loaded.cookie.cookies.cookie_header_for_url(&Url::parse("http://www.midjourney.com/").unwrap()).is_none());
assert_eq!(loaded.cookie.cookies.cookie_header_for_url(&Url::parse("https://midjourney.com/api/test").unwrap()).as_deref(), Some("session=domain"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600);
}
}
}
#[test]
fn temporary_refresh_failures_do_not_force_logout() {
assert!(!is_revoked_refresh(&MidjourneyApiError::NetworkError("offline".into()).into()));
assert!(!is_revoked_refresh(&MidjourneyApiError::InvalidRequest("API_KEY_INVALID".into()).into()));
assert!(is_revoked_refresh(&MidjourneyApiError::InvalidRequest("INVALID_REFRESH_TOKEN".into()).into()));
}
fn manager() -> (TempDir, MidjourneyCredentialManager) {
let dir = tempfile::tempdir().unwrap();
let manager = MidjourneyCredentialManager::new(dir.path().join("midjourney_cookies.toml"), Vec::new(), None);
(dir, manager)
}
fn cookies(exp: i64) -> CookieStore {
CookieStore::from_cookie_header(&format!("{AUTH_COOKIE_NAME_I}={}", token(exp)), &Url::parse(MIDJOURNEY_ORIGIN).unwrap())
}
fn token(exp: i64) -> String {
let claims = serde_json::json!({"exp": exp, "midjourney_id": TEST_USER_ID});
format!("header.{}.signature", BASE64_URL_SAFE_NO_PAD.encode(claims.to_string()))
}
}
@@ -1,4 +1,7 @@
pub mod auth_token;
pub mod cookie_names;
pub mod cookie_store_has_auth_cookies;
pub mod credential_file;
pub mod credential_manager;
pub mod jwt_decode;
pub mod midjourney_user_id;
@@ -15,7 +15,7 @@ pub const MIDJOURNEY_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X
/// traffic (submit, imagine, index page, image downloads, websocket upgrade).
///
/// Safari fingerprint + the exact [`MIDJOURNEY_USER_AGENT`], to stay consistent
/// with the macOS WKWebView login window that captured the cookies.
/// with the login webview that captured the cookies.
pub fn midjourney_browser_profile() -> BrowserProfile {
BrowserProfile::safari_macos_with_user_agent(MIDJOURNEY_USER_AGENT)
}
@@ -1,2 +1,3 @@
pub mod get_image_url;
pub mod image_downloader_client;
pub mod image_downloader_client;
pub mod midjourney_browser_profile;
@@ -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"] }
@@ -1,2 +1 @@
pub mod router_image_request_to_artcraft_prompt;
pub mod router_video_request_to_artcraft_prompt;
@@ -19,7 +19,7 @@ use crate::core::api_adapters::models::image::tauri_image_model_to_generation_mo
use crate::core::api_adapters::models::image::tauri_image_model_to_router_model::tauri_image_model_to_router_model;
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::generate::common::router_image_request_to_artcraft_prompt::router_image_request_to_artcraft_prompt;
use artcraft_router::generate::generate_image::router_image_request_to_artcraft_prompt::router_image_request_to_artcraft_prompt;
use crate::core::commands::generate::generate_image::providers::artcraft_router::utils::convert_enums_to_router::{convert_aspect_ratio, convert_quality, convert_resolution};
use crate::core::commands::generate::generate_image::providers::artcraft_router::utils::map_media_files_to_urls::map_media_file_tokens_to_cdn_urls;
use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
@@ -2,7 +2,7 @@ use crate::core::api_adapters::models::image::tauri_image_model_to_generation_mo
use crate::core::api_adapters::models::image::tauri_image_model_to_router_model::tauri_image_model_to_router_model;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::generate::common::router_image_request_to_artcraft_prompt::router_image_request_to_artcraft_prompt;
use artcraft_router::generate::generate_image::router_image_request_to_artcraft_prompt::router_image_request_to_artcraft_prompt;
use crate::core::commands::generate::generate_image::providers::artcraft_router::utils::convert_enums_to_router::convert_aspect_ratio;
use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel;
@@ -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();
}
@@ -1,337 +1,126 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::midjourney::state::midjourney_credential::{MidjourneyAccountInfo, MidjourneyCredential};
use crate::services::midjourney::state::midjourney_user_info::MidjourneyUserInfo;
use crate::services::midjourney::state::serializable_midjourney_state::SerializableMidjourneyState;
use crate::services::midjourney::utils::extract_midjourney_user_id_from_cookies::extract_midjourney_user_id_from_cookie_header;
use anyhow::{anyhow, bail};
use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
use browser_emulation::browser_profile::BrowserProfile;
use chrono::Utc;
//! The app's Midjourney login: the shared manager (`midjourney_client`, also used by the native
//! app) kept in the app's credentials directory, plus the one-time migration from the legacy
//! `midjourney_state.json`.
use std::ops::Deref;
use cookie_store_wrapper::cookie_store::CookieStore;
use midjourney_client::credentials::cookie_store_has_auth_cookies::cookie_store_has_auth_cookies;
use midjourney_client::credentials::credential_file::{MidjourneyAccountInfo, MidjourneyCredential};
use midjourney_client::credentials::credential_manager::{MIDJOURNEY_ORIGIN, MidjourneyCredentialManager as SharedCredentialManager, populate_account_info};
use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId;
use midjourney_client::endpoints::refresh_token::{refresh_token, RefreshTokenArgs, RefreshTokenRequest, DEFAULT_FIREBASE_API_KEY};
use midjourney_client::error::midjourney_api_error::MidjourneyApiError;
use midjourney_client::error::midjourney_error::MidjourneyError;
use midjourney_client::recipes::get_user_info::{get_user_info, GetUserInfoArgs};
use serde_json::Value;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::sync::Mutex;
use url::Url;
const AUTH_ID: &str = "__Host-Midjourney.AuthUserTokenV3_i";
const AUTH_REFRESH: &str = "__Host-Midjourney.AuthUserTokenV3_r";
const MIDJOURNEY_ORIGIN: &str = "https://www.midjourney.com/";
pub use midjourney_client::credentials::credential_manager::MidjourneySession;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::midjourney::state::midjourney_user_info::MidjourneyUserInfo;
use crate::services::midjourney::state::serializable_midjourney_state::SerializableMidjourneyState;
#[derive(Clone)]
pub struct MidjourneyCredentialManager {
credential: Arc<RwLock<Option<MidjourneyCredential>>>,
// Serialize refresh, login, and logout so an old refresh cannot resurrect a
// signed-out session or overwrite credentials captured by a new login.
mutation: Arc<Mutex<()>>,
app_data_root: AppDataRoot,
}
pub struct MidjourneyCredentialManager(SharedCredentialManager);
pub struct MidjourneySession {
pub credential_id: String,
pub cookie_header: String,
pub user_id: MidjourneyUserId,
pub browser: BrowserProfile,
impl Deref for MidjourneyCredentialManager {
type Target = SharedCredentialManager;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl MidjourneyCredentialManager {
pub fn initialize_empty(app_data_root: &AppDataRoot) -> Self {
Self { credential: Arc::new(RwLock::new(None)), mutation: Arc::new(Mutex::new(())), app_data_root: app_data_root.clone() }
let dir = app_data_root.credentials_dir();
Self(SharedCredentialManager::new(dir.get_midjourney_credential_path(), vec![dir.get_midjourney_state_path()], None))
}
pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self {
let manager = Self::initialize_empty(app_data_root);
match load_or_migrate(app_data_root) {
Ok(credential) => {
if let Ok(mut current) = manager.credential.write() {
*current = credential;
}
Ok(manager) => manager,
Err(err) => {
log::warn!("Could not load Midjourney credentials: {}", err);
Self::initialize_empty(app_data_root)
},
Err(err) => log::warn!("Could not load Midjourney credentials: {}", err),
}
manager
}
pub fn maybe_copy_cookie_store(&self) -> anyhow::Result<Option<CookieStore>> {
Ok(self.snapshot()?.map(|credential| credential.cookie.cookies))
}
pub fn maybe_copy_user_info(&self) -> anyhow::Result<Option<MidjourneyUserInfo>> {
Ok(self.snapshot()?.and_then(|credential| credential.maybe_user_info).map(|info| MidjourneyUserInfo { user_id: info.maybe_user_id.map(MidjourneyUserId::from_string), email: info.maybe_email, websocket_token: None }))
}
pub fn session_appears_active(&self) -> anyhow::Result<bool> {
Ok(self.snapshot()?.is_some_and(|credential| credential.cookie.maybe_relogin_required_since.is_none() && (cookie_store_has_auth_cookies(&credential.cookie.cookies) || !token_needs_refresh(credential.cookie.cookies.get_cookie_value(AUTH_ID), Utc::now().timestamp()))))
}
pub async fn save_login(&self, cookies: CookieStore) -> anyhow::Result<()> {
let _guard = self.mutation.lock().await;
let mut credential = MidjourneyCredential::new(cookies);
populate_account_info(&mut credential);
self.store(credential)
}
pub async fn clear_credentials(&self) -> anyhow::Result<()> {
let _guard = self.mutation.lock().await;
// Remove both formats, so a later startup cannot migrate a logged-out account.
for path in [self.app_data_root.credentials_dir().get_midjourney_state_path(), self.credential_path()] {
match std::fs::remove_file(path) {
Ok(()) => {},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {},
Err(err) => return Err(err.into()),
}
}
*self.credential.write().map_err(|_| anyhow!("Midjourney credential lock poisoned"))? = None;
Ok(())
}
/// Resolve identity and renew expiring Firebase credentials before sending
/// generation or polling requests. The refresh token can outlive the ID JWT.
pub async fn session(&self) -> anyhow::Result<Option<MidjourneySession>> {
let _guard = self.mutation.lock().await;
let Some(mut credential) = self.snapshot()? else {
return Ok(None);
};
if credential.cookie.maybe_relogin_required_since.is_some() {
return Ok(None);
}
if token_needs_refresh(credential.cookie.cookies.get_cookie_value(AUTH_ID), Utc::now().timestamp()) {
let Some(refresh) = credential.cookie.cookies.get_cookie_value(AUTH_REFRESH).map(str::to_owned) else {
self.mark_relogin(&mut credential)?;
return Ok(None);
};
let refreshed = tokio::time::timeout(Duration::from_secs(30), refresh_token(RefreshTokenArgs { request: RefreshTokenRequest { refresh_token: &refresh, api_key: DEFAULT_FIREBASE_API_KEY }, browser: Some(credential.browser()) })).await?;
match refreshed {
Ok(response) => {
let origin = Url::parse(MIDJOURNEY_ORIGIN)?;
// Preserve the host-only Secure scope required by __Host- cookies.
for (name, value) in [(AUTH_ID, response.id_token), (AUTH_REFRESH, response.refresh_token)] {
credential.cookie.cookies.apply_set_cookie_header(&format!("{name}={value}; Path=/; Secure; HttpOnly"), &origin);
}
credential.cookie.maybe_updated_at = Some(Utc::now());
credential.cookie.maybe_succeeded_at = Some(Utc::now());
populate_account_info(&mut credential);
self.store(credential.clone())?;
},
Err(err) if is_revoked_refresh(&err) => {
self.mark_relogin(&mut credential)?;
return Ok(None);
},
Err(err) => {
credential.cookie.maybe_failed_at = Some(Utc::now());
self.store(credential)?;
return Err(err.into());
},
}
}
populate_account_info(&mut credential);
let cookie_header = credential.cookie.cookies.cookie_header_for_url(&Url::parse(MIDJOURNEY_ORIGIN)?).unwrap_or_default();
let browser = credential.browser();
let mut maybe_user_id = extract_midjourney_user_id_from_cookie_header(&cookie_header).or_else(|| credential.maybe_user_info.as_ref()?.maybe_user_id.as_deref().map(MidjourneyUserId::from_str));
if maybe_user_id.is_none() {
let info = tokio::time::timeout(Duration::from_secs(30), get_user_info(GetUserInfoArgs { cookie_header: &cookie_header, hostname: None, browser: Some(browser.clone()) })).await??;
maybe_user_id = info.user_id;
credential.maybe_user_info = Some(MidjourneyAccountInfo { maybe_user_id: maybe_user_id.as_ref().map(|id| id.as_str().to_string()), maybe_email: info.email, maybe_username: None });
self.store(credential.clone())?;
}
let Some(user_id) = maybe_user_id else {
bail!("Could not resolve the Midjourney account; please log in again")
};
Ok(Some(MidjourneySession { credential_id: credential.id, cookie_header, user_id, browser }))
}
fn snapshot(&self) -> anyhow::Result<Option<MidjourneyCredential>> {
Ok(self.credential.read().map_err(|_| anyhow!("Midjourney credential lock poisoned"))?.clone())
}
fn store(&self, credential: MidjourneyCredential) -> anyhow::Result<()> {
credential.save(&self.credential_path())?;
*self.credential.write().map_err(|_| anyhow!("Midjourney credential lock poisoned"))? = Some(credential);
Ok(())
}
fn mark_relogin(&self, credential: &mut MidjourneyCredential) -> anyhow::Result<()> {
let now = Utc::now();
credential.cookie.maybe_failed_at = Some(now);
credential.cookie.maybe_relogin_required_since.get_or_insert(now);
self.store(credential.clone())
}
fn credential_path(&self) -> std::path::PathBuf {
self.app_data_root.credentials_dir().get_midjourney_credential_path()
Ok(self.maybe_account_info()?.map(|info| MidjourneyUserInfo { user_id: info.maybe_user_id.map(MidjourneyUserId::from_string), email: info.maybe_email, websocket_token: None }))
}
}
fn load_or_migrate(root: &AppDataRoot) -> anyhow::Result<Option<MidjourneyCredential>> {
let path = root.credentials_dir().get_midjourney_credential_path();
if path.exists() {
let credential = MidjourneyCredential::load(&path)?;
credential.save(&path)?; // Persist a stable ID for hand-written files.
return Ok(Some(credential));
fn load_or_migrate(root: &AppDataRoot) -> anyhow::Result<MidjourneyCredentialManager> {
let dir = root.credentials_dir();
let (path, legacy_path) = (dir.get_midjourney_credential_path(), dir.get_midjourney_state_path());
if path.exists() || !legacy_path.exists() {
return Ok(MidjourneyCredentialManager(SharedCredentialManager::load(path, vec![legacy_path])?));
}
let legacy_path = root.credentials_dir().get_midjourney_state_path();
if !legacy_path.exists() {
return Ok(None);
}
let legacy: SerializableMidjourneyState = serde_json::from_str(&std::fs::read_to_string(legacy_path)?)?;
let legacy: SerializableMidjourneyState = serde_json::from_str(&std::fs::read_to_string(&legacy_path)?)?;
let Some(cookies) = legacy.user_cookies else {
return Ok(None);
return Ok(MidjourneyCredentialManager::initialize_empty(root));
};
let cookies = CookieStore::from_cookie_header(&cookies.to_cookie_store().to_cookie_string(), &Url::parse(MIDJOURNEY_ORIGIN)?);
let mut credential = MidjourneyCredential::new(cookies);
credential.maybe_user_info = legacy.user_info.map(|info| MidjourneyAccountInfo { maybe_user_id: info.user_id, maybe_email: info.email, maybe_username: None });
populate_account_info(&mut credential);
credential.save(&path)?;
Ok(Some(credential))
}
fn populate_account_info(credential: &mut MidjourneyCredential) {
let Some(claims) = credential.cookie.cookies.get_cookie_value(AUTH_ID).and_then(jwt_claims) else {
return;
};
let info = credential.maybe_user_info.get_or_insert_with(MidjourneyAccountInfo::default);
if let Some(id) = claims.get("midjourney_id").and_then(Value::as_str).filter(|id| !id.is_empty()) {
info.maybe_user_id = Some(id.to_string());
}
if let Some(email) = claims.get("email").and_then(Value::as_str) {
info.maybe_email = Some(email.to_string());
}
}
fn token_needs_refresh(maybe_token: Option<&str>, now: i64) -> bool {
maybe_token.and_then(jwt_claims).and_then(|claims| claims.get("exp")?.as_i64()).is_none_or(|expires| expires <= now + 120)
}
fn jwt_claims(token: &str) -> Option<Value> {
let payload = token.split('.').nth(1)?;
serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(payload).ok()?).ok()
}
fn is_revoked_refresh(error: &MidjourneyError) -> bool {
let MidjourneyError::Api(MidjourneyApiError::InvalidRequest(body) | MidjourneyApiError::Unauthorized(body)) = error else {
return false;
};
["TOKEN_EXPIRED", "INVALID_REFRESH_TOKEN", "USER_DISABLED", "USER_NOT_FOUND"].iter().any(|code| body.contains(code))
Ok(MidjourneyCredentialManager(SharedCredentialManager::new(path, vec![legacy_path], Some(credential))))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine};
use chrono::Utc;
use tempfile::TempDir;
use super::*;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
const TEST_USER_ID: &str = "00000000-0000-4000-8000-000000000001";
const AUTH_ID: &str = "__Host-Midjourney.AuthUserTokenV3_i";
const AUTH_REFRESH: &str = "__Host-Midjourney.AuthUserTokenV3_r";
mod persistence {
use super::*;
#[tokio::test]
async fn migrates_legacy_cookies_and_logout_does_not_touch_artcraft() {
let (_dir, root) = root();
let legacy = root.credentials_dir().get_midjourney_state_path();
let artcraft = root.credentials_dir().get_storyteller_session_cookie_file_path();
std::fs::write(&artcraft, "existing-artcraft-session").unwrap();
std::fs::write(
&legacy,
serde_json::json!({
"version": 1,
"user_cookies": {"cookies": [
{"name": AUTH_ID, "value": token(Utc::now().timestamp() + 3600)},
{"name": AUTH_REFRESH, "value": "test-refresh"}
]},
"user_info": {"user_id": TEST_USER_ID, "email": "test@example.invalid", "websocket_token": "old-socket-token"}
})
.to_string(),
)
.unwrap();
let manager = MidjourneyCredentialManager::initialize_from_disk_infallible(&root);
let session = manager.session().await.unwrap().unwrap();
assert_eq!(session.user_id.as_str(), TEST_USER_ID);
let contents = std::fs::read_to_string(root.credentials_dir().get_midjourney_credential_path()).unwrap();
assert!(contents.contains("midjourney_cookies"));
assert!(!contents.contains("old-socket-token"));
assert!(legacy.exists(), "migration preserves the old file until logout");
let reloaded = MidjourneyCredentialManager::initialize_from_disk_infallible(&root);
assert_eq!(reloaded.session().await.unwrap().unwrap().credential_id, session.credential_id);
manager.clear_credentials().await.unwrap();
assert!(!legacy.exists());
assert!(!root.credentials_dir().get_midjourney_credential_path().exists());
assert_eq!(std::fs::read_to_string(artcraft).unwrap(), "existing-artcraft-session");
let reloaded = MidjourneyCredentialManager::initialize_from_disk_infallible(&root);
assert!(reloaded.session().await.unwrap().is_none());
}
#[tokio::test]
async fn expired_session_without_refresh_requires_login_and_new_login_clears_it() {
let (_dir, root) = root();
let manager = MidjourneyCredentialManager::initialize_empty(&root);
manager.save_login(cookies(10)).await.unwrap();
assert!(manager.session().await.unwrap().is_none());
assert!(!manager.session_appears_active().unwrap());
let dead = MidjourneyCredential::load(&root.credentials_dir().get_midjourney_credential_path()).unwrap();
assert!(dead.cookie.maybe_relogin_required_since.is_some());
manager.save_login(cookies(Utc::now().timestamp() + 3600)).await.unwrap();
assert!(manager.session().await.unwrap().is_some());
assert!(manager.session_appears_active().unwrap());
assert!(manager.snapshot().unwrap().unwrap().cookie.maybe_relogin_required_since.is_none());
}
#[test]
fn cookie_attributes_and_user_agent_survive_toml_round_trip() {
let (_dir, root) = root();
let origin = Url::parse(MIDJOURNEY_ORIGIN).unwrap();
let mut cookies = CookieStore::empty();
cookies.apply_set_cookie_header("session=host; Path=/; Secure; HttpOnly; SameSite=Lax", &origin);
cookies.apply_set_cookie_header("session=domain; Domain=midjourney.com; Path=/api; Secure", &origin);
let credential = MidjourneyCredential::new(cookies);
let path = root.credentials_dir().get_midjourney_credential_path();
credential.save(&path).unwrap();
let loaded = MidjourneyCredential::load(&path).unwrap();
assert_eq!(loaded.cookie.cookies, credential.cookie.cookies);
assert_eq!(loaded.browser(), credential.browser());
assert_eq!(loaded.cookie.cookies.cookie_header_for_url(&origin).as_deref(), Some("session=host"));
assert!(loaded.cookie.cookies.cookie_header_for_url(&Url::parse("http://www.midjourney.com/").unwrap()).is_none());
assert_eq!(loaded.cookie.cookies.cookie_header_for_url(&Url::parse("https://midjourney.com/api/test").unwrap()).as_deref(), Some("session=domain"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600);
}
}
#[test]
fn corrupt_modern_credential_does_not_revive_legacy_state() {
let (_dir, root) = root();
std::fs::write(root.credentials_dir().get_midjourney_credential_path(), "not valid toml").unwrap();
std::fs::write(root.credentials_dir().get_midjourney_state_path(), "{}").unwrap();
assert!(load_or_migrate(&root).is_err());
}
#[tokio::test]
async fn migrates_legacy_cookies_and_logout_does_not_touch_artcraft() {
let (_dir, root) = root();
let legacy = root.credentials_dir().get_midjourney_state_path();
let artcraft = root.credentials_dir().get_storyteller_session_cookie_file_path();
std::fs::write(&artcraft, "existing-artcraft-session").unwrap();
std::fs::write(
&legacy,
serde_json::json!({
"version": 1,
"user_cookies": {"cookies": [
{"name": AUTH_ID, "value": token(Utc::now().timestamp() + 3600)},
{"name": AUTH_REFRESH, "value": "test-refresh"}
]},
"user_info": {"user_id": TEST_USER_ID, "email": "test@example.invalid", "websocket_token": "old-socket-token"}
})
.to_string(),
)
.unwrap();
let manager = MidjourneyCredentialManager::initialize_from_disk_infallible(&root);
let session = manager.session().await.unwrap().unwrap();
assert_eq!(session.user_id.as_str(), TEST_USER_ID);
let contents = std::fs::read_to_string(root.credentials_dir().get_midjourney_credential_path()).unwrap();
assert!(contents.contains("midjourney_cookies"));
assert!(!contents.contains("old-socket-token"));
assert!(legacy.exists(), "migration preserves the old file until logout");
let reloaded = MidjourneyCredentialManager::initialize_from_disk_infallible(&root);
assert_eq!(reloaded.session().await.unwrap().unwrap().credential_id, session.credential_id);
manager.clear_credentials().await.unwrap();
assert!(!legacy.exists());
assert!(!root.credentials_dir().get_midjourney_credential_path().exists());
assert_eq!(std::fs::read_to_string(artcraft).unwrap(), "existing-artcraft-session");
let reloaded = MidjourneyCredentialManager::initialize_from_disk_infallible(&root);
assert!(reloaded.session().await.unwrap().is_none());
}
mod refresh {
use super::*;
#[test]
fn refreshes_before_expiry_but_does_not_refresh_a_fresh_token() {
assert!(!token_needs_refresh(Some(&token(5000)), 1000));
assert!(token_needs_refresh(Some(&token(1120)), 1000));
assert!(token_needs_refresh(Some(&token(999)), 1000));
assert!(token_needs_refresh(Some("invalid"), 1000));
assert!(token_needs_refresh(None, 1000));
}
#[test]
fn temporary_refresh_failures_do_not_force_logout() {
assert!(!is_revoked_refresh(&MidjourneyApiError::NetworkError("offline".into()).into()));
assert!(!is_revoked_refresh(&MidjourneyApiError::InvalidRequest("API_KEY_INVALID".into()).into()));
assert!(is_revoked_refresh(&MidjourneyApiError::InvalidRequest("INVALID_REFRESH_TOKEN".into()).into()));
}
#[test]
fn corrupt_modern_credential_does_not_revive_legacy_state() {
let (_dir, root) = root();
std::fs::write(root.credentials_dir().get_midjourney_credential_path(), "not valid toml").unwrap();
std::fs::write(root.credentials_dir().get_midjourney_state_path(), "{}").unwrap();
assert!(load_or_migrate(&root).is_err());
}
fn root() -> (TempDir, AppDataRoot) {
@@ -341,10 +130,6 @@ mod tests {
(dir, root)
}
fn cookies(exp: i64) -> CookieStore {
CookieStore::from_cookie_header(&format!("{AUTH_ID}={}", token(exp)), &Url::parse(MIDJOURNEY_ORIGIN).unwrap())
}
fn token(exp: i64) -> String {
let claims = serde_json::json!({"exp": exp, "midjourney_id": TEST_USER_ID});
format!("header.{}.signature", BASE64_URL_SAFE_NO_PAD.encode(claims.to_string()))
@@ -1,4 +1,3 @@
pub (super) mod serializable_midjourney_state;
pub mod midjourney_credential_manager;
pub mod midjourney_user_info;
pub mod midjourney_credential;
@@ -169,7 +169,7 @@ mod tests {
use super::*;
use crate::core::commands::generate::generate_image::providers::midjourney::handle_midjourney::handle_midjourney;
use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
use crate::services::midjourney::state::midjourney_credential::MidjourneyCredential;
use midjourney_client::credentials::credential_file::MidjourneyCredential;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files;
use artcraft_client::utils::api_host::ApiHost;
@@ -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)
}
@@ -1,62 +0,0 @@
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use base64::Engine;
use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId;
use serde_json::Value;
/// The Midjourney auth id-token cookie. Its (unverified) JWT payload carries
/// the Midjourney user id under `midjourney_id`.
const AUTH_ID_TOKEN_COOKIE: &str = "__Host-Midjourney.AuthUserTokenV3_i";
/// The JWT claim holding the Midjourney user id (the `singleplayer_{...}`
/// channel id is built from it).
const MIDJOURNEY_ID_CLAIM: &str = "midjourney_id";
/// Recover the Midjourney user id from the auth cookie's JWT, WITHOUT hitting
/// the Cloudflare-gated index page. `cookie_header` is a raw `Cookie:` header
/// string (`"a=1; b=2"`).
pub fn extract_midjourney_user_id_from_cookie_header(cookie_header: &str) -> Option<MidjourneyUserId> {
let token = cookie_value(cookie_header, AUTH_ID_TOKEN_COOKIE)?;
let claims = decode_jwt_payload(token)?;
let id = claims.get(MIDJOURNEY_ID_CLAIM).and_then(Value::as_str)?;
if id.is_empty() {
return None;
}
Some(MidjourneyUserId::from_str(id))
}
fn cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
cookie_header.split(';').filter_map(|pair| pair.trim().split_once('=')).find(|(cookie_name, _)| *cookie_name == name).map(|(_, value)| value)
}
/// Decode the (unverified) payload of a `header.payload.signature` JWT.
fn decode_jwt_payload(token: &str) -> Option<Value> {
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let _signature = parts.next()?;
if parts.next().is_some() {
return None;
}
let bytes = BASE64_URL_SAFE_NO_PAD.decode(payload).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
// Payload: {"midjourney_id":"26c9d38d-1f71-49c7-a356-b29dac58b54c","name":"u1"}
const JWT: &str = "eyJhbGciOiJIUzI1NiJ9.eyJtaWRqb3VybmV5X2lkIjoiMjZjOWQzOGQtMWY3MS00OWM3LWEzNTYtYjI5ZGFjNThiNTRjIiwibmFtZSI6InUxIn0.sig";
#[test]
fn extracts_user_id_from_auth_cookie() {
let header = format!("_fbp=abc; {}={}; other=x", AUTH_ID_TOKEN_COOKIE, JWT);
let user_id = extract_midjourney_user_id_from_cookie_header(&header).unwrap();
assert_eq!(user_id.as_str(), "26c9d38d-1f71-49c7-a356-b29dac58b54c");
}
#[test]
fn returns_none_without_auth_cookie() {
assert!(extract_midjourney_user_id_from_cookie_header("_fbp=abc; other=x").is_none());
}
}
@@ -1,3 +1 @@
pub mod download_midjourney_image;
pub mod midjourney_browser_profile;
pub mod extract_midjourney_user_id_from_cookies;
@@ -1,4 +1,4 @@
use crate::services::midjourney::utils::midjourney_browser_profile::MIDJOURNEY_USER_AGENT;
use midjourney_client::utils::midjourney_browser_profile::MIDJOURNEY_USER_AGENT;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::clear_all_webview_cookies::clear_all_webview_cookies;
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
@@ -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
+29 -14
View File
@@ -1,7 +1,20 @@
use std::env;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub fn main() {
println!("cargo:rerun-if-env-changed=DATABASE_URL");
println!("cargo:rerun-if-env-changed=SQLX_OFFLINE");
if let Ok(url) = env::var("DATABASE_URL") {
println!("cargo:rustc-env=DATABASE_URL={}", url);
return;
}
if env::var("SQLX_OFFLINE").as_deref() == Ok("true") || env::var("SQLX_OFFLINE").as_deref() == Ok("1") {
println!("cargo:rustc-env=SQLX_OFFLINE=true");
return;
}
// NB: This should help JetBrains' RustRover from highlighting failing query macros,
// but we do not want to interfere with the following:
//
@@ -14,27 +27,29 @@ pub fn main() {
Some("unix") => unix_temp_database_pathing(),
Some("windows") => windows_temp_database_pathing(),
_ => {
println!("cargo:warning=Unsupported target family, using Unix temp database pathing.");
unix_temp_database_pathing();
println!("cargo:rustc-env=SQLX_OFFLINE=true");
},
}
}
fn unix_temp_database_pathing() {
println!("cargo:rustc-env=DATABASE_URL=sqlite:/tmp/tasks.sqlite");
println!("cargo:rerun-if-changed=/tmp/tasks.sqlite");
if Path::new("/tmp/tasks.sqlite").exists() {
println!("cargo:rustc-env=DATABASE_URL=sqlite:/tmp/tasks.sqlite");
} else {
println!("cargo:rustc-env=SQLX_OFFLINE=true");
}
}
fn windows_temp_database_pathing() {
if let Ok(local_app_data) = env::var("LOCALAPPDATA") {
let path = PathBuf::from(local_app_data);
let path = path.join("Temp\\tasks.sqlite");
let path = path
.to_str()
.expect("path should be valid")
.to_string();
println!("cargo:warning=LocalAppData path: {}", path);
println!("cargo:rustc-env=DATABASE_URL=sqlite:{}", path);
} else {
panic!("LOCALAPPDATA environment variable not set");
let path = PathBuf::from(local_app_data).join("Temp\\tasks.sqlite");
if path.exists() {
let path_str = path.to_str().expect("path should be valid");
println!("cargo:warning=LocalAppData path: {}", path_str);
println!("cargo:rustc-env=DATABASE_URL=sqlite:{}", path_str);
return;
}
}
println!("cargo:rustc-env=SQLX_OFFLINE=true");
}
@@ -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],
);

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