Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 fbabbcf623 Update the LoopCraft icon and app colour
The vectorised woodpecker tile and the softer yellow #f0d824, with the
light and dark ink colours re-derived from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:53:20 +07:00
bflatasticandClaude Opus 5.5 cd93e2aacf Place LoopCraft after FigCraft in the lineup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:49:50 +07:00
bflatasticandClaude Opus 5.5 f72fedd0a1 Add LoopCraft to the Crafting Apps pages
LoopCraft (beat making and music production) joins the lineup as the
14th app with its own /apps/loopcraft page: icon and screenshots from
storytold/loopcraft, and no release yet, so the page shows the Discord
waitlist and build-from-source instead of installers.

Its screenshots are 16:9, so gallery shots can be marked `wide`. An app
with no release no longer offers the launcher download in its hero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:46:01 +07:00
BFlatandClaude Opus 5.5 3048db996d Bump FigCraft downloads to 0.5.1 (#137)
Points the FigCraft download buttons on `/apps/figcraft` at
[v0.5.1](https://github.com/storytold/figcraft/releases/tag/v0.5.1) (was
0.4.0). The release ships the same file set, so only the version
changes.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:44:23 +07:00
BFlatandClaude Opus 5.5 bcff742052 Add FigCraft to the Crafting Apps pages (#135)
## Summary

Adds FigCraft (interface design) as the 13th Crafting App, after
DesignCraft.

- **Catalog**: new `figcraft` entry in `crafting-apps.ts` (copy adapted
from the repo README, no third-party product names), release `0.4.0`,
accent colour from the app icon.
- **Assets**: icon, share card and the seven README screenshots from
`storytold/figcraft` main, plus a poster frame for the video.
- **Video section**: an optional `demo` on an app entry renders a
section directly under the hero. FigCraft uses it for the live-sessions
collaboration clip (muted, looping, plays in view via `LazyVideo`, which
gains a `poster` prop). Later sections renumber when a demo is present.
- **/apps hub**: coverage line mentions interface design, the title
keeps "One craft." on one line, and the icon row is a grid of two even
rows (7 + 6) from `sm` up.

## Testing

Checked `/apps` and `/apps/figcraft` in `next dev` at desktop width,
dark theme: video plays, section indices run 01–06, no broken images.
`tsc` reports no errors in the touched files. Not checked: production
build, light theme, mobile layout beyond the icon row.

## Not included

The launcher's release-polling list
(`poll_craft_app_releases_thread/config.rs`) does not list FigCraft yet.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:37:13 +07:00
Brandon ThomasandClaude Opus 5.5 854869ba53 Coordinate Craft App release polling across the fleet through Redis (#77)
New releases took up to ~30 min to reach \`GET
/v1/craft_apps/release_info\` (ArtCraft Launcher 0.2.0 needed 17–29
min), pods disagreed with each other, and every pod polled GitHub on its
own, which uses up the unauthenticated quota (60/h per IP, and 304s
count) and risks backing off for up to 4 h.

The pods now share their results through Redis. There's no lock:
- **One Redis hash per app** (\`craftApps:releases:app:<key>\`, 7 d
TTL). It holds the latest release, the ETag and \`fetched_ms\`. Before
asking GitHub about an app, a pod reads its entry. If it's fresher than
\`interval − 30 s\` (a clock-skew margin), the pod takes it from Redis
and doesn't call GitHub.
- **Last write wins, guarded by timestamp.** A Lua script never lets an
entry go back in time. A 304 moves only \`fetched_ms\`, and only while
the stored ETag still matches.
- **A soft per-app claim** (60 s, no owner, it just lapses), so a fleet
that ticks every 30 s rarely fetches the same app twice.
- **Backoff shared by the whole fleet**
(\`craftApps:releases:backoff\`), so a rate limit slows everyone down.
Per-app failure retry is 60 s, doubling.
- **The endpoint is unchanged.** It still serves each pod's in-memory
copy. If Redis is down, a pod falls back to polling on its own and warns
at most every 10 minutes.

**The GitHub token is optional.** With
\`CRAFT_APPS_RELEASE_GITHUB_TOKEN\` set, the default interval is 120 s
(min 60). Without it (unset or blank), calls go out unauthenticated with
no Authorization header, at 1800 s (min 300), and startup and polling
are never blocked. \`CRAFT_APPS_RELEASE_COORDINATION_ENABLED=false\`
switches back to the old per-pod polling.

**Tests:** \`cargo test -p storyteller-web --bin storyteller-web craft\`
(49 pass) and \`cargo test -p redis_schema craft\` (4 pass). The Redis
tests passed against a local redis-server with
\`CRAFT_APPS_TEST_REDIS_URL=… -- --include-ignored --skip live_round\`
(51 pass).

**Follow-ups after review:**
- **The poller has its own Redis pool** (2 connections; connects within
3 s; every read and write, including the pool's `PING` check, times out
after 5 s). Before this, it shared the handlers' pool, which has no read
timeout, so a Redis that stopped answering could leave the poller
holding every connection while handlers waited 30 s for one. A test
against a fake Redis that goes silent after the handshake fails without
the timeouts and passes with them.
- **When Redis won't run scripts**, writes fall back to
`WATCH`/`MULTI`/`EXEC`: read and decode the guarding fields, decide, and
write in a transaction that Redis drops if another pod wrote in between.
This switch only happens when Redis refuses the script itself. Being
unreachable, busy, out of memory or read-only doesn't trigger it. It was
tested against a real Redis user denied `@scripting`.
- **New pods load from Redis at startup**, before the startup jitter, so
they serve the fleet's releases right away without asking GitHub.
- **`Cache-Control: no-store` until every app has been fetched**
(`last_fetched` set), so Cloudflare never caches a cold pod's "not
fetched yet" answer. The response body is unchanged.

**Tests:** `cargo test -p storyteller-web --bin storyteller-web craft --
--include-ignored --skip live_round` against a local Redis 8.6: 63 pass.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:05:06 -04:00
Navaras P 25f38b79b8 Add docker-compose setup for self-hosting the backend (#68)
Closes storytold/artcraft#1301

Adds `build/selfhost/`, a Docker Compose setup that runs
`storyteller-web` with MySQL 8.4, Redis, Elasticsearch and SeaweedFS
(S3-compatible storage).

- A `migrate` service applies `_database/sql/migrations` in order and
records them in `__diesel_schema_migrations`, so reruns only apply new
migrations.
- `storage-init` creates the three buckets the server expects.
- The server image is built from `build/service_cpu.Dockerfile`.
- `.env.example` lists every variable the server requires at startup,
with placeholders for third-party providers.

SeaweedFS is used because MinIO no longer publishes Docker images.

**Tested locally (Docker Desktop on Windows):** image build, all 285
migrations (plus a rerun), `GET /_status` healthy, model listing,
account creation and login, and an image upload stored in the bucket.

**Not included:** background workers, Elasticsearch index setup.
2026-10-11 18:51:50 -04:00
Dylan M. Taylor 2893b6f713 chore: bump github actions to fix Node 20 deprecation warnings (#69)
This PR bumps GitHub Actions to their latest versions and pins them to
strict SHAs, remediating Node 20 deprecation warnings.
2026-10-11 18:48:57 -04:00
Dylan M. Taylor ad1ca86b00 Update launcher version (#85)
Fixes link to point to the new version with fixes and improvements
2026-10-11 18:34:45 -04:00
Dylan M. Taylor 165a4b272c Add WordCraft, SoundCraft, DeckCraft, CADCraft, and GridCraft marketing pages (#79)
## Summary

Adds product definitions and marketing page assets for the five
remaining Crafting Apps (WordCraft, SoundCraft, DeckCraft, CADCraft, and
GridCraft), and bumps all Crafting Apps in `crafting-app-releases.ts` to
their latest published GitHub release versions.

### Changes

- **Data & Definitions (`crafting-apps.ts`,
`crafting-app-releases.ts`)**:
- Added `wordcraft`, `soundcraft`, `deckcraft`, `cadcraft`, and
`gridcraft` to `CraftAppSlug` and `CRAFTING_APPS`.
  - Added feature sets, ledes, pitches, and release entries.
  - Bumped all app releases to latest GitHub releases:
    - `photocraft`: `0.7.0`
    - `vectorcraft`: `0.9.0`
    - `filmcraft`: `0.6.0`
    - `lightcraft`: `0.6.0`
    - `pdfcraft`: `0.6.0`
    - `effectcraft`: `0.8.0`
    - `designcraft`: `0.6.0`
    - `wordcraft`: `0.5.0`
    - `soundcraft`: `0.5.0`
    - `deckcraft`: `0.5.0`
    - `cadcraft`: `0.5.0`
    - `gridcraft`: `0.5.0`
- Extended `COUNT_WORDS` to support twelve apps and updated category
coverage on `/apps`.
- Maintained copy rules: accurately describing categories and workflows
without naming third-party products.
- **Branding & Themes (`globals.css`)**:
- Added theme color identities (`--app`, `--app-ink`) for all five apps
across light and dark themes with full AA contrast compliance.
- **Visual Assets (`public/images/apps/<slug>/`)**:
- Generated 256px WebP icons, 1600x1000 WebP screenshots, and 1200x630
OG share cards for each new app.

### Testing

- `npm run typecheck` (`tsc --noEmit`) passes cleanly.
- `npm run build` generates all 12 app pages statically (44/44 static
pages).
2026-10-11 13:09:48 -04:00
Dylan M. Taylor b45df084b8 feat(website): offer format choices for ArtCraft Launcher on Linux (#83)
### Summary

- **Linux format choices**: Added a split dropdown button to
`LauncherDownloadButton` on Linux, allowing users to choose their
preferred package format directly from the download button (Flatpak,
AppImage, Debian package, and RPM package).
- **Default and ordering**: Flatpak is selected by default for the
primary download action, with AppImage, `.deb`, and `.rpm` available via
the dropdown arrow.
- **Page integration**: Passed complete release download lists to
`LauncherDownloadButton` across Crafting Apps pages so all supported
Linux formats are available.
2026-10-11 13:03:11 -04:00
BFlatandClaude Opus 5.5 28bd2e7ee3 Bump ArtCraft Launcher downloads to 0.2.1 (#81)
Points every launcher download on /apps and the Crafting App pages at
[v0.2.1](https://github.com/storytold/craft-launcher/releases/tag/v0.2.1).
That release fixes macOS installs and updates failing with "couldn't
open the disk image".

This is merged before the release is published. It only goes live when
the site is published on Netlify.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:53:27 +07:00
BFlatandClaude Opus 5.5 02a3de2509 Bump ArtCraft Launcher downloads to 0.2.0 (#76)
Points every launcher download on /apps and the Crafting App pages at
[v0.2.0](https://github.com/storytold/craft-launcher/releases/tag/v0.2.0),
the latest release. Checked: all 20 files the site links exist in v0.2.0
and download (HTTP 200).

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:40:40 +07:00
DWAA1660 4ae6fe7966 fix(website): update VectorCraft downloads to v0.7.0 (#64)
The VectorCraft app page currently advertises v0.6.0, while GitHub's
latest published release is
[v0.7.0](https://github.com/storytold/vectorcraft/releases/tag/v0.7.0).
Update its shared release configuration so the visible version, release
page, and all 21 asset download links use v0.7.0.

Audited all eight advertised apps against GitHub's latest published
releases on October 9, 2026. ArtCraft (0.41.0), PhotoCraft (0.5.0),
FilmCraft (0.4.0), LightCraft (0.4.0), PDFCraft (0.4.0), EffectCraft
(0.6.0), and DesignCraft (0.4.0) are already current. Both ArtCraft
download configurations match the latest release; the static desktop
landing page delegates to the main download page.

Validation: all 151 configured active download URLs match nonempty
published assets in the latest releases, including all 21 VectorCraft
v0.7.0 assets; `npm run typecheck` passes in
`frontend/apps/artcraft-website-next`; `git diff --check` passes.

The website requires deployment after merging before its public download
links change.
2026-10-11 00:09:26 +07:00
Brandon ThomasandClaude Opus 5.5 f37f82b400 Serve Craft App release info for ArtCraft Launcher (#71)
GET /v1/craft_apps/release_info reports the latest release of every
Craft App (and ArtCraft Launcher itself), so launchers stop polling
GitHub directly. A background thread polls GitHub and keeps a shared
cache.

Polling (threads/poll_craft_app_releases_thread):
- One round at startup (after 0–30 s of jitter, so a fleet deploy is
spread out), then every CRAFT_APPS_RELEASE_POLL_INTERVAL_SECS (30 min
default, 5 min minimum) with ±10% jitter
- One request per app (GET /repos/{repo}/releases/latest) with ETag
revalidation and 1 s between requests
- Rate limits stop the round and wait for GitHub's reset, backing off
2×, 4×, … the interval up to CRAFT_APPS_RELEASE_MAX_BACKOFF_SECS (4 h);
rounds where every request failed retry after 1, 2, 4… min, never more
often, up to the interval
- Per app all-or-nothing: either its release and app_last_fetched are
stored, or nothing about it changes
- CRAFT_APPS_RELEASE_POLLING_ENABLED, CRAFT_APPS_RELEASE_GITHUB_TOKEN
and CRAFT_APPS_RELEASE_APPS (key=Name=org/repo,…) configure it

Cache (state/craft_apps_release_cache): Arc<RwLock<BTreeMap<..>>> of
typed entries. release_info.last_fetched is the oldest app_last_fetched,
absent until every app has been fetched. Apps are listed from the start,
without a release or app_last_fetched until fetched.

The endpoint never fails, sets Cache-Control: public, max-age=60, and
returns empty app_renames, repo_renames and important_notices for now.
API types live in artcraft_api_defs::craft_apps::release_info, with each
asset's OS / arch / package type parsed from its file name. reqwest
moves from a dev-dependency to a dependency of storyteller-web.

Tests: cache freshness, asset-name parsing for every published file
type, untrusted-answer filtering, backoff, and an ignored live round
against GitHub that also writes a sample the launcher's parser is tested
against.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:35:45 -04:00
BFlatandClaude Opus 5.5 42b1872760 Bump ArtCraft Launcher downloads to 0.1.2 (#74)
Points every launcher download on /apps and the Crafting App pages at
[v0.1.2](https://github.com/storytold/craft-launcher/releases/tag/v0.1.2):
the Windows installer now lets you pick the install folder and offers a
desktop shortcut. Merged ahead of the release being published; it goes
live only when the site is published on Netlify.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:44:31 +07:00
BFlatandClaude Opus 5.5 a93ebd6f84 Bump ArtCraft Launcher downloads to 0.1.1 (#73)
Points every launcher download on /apps and the Crafting App pages at
[v0.1.1](https://github.com/storytold/craft-launcher/releases/tag/v0.1.1),
now the latest release: the restyled launcher, the Windows setup wizard
and the blue app icon.

Checked: all 20 files the site links exist in v0.1.1 and download (HTTP
200).

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:04:32 +07:00
BFlatandClaude Opus 5.5 b57f9155da Lead Crafting App pages with the Launcher download; shorter download labels (#72)
## What

- **App pages lead with ArtCraft Launcher.** The first download button
at the top of every `/apps/<slug>` page now downloads ArtCraft Launcher
for the visitor's system. It's the same OS-aware button as the `/apps`
hub, with the platform and build underneath. With no detected system it
jumps to `#get-it`. If the launcher ever has no release, the hero falls
back to the app's own download.
- **Shorter labels.**
- The launcher button now reads **Download Launcher** (hub, app heroes
and the Get-it callout).
- The standalone platform buttons read **Standalone for macOS / Windows
/ Linux** instead of "Download PhotoCraft for Windows". The section
heading and each "Other PhotoCraft files" list still name the app.
- **Spacing fix.** The "For Windows · Installer (64-bit)" line under the
launcher button no longer widens its column, which used to push the
buttons beside it away.

The launcher stays at v0.1.0, its latest published release. All 20 links
the site builds for it exist in that release. v0.1.1 is built but still
a draft; bump `CRAFT_LAUNCHER_RELEASE` once it's published.

## Checks

- Ran the site locally (`next dev`) and looked at `/apps/photocraft` and
`/apps` in Edge at desktop and phone widths: one row of buttons, even
gaps, and the launcher link goes to the Windows x64 MSI.
- `npm run typecheck` reports the same 54 errors as `main` without this
change (Playwright and three.js types in `tests/` and
`media-viewer-3d.tsx`); none come from these files.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:00:18 +07:00
BFlatandClaude c762161646 Recommend ArtCraft Launcher on the Crafting Apps pages (#70)
## Summary

Pushes ArtCraft Launcher
([storytold/craft-launcher](https://github.com/storytold/craft-launcher))
as the recommended way to install the Crafting Apps. One install covers
every app and keeps them up to date. Each app keeps its own installers.

- **`/apps` header:** the main button is now **Download ArtCraft
Launcher**. It detects the visitor's OS and links straight to the right
installer, with a small line naming the file (e.g. "For macOS · Disk
image (Universal)"). Below the buttons, one line says it's the
recommended option and that each app also has its own installers.
- **`/apps/<slug>` Get-it section:** split into two numbered options.
**Option 1 · Recommended** is the launcher block: the pitch on the left,
and on the right a sunken panel with the version, a full-width download
button and an About link. **Option 2 · Standalone** ("Just <App>?
Download it standalone.") comes next, followed by the app's existing
per-platform installers, unchanged.
- **Fallback:** when the OS can't be detected (phones, tablets), the
launcher buttons go to the launcher's GitHub release page.

## Implementation

- `CRAFT_LAUNCHER_RELEASE` in `crafting-app-releases.ts` holds the
launcher's version (v0.1.0). It reuses the shared asset list minus the
macOS CLI zip, and its files are named `artcraft-launcher-*`. Setting it
to `null` hides every launcher button.
- `src/lib/craft-launcher.ts` holds the name, repo, download list and
release-page URL. `src/components/apps/launcher-callout.tsx` holds the
app-page block.
- `craftReleaseDownloads` now delegates to a new repo-based
`releaseDownloads` helper, which the launcher also uses.
- `LauncherDownloadButton` in `app-downloads.tsx` reuses the existing OS
and CPU detection.
- Download clicks are already tracked as `app_download` (`app_name:
craft-launcher`) by the link classifier in `analytics.ts`.
- Small fix: the platform cards get `min-w-0`, so long filenames no
longer push the page wider than the screen on narrow phones.

## Testing

- `npm run typecheck` and `npm run build` pass.
- All 20 launcher release URLs on the page return 200 on GitHub.
- Checked in Chromium with a macOS user agent: `/apps` and app pages in
light and dark mode, and at 1440px, 390px and 360px. Nothing overflows
the page horizontally.

This needs a deploy of `artcraft-website-next` before getartcraft.com
changes.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 12:56:10 +07:00
Brandon ThomasandClaude Opus 5.5 c489e8c650 Cap MySQL statement cache and connection lifetime in storyteller-web (#65)
## Problem

Production `storyteller-web` and the job workers started returning fast
500s with MySQL error 1461:

```
Can't create more than max_prepared_stmt_count statements (current value: 16382)
```

A customer saw this on `GET /v1/prompt/batch`. Errors rose to ~316/hour
on 2026-10-09 and stopped after a rolling restart at ~17:50 UTC. The
restart only clears the caches, so this will happen again under load.

## Cause

sqlx (0.7.4) caches up to **100** prepared statements per connection by
default, and each cached statement stays open on the server. At current
scale, `storyteller-web` can hold:

| Setting                       | Before   | After   |
|-------------------------------|----------|---------|
| Replicas                      | 7        | 7       |
| `MYSQL_MAX_CONNECTIONS`       | 50       | 50      |
| Statement cache / connection  | 100      | 25      |
| Max statements held           | ~35,000  | ~8,750  |
| Server cap                    | 16,382   | 16,382  |

The ~70 call sites that use `QueryBuilder` (variable-length `IN (...)`
lists) produce many different SQL strings, which fill the caches faster.

## Changes

`crates/service/web/storyteller_web/src/configs/connect_to_database.rs`:

- **`statement_cache_capacity`** defaults to 25, configurable with
`MYSQL_STATEMENT_CACHE_CAPACITY`.
- **`max_lifetime`** defaults to 10 minutes (sqlx's default is 30),
configurable with `MYSQL_MAX_LIFETIME_SECONDS`. A query cancelled
between prepare and close (e.g. the client disconnects) leaves its
statement open on the server until the connection closes. Recycling
connections sooner frees those statements sooner.
- Both values are logged with the existing pool settings at startup.

Job workers are left unchanged: their pools are hardcoded to 2
connections, so each holds at most 200 statements.

## Notes

- If replicas or `MYSQL_MAX_CONNECTIONS` increase, keep `replicas ×
MYSQL_MAX_CONNECTIONS × MYSQL_STATEMENT_CACHE_CAPACITY` well under
16,382.
- Follow-up (not in this PR): use `.persistent(false)` on hot dynamic
`QueryBuilder` queries such as `batch_get_prompts`.

## Testing

- `SQLX_OFFLINE=true cargo check -p storyteller-web` passes.
- After deploy, look for the new values in the `Building MySQL pool:`
startup log line, and confirm no new `max_prepared_stmt_count` errors.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:29:20 -04:00
97 changed files with 4479 additions and 107 deletions

No files matched your search

+5 -5
View File
@@ -43,7 +43,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Free up disk on the runner before BuildKit fills it up. The cargo
# release build inside the Dockerfile produces multi-GB target dirs
@@ -60,10 +60,10 @@ jobs:
# BuildKit is required for the `cache-from`/`cache-to` options on
# docker/build-push-action.
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
name: Set up Docker Buildx
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
name: Login to GitHub Container Registry
with:
registry: ghcr.io
@@ -80,7 +80,7 @@ jobs:
- id: meta
name: Compute Docker tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/storytold/artcraft-services
tags: |
@@ -89,7 +89,7 @@ jobs:
type=raw,value=${{ steps.vars.outputs.sha_short }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: ./build/service_cpu.Dockerfile
+5
View File
@@ -84,6 +84,11 @@ report a local update as a deployed update.
Organize for top-to-bottom reading. Important things first, details later.
- **`mod.rs` holds module declarations only** (`pub mod foo;`, plus re-exports if needed). Never
put functions, structs, enums, constants, impls or tests in a `mod.rs`: give each its own file
in that directory (for example `threads/poll_foo_thread/poll_foo.rs`, declared from
`threads/poll_foo_thread/mod.rs`).
- **Constants** at the top (after imports)
- **Structs/enums** next; outer structs above inner sub-structs
- **API types** in order: Request, Response, Error
+59
View File
@@ -0,0 +1,59 @@
# Copy this file to `.env` and change the values.
# `.env` is ignored by git; never commit real keys.
# ----- Local infrastructure (used by docker-compose.yml) -----
MYSQL_ROOT_PASSWORD=change-me-root
MYSQL_PASSWORD=change-me
# Credentials for the local S3-compatible storage (SeaweedFS).
S3_ACCESS_KEY=artcraft
S3_SECRET_KEY=change-me-please
# ----- storyteller-web settings -----
# Cookies are served over plain HTTP locally.
COOKIE_SECURE=false
COOKIE_DOMAIN=localhost
COOKIE_SECRET=change-me
SORT_KEY_SECRET=change-me
WEBSITE_HOMEPAGE_REDIRECT=http://localhost:4201
ENABLE_PAGING=false
NUM_WORKERS=4
# ----- Third-party providers -----
# storyteller-web requires these to be set at startup. Placeholders let the server
# start; features backed by a provider only work once you add a real key.
FAL_API_KEY=unset
FAL_WEBHOOK_URL=http://localhost:12345/v1/webhooks/fal
GMICLOUD_API_KEY=unset
GROK_API_KEY=unset
OPENAI_API_KEY=unset
WORLDLABS_API_KEY=unset
BEEBLE_API_KEY=unset
BEEBLE_WEBHOOK_URL=http://localhost:12345/v1/webhooks/beeble
SEEDANCE2PRO_COOKIES=unset
SEEDANCE2PRO_WHITELIST_COOKIES=unset
SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES=unset
# Email (Resend)
RESEND_API_KEY=unset
# ----- Stripe (billing) -----
STRIPE_SECRET_KEY=unset
STRIPE_SECRET_WEBHOOK_SIGNING_KEY=unset
STRIPE_FAKEYOU_ACCOUNT_ID=unset
STRIPE_PORTAL_DEFAULT_CONFIG_ID=unset
STRIPE_CHECKOUT_SUCCESS_URL_PATH=/checkout_success
STRIPE_CHECKOUT_CANCEL_URL_PATH=/checkout_cancel
STRIPE_PORTAL_RETURN_URL_PATH=/portal_success
STRIPE_ARTCRAFT_ACCOUNT_ID=unset
STRIPE_ARTCRAFT_SECRET_KEY=unset
STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY=unset
STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL=http://localhost:4201/checkout_success
STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL=http://localhost:4201/checkout_cancel
STRIPE_ARTCRAFT_PORTAL_RETURN_URL=http://localhost:4201/portal_closed
+77
View File
@@ -0,0 +1,77 @@
# Self-hosting with Docker Compose
This directory runs the ArtCraft backend (`storyteller-web`) and the services it
depends on with Docker Compose.
| Service | Image | Purpose |
|-------------------|--------------------------|---------------------------------------------|
| `storyteller-web` | built from this repo | HTTP API, on `http://localhost:12345` |
| `mysql` | `mysql:8.4` | Accounts, media metadata, jobs, billing |
| `migrate` | `mysql:8.4` | Applies `_database/sql/migrations`, exits |
| `redis` | `redis:7` | Caching, rate limiting, job progress |
| `elasticsearch` | `elasticsearch:8.15.3` | Search |
| `seaweedfs` | `chrislusf/seaweedfs` | S3-compatible storage for media |
| `storage-init` | `chrislusf/seaweedfs` | Creates the storage buckets, exits |
Only the API port (`12345`) is published to the host.
## Requirements
- Docker with Docker Compose v2.23 or newer.
- About 8 GB of memory for Docker and 20 GB of free disk. The first build
compiles the Rust workspace in release mode and can take a long time.
## Usage
From this directory:
```bash
cp .env.example .env # then edit the passwords and secrets
docker compose up --build -d
```
Check that the server is up:
```bash
curl http://localhost:12345/_status
```
Stop everything with `docker compose down`. Add `-v` to also delete the
database, search index and stored media.
## Configuration
Settings live in `.env`, which git ignores. `docker-compose.yml` sets the
connection settings for MySQL, Redis, Elasticsearch and storage, so you only
need to change passwords and secrets.
`storyteller-web` requires credentials for every third-party provider (FAL,
OpenAI, Stripe, Resend and others) to be present at startup. `.env.example`
sets them to the placeholder `unset`, which lets the server start. Features
that call a provider only work after you add a real key for it.
Provider webhooks, such as `FAL_WEBHOOK_URL`, must be reachable from the
internet for asynchronous generation results to arrive.
## Database migrations
The `migrate` service applies every migration in `_database/sql/migrations` in
order and records each one in `__diesel_schema_migrations`, the table the diesel
CLI uses. It runs on every `docker compose up` and only applies migrations that
have not run yet.
## Connecting the web app
To point the browser app at this server, run it from `frontend` as described in
the repository README:
```bash
VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
```
## Limitations
- Only the HTTP API is included. Background workers (provider polling, video
thumbnails, email, analytics) are not started.
- Elasticsearch starts with no indexes. Index definitions are in
`_database/elasticsearch/index_definitions`.
+163
View File
@@ -0,0 +1,163 @@
# Self-hosted ArtCraft backend (storyteller-web) with its dependencies.
#
# Usage (from this directory):
# cp .env.example .env
# docker compose up --build
#
# See README.md in this directory for details.
name: artcraft-selfhost
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: storyteller
MYSQL_USER: storyteller
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
# NB: Use TCP (127.0.0.1), not the socket, so the check fails while the image's
# temporary init server (which has networking disabled) is still running.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD}", "--silent"]
interval: 5s
timeout: 5s
retries: 30
# Applies the SQL migrations in `_database/sql/migrations` in order.
# Applied versions are recorded in `__diesel_schema_migrations` (the same table
# the diesel CLI uses), so re-running is safe and only applies new migrations.
migrate:
image: mysql:8.4
depends_on:
mysql:
condition: service_healthy
environment:
MYSQL_PWD: ${MYSQL_ROOT_PASSWORD}
volumes:
- ../../_database/sql/migrations:/migrations:ro
entrypoint: ["bash", "-c"]
command:
- |
set -euo pipefail
run_sql() { mysql -h mysql -u root storyteller "$$@"; }
run_sql -e "CREATE TABLE IF NOT EXISTS __diesel_schema_migrations (
version VARCHAR(50) PRIMARY KEY NOT NULL,
run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)"
for dir in /migrations/*/; do
name=$$(basename "$$dir")
version=$$(echo "$${name%%_*}" | tr -d '-')
if [ -n "$$(run_sql -N -e "SELECT 1 FROM __diesel_schema_migrations WHERE version = '$$version'")" ]; then
continue
fi
echo "Applying migration $$name"
run_sql < "$$dir/up.sql"
run_sql -e "INSERT INTO __diesel_schema_migrations (version) VALUES ('$$version')"
done
echo "Migrations complete."
redis:
image: redis:7
restart: unless-stopped
volumes:
- redis-data:/data
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.15.3
restart: unless-stopped
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: -Xms512m -Xmx512m
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD-SHELL", "curl -fs http://localhost:9200/_cluster/health || exit 1"]
interval: 10s
timeout: 5s
retries: 30
# S3-compatible object storage (SeaweedFS) for uploaded and generated media.
seaweedfs:
image: chrislusf/seaweedfs:3.97
restart: unless-stopped
command: server -dir=/data -s3 -s3.port=8333 -s3.config=/etc/seaweedfs/s3.json
configs:
- source: seaweedfs-s3
target: /etc/seaweedfs/s3.json
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8333/healthz || exit 1"]
interval: 5s
timeout: 5s
retries: 30
# Creates the buckets storyteller-web expects.
storage-init:
image: chrislusf/seaweedfs:3.97
depends_on:
seaweedfs:
condition: service_healthy
entrypoint: ["sh", "-c"]
command:
- |
set -e
for bucket in artcraft-private artcraft-public artcraft-public-gc; do
echo "s3.bucket.create -name $$bucket" | weed shell -master=seaweedfs:9333
done
storyteller-web:
build:
context: ../..
dockerfile: build/service_cpu.Dockerfile
image: artcraft-services:local
restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
storage-init:
condition: service_completed_successfully
redis:
condition: service_started
elasticsearch:
condition: service_healthy
env_file: .env
environment:
SERVER_ENVIRONMENT: development
BIND_ADDRESS: 0.0.0.0:12345
MYSQL_URL: mysql://storyteller:${MYSQL_PASSWORD}@mysql:3306/storyteller
REDIS_0_URL: redis://redis:6379/0
ELASTICSEARCH_URL: http://elasticsearch:9200
S3_COMPATIBLE_ENDPOINT_URL: http://seaweedfs:8333
ACCESS_KEY: ${S3_ACCESS_KEY}
SECRET_KEY: ${S3_SECRET_KEY}
REGION_NAME: us-east-1
W2L_PRIVATE_DOWNLOAD_BUCKET_NAME: artcraft-private
W2L_PUBLIC_DOWNLOAD_BUCKET_NAME: artcraft-public
GC_ENABLED_PUBLIC_BUCKET_NAME: artcraft-public-gc
AUDIO_UPLOADS_BUCKET_ROOT: /user_uploaded_audio
ports:
- "12345:12345"
configs:
seaweedfs-s3:
content: |
{
"identities": [
{
"name": "artcraft",
"credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}
volumes:
mysql-data:
redis-data:
elasticsearch-data:
seaweedfs-data:
@@ -0,0 +1 @@
pub mod release_info;
@@ -0,0 +1,203 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use url::Url;
use utoipa::ToSchema;
// ── GET /v1/craft_apps/release_info ──
//
// The latest release of every Craft App (PhotoCraft, VectorCraft, …, and ArtCraft Launcher
// itself), as storyteller-web last fetched it from GitHub, plus renames and notices for the
// ArtCraft Launcher. The launcher polls this instead of GitHub; GitHub is its fallback.
//
// Compatibility: clients must ignore fields they don't know, and treat every field as optional.
// New fields may be added; existing ones keep their meaning.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GetCraftAppsReleaseInfoResponse {
pub success: bool,
pub release_info: CraftAppsReleaseInfo,
/// Apps that changed their name (for example PdfCraft → DocuCraft).
pub app_renames: Vec<CraftAppRename>,
/// Apps whose GitHub repository moved (for example `storytold/photocraft` → `art/photocraft`).
pub repo_renames: Vec<CraftAppRepoRename>,
pub important_notices: CraftAppsImportantNotices,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct CraftAppsReleaseInfo {
/// Keyed by app key: the app's lowercase machine name, as used in its binaries, asset file
/// names and `ai.storyteller.<key>` bundle id (`photocraft`, `cadcraft`, `artcraft-launcher`).
/// Not the display name and not the repo name. Clients should ignore keys they don't know.
pub apps: BTreeMap<String, CraftAppReleaseInfo>,
/// When the least recently fetched app was fetched: every app's data is at least this fresh.
/// Absent until every app has been fetched at least once.
pub last_fetched: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppReleaseInfo {
/// Display name (`PhotoCraft`).
pub name: String,
/// GitHub repository, `organization/project` (`storytold/photocraft`).
pub repo: String,
/// The latest published, non-draft, non-prerelease release. Absent when the repository has no
/// release yet, or when it hasn't been fetched yet (see `app_last_fetched`).
pub latest_release: Option<CraftAppRelease>,
/// When this app's release data was last fetched successfully. Absent if it never was; then
/// `latest_release` says nothing and clients should ask GitHub themselves.
pub app_last_fetched: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppRelease {
/// Git tag (`v0.5.0`).
pub tag: String,
/// The tag without its leading `v` (`0.5.0`, `0.6.0-rc.1`).
pub version: String,
/// Release title (`PhotoCraft v0.5.0`).
pub name: String,
pub published_at: Option<DateTime<Utc>>,
/// The release's page on GitHub.
pub html_url: String,
pub prerelease: bool,
/// Release notes (Markdown), truncated to 64 KiB.
pub notes_markdown: String,
pub assets: Vec<CraftAppReleaseAsset>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppReleaseAsset {
/// File name (`photocraft-0.5.0-windows-x64.msi`).
pub name: String,
pub size_bytes: u64,
/// `https://github.com/<repo>/releases/download/<tag>/<name>`.
pub download_url: String,
/// Lowercase hex SHA-256, when GitHub published one for the file.
pub sha256: Option<String>,
/// What the file name says it is, when it follows the Craft App naming scheme
/// (`<app>-<version>-<os>-<arch>[-portable].<ext>`).
pub platform: Option<CraftAppAssetPlatform>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppAssetPlatform {
pub os: CraftAppOs,
/// Absent for files that aren't for one CPU (the web build, checksums).
pub arch: Option<CraftAppArch>,
pub package_type: CraftAppPackageType,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CraftAppOs {
Windows,
Macos,
Linux,
Freebsd,
/// The browser build.
Web,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CraftAppArch {
X64,
X86,
Arm64,
/// macOS universal binary (Apple silicon and Intel).
Universal,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CraftAppPackageType {
/// Windows Installer.
Msi,
/// Windows portable zip.
PortableZip,
/// macOS disk image.
Dmg,
/// macOS command-line tools zip.
CliZip,
AppImage,
/// AppImage delta-update file.
AppImageZsync,
Deb,
Rpm,
Flatpak,
/// Linux or FreeBSD tarball.
TarGz,
/// The web (WebAssembly) build.
WebZip,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppRename {
/// User-facing name before (`PdfCraft`).
pub old_name: String,
/// Lowercase name before (`pdfcraft`).
pub old_name_lower: String,
/// User-facing name after (`DocuCraft`).
pub new_name: String,
/// Lowercase name after (`docucraft`).
pub new_name_lower: String,
/// A sentence or two to show beside the rename.
pub description: String,
pub renamed_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppRepoRename {
/// `organization/project` before.
pub old_name: String,
/// `organization/project` after.
pub new_name: String,
pub renamed_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct CraftAppsImportantNotices {
/// News, shown inline; the user can dismiss each.
pub announcements: Vec<CraftAppsAnnouncement>,
/// Shown as a popup the user acknowledges.
pub alerts: Vec<CraftAppsAlert>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppsAnnouncement {
pub title: String,
pub body: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CraftAppsAlert {
pub title: String,
pub body: String,
/// A page the popup can offer to open.
pub maybe_web_link: Option<Url>,
pub created_at: Option<DateTime<Utc>>,
}
@@ -2,6 +2,7 @@ pub mod analytics;
pub mod api_keys;
pub mod characters;
pub mod common;
pub mod craft_apps;
pub mod credits;
pub mod folders;
pub mod generate;
@@ -0,0 +1,41 @@
//! The latest release of one Craft App, shared by every storyteller-web instance so the fleet
//! polls GitHub once per app instead of once per pod.
//!
//! A Redis hash, written by `poll_craft_app_releases_thread` (storyteller-web), which documents
//! the fields. Expires if nobody refreshes it for a week, which only happens if polling stops.
use chrono::Duration;
pub struct CraftAppReleaseRedisKey(pub String);
impl_string_key!(CraftAppReleaseRedisKey);
const REDIS_KEY_TTL_DURATION: Duration = Duration::milliseconds(1000 * 60 * 60 * 24 * 7);
impl CraftAppReleaseRedisKey {
/// `app_key` is the app's lowercase machine name (`photocraft`, `artcraft-launcher`).
pub fn new_for_app(app_key: &str) -> Self {
let key = format!("craftApps:releases:app:{}", app_key);
Self(key)
}
pub fn get_redis_ttl() -> Duration {
REDIS_KEY_TTL_DURATION
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_for_app() {
let key = CraftAppReleaseRedisKey::new_for_app("photocraft");
assert_eq!(key.as_str(), "craftApps:releases:app:photocraft");
}
#[test]
fn test_duration() {
assert_eq!(CraftAppReleaseRedisKey::get_redis_ttl().num_days(), 7);
}
}
@@ -0,0 +1,39 @@
//! The fleet-wide GitHub backoff for Craft App release polling: after GitHub rate-limits us (or
//! every request of a round fails), no storyteller-web instance asks GitHub again until it ends.
//!
//! A Redis hash, written by `poll_craft_app_releases_thread` (storyteller-web), which documents
//! the fields. It outlives the backoff itself so repeated trouble keeps growing the wait; it is
//! deleted after a good round.
use chrono::Duration;
pub struct CraftAppsReleaseBackoffRedisKey(pub String);
impl_string_key!(CraftAppsReleaseBackoffRedisKey);
const REDIS_KEY_TTL_DURATION: Duration = Duration::milliseconds(1000 * 60 * 60 * 24);
impl CraftAppsReleaseBackoffRedisKey {
pub fn new_for_fleet() -> Self {
Self("craftApps:releases:backoff".to_string())
}
pub fn get_redis_ttl() -> Duration {
REDIS_KEY_TTL_DURATION
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_for_fleet() {
assert_eq!(CraftAppsReleaseBackoffRedisKey::new_for_fleet().as_str(), "craftApps:releases:backoff");
}
#[test]
fn test_duration() {
assert_eq!(CraftAppsReleaseBackoffRedisKey::get_redis_ttl().num_hours(), 24);
}
}
@@ -0,0 +1,2 @@
pub mod craft_app_release_redis_key;
pub mod craft_apps_release_backoff_redis_key;
@@ -1,2 +1,3 @@
pub mod craft_apps;
pub mod premium;
pub mod inference_job;
pub mod inference_job;
@@ -1,10 +1,11 @@
use std::str::FromStr;
use std::time::Duration;
use log::info;
use errors::AnyhowResult;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use sqlx::mysql::MySqlPoolOptions;
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
use sqlx::MySqlPool;
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
@@ -20,20 +21,37 @@ pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
// shorter timeout sheds load and surfaces the problem instead of hiding it behind long waits.
let acquire_timeout_seconds = easyenv::get_env_num::<u64>("MYSQL_ACQUIRE_TIMEOUT_SECONDS", 10)?;
// NB: Every cached prepared statement stays open on the server until its connection evicts it
// or closes, and the server caps the total across ALL clients (`max_prepared_stmt_count`,
// 16382 on the managed DB). sqlx's default of 100 per connection let `replicas *
// MYSQL_MAX_CONNECTIONS * 100` blow past that cap, failing new prepares with MySQL error 1461.
// Keep `replicas * MYSQL_MAX_CONNECTIONS * MYSQL_STATEMENT_CACHE_CAPACITY` well under it.
let statement_cache_capacity = easyenv::get_env_num::<usize>("MYSQL_STATEMENT_CACHE_CAPACITY", 25)?;
// NB: Recycling connections releases any server-side statements orphaned by cancelled queries
// (eg. a client disconnect between prepare and close). sqlx's default lifetime is 30 minutes.
let max_lifetime_seconds = easyenv::get_env_num::<u64>("MYSQL_MAX_LIFETIME_SECONDS", 600)?;
// NB: Log the settings before building/connecting so a connect failure has context. We do NOT
// log the connection string (it contains credentials).
info!(
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}",
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}, \
statement_cache_capacity={}, max_lifetime_seconds={}",
max_connections, min_connections, acquire_timeout_seconds,
statement_cache_capacity, max_lifetime_seconds,
);
let connect_options = MySqlConnectOptions::from_str(&db_connection_string)?
.statement_cache_capacity(statement_cache_capacity);
let pool_options = MySqlPoolOptions::new()
.max_connections(max_connections)
.min_connections(min_connections)
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds));
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds))
.max_lifetime(Some(Duration::from_secs(max_lifetime_seconds)));
let pool = pool_options
.connect(&db_connection_string)
.connect_with(connect_options)
.await?;
Ok(pool)
@@ -162,6 +162,7 @@ use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*;
use artcraft_api_defs::moderation::top_spenders::list::*;
use artcraft_api_defs::moderation::user_spend_events::list::*;
use artcraft_api_defs::folders::subfolder::*;
use artcraft_api_defs::craft_apps::release_info::*;
use artcraft_api_defs::tags::add_media_file_tags::*;
use artcraft_api_defs::tags::bulk_add_tags::*;
use artcraft_api_defs::tags::bulk_list_media_file_tags::*;
@@ -734,6 +735,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler,
crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler,
// Craft Apps
crate::http_server::endpoints::craft_apps::get_craft_apps_release_info_handler::get_craft_apps_release_info_handler,
// Tags
crate::http_server::endpoints::tags::list_tags_handler::list_tags_handler,
crate::http_server::endpoints::tags::list_media_file_tags_handler::list_media_file_tags_handler,
@@ -1606,6 +1610,20 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListTaggedMediaFilesSuccessResponse,
ListTagsQueryParams,
ListTagsSuccessResponse,
GetCraftAppsReleaseInfoResponse,
CraftAppsReleaseInfo,
CraftAppReleaseInfo,
CraftAppRelease,
CraftAppReleaseAsset,
CraftAppAssetPlatform,
CraftAppOs,
CraftAppArch,
CraftAppPackageType,
CraftAppRename,
CraftAppRepoRename,
CraftAppsImportantNotices,
CraftAppsAnnouncement,
CraftAppsAlert,
ListUntaggedMediaFilesQueryParams,
ListUntaggedMediaFilesSuccessResponse,
MediaFileTagsEntry,
@@ -0,0 +1,73 @@
use std::sync::Arc;
use actix_web::http::header::{CacheControl, CacheDirective};
use actix_web::{web, HttpResponse};
use artcraft_api_defs::craft_apps::release_info::{CraftAppsImportantNotices, CraftAppsReleaseInfo, GetCraftAppsReleaseInfoResponse};
use crate::state::server_state::ServerState;
/// Shared caches (Cloudflare) may serve the same answer for this long once every app has been
/// fetched: the data changes at most once per polling interval.
const CACHE_MAX_AGE_SECS: u32 = 60;
/// The latest release of every Craft App (and ArtCraft Launcher), as storyteller-web last fetched
/// it from GitHub, plus app/repo renames and notices for the launcher. Never fails: before the
/// first fetch, apps are listed without a release and without `app_last_fetched`.
#[utoipa::path(
get,
tag = "Craft Apps",
path = "/v1/craft_apps/release_info",
responses(
(status = 200, body = GetCraftAppsReleaseInfoResponse),
),
)]
pub async fn get_craft_apps_release_info_handler(
server_state: web::Data<Arc<ServerState>>,
) -> HttpResponse {
let release_info = server_state.craft_apps_release_cache.snapshot();
let cache_control = cache_control_for(&release_info);
let response = GetCraftAppsReleaseInfoResponse {
success: true,
release_info,
// None yet. When there are, they're listed here (newest first).
app_renames: Vec::new(),
repo_renames: Vec::new(),
important_notices: CraftAppsImportantNotices::default(),
};
HttpResponse::Ok()
.insert_header(cache_control)
.json(response)
}
/// Cacheable only once every app has been fetched (`last_fetched` is set). Until then (an
/// instance that just started, or one whose first fetch failed) the answer is `no-store`, so a
/// shared cache never keeps handing out "not fetched yet" after this instance has warmed up.
fn cache_control_for(release_info: &CraftAppsReleaseInfo) -> CacheControl {
if release_info.last_fetched.is_some() {
CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(CACHE_MAX_AGE_SECS)])
} else {
CacheControl(vec![CacheDirective::NoStore])
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use chrono::Utc;
use super::*;
#[test]
fn a_fully_fetched_answer_is_cacheable() {
let release_info = CraftAppsReleaseInfo { apps: BTreeMap::new(), last_fetched: Some(Utc::now()) };
assert_eq!(cache_control_for(&release_info).to_string(), "public, max-age=60");
}
#[test]
fn an_answer_with_unfetched_apps_is_never_stored() {
let release_info = CraftAppsReleaseInfo { apps: BTreeMap::new(), last_fetched: None };
assert_eq!(cache_control_for(&release_info).to_string(), "no-store");
}
}
@@ -0,0 +1 @@
pub mod get_craft_apps_release_info_handler;
@@ -5,6 +5,7 @@ pub mod beta_keys;
pub mod billing_fakeyou;
pub mod characters;
pub mod comments;
pub mod craft_apps;
pub mod credits;
pub mod dev;
pub mod download_job;
@@ -63,6 +63,7 @@ use crate::startup::setup_inference_providers::{
use crate::startup::setup_pager::build_pager;
use crate::startup::setup_static_feature_flags::setup_static_feature_flags;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::craft_apps_release_cache::CraftAppsReleaseCache;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::state::server_state::{
Dashboards, DataboxDashboards, DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches,
@@ -702,6 +703,7 @@ pub(crate) fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: S
sort_key_crypto: WebSortKeyCrypto::new("test-sort-key"),
opaque_cursors: WebOpaqueCursorEncoderV2::new("test-sort-key"),
ip_ban_list: IpBanList::new(),
craft_apps_release_cache: CraftAppsReleaseCache::new(),
cidr_ban_set: BannedCidrSet::new(),
troll_bans: TrollBans {
user_tokens: TrollUserBanList::new(),
@@ -3,6 +3,7 @@ use crate::http_server::routes::application_routes::api_keys_routes::add_api_key
use crate::http_server::routes::application_routes::billing_fakeyou_routes::add_billing_fakeyou_routes;
use crate::http_server::routes::application_routes::character_routes::add_character_routes;
use crate::http_server::routes::application_routes::comments_routes::add_comments_routes;
use crate::http_server::routes::application_routes::craft_apps_routes::add_craft_apps_routes;
use crate::http_server::routes::application_routes::credits_routes::add_credits_routes;
use crate::http_server::routes::application_routes::dev_routes::add_dev_routes;
use crate::http_server::routes::application_routes::featured_item_routes::add_featured_item_routes;
@@ -76,6 +77,7 @@ where
app = add_video_info_routes(app); // /v1/video_info/... (video provenance detection)
app = add_analytics_routes(app); // /v1/analytics/...
app = add_web_referrals_routes(app); // /v1/web_referrals/...
app = add_craft_apps_routes(app); // /v1/craft_apps/... (ArtCraft Launcher release info)
app = add_dev_routes(app); // /v1/dev/... — dev-only error/response shape probes
// User and user-adjacent routes
@@ -0,0 +1,28 @@
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::craft_apps::get_craft_apps_release_info_handler::get_craft_apps_release_info_handler;
pub fn add_craft_apps_routes<T, B>(app: App<T>) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
Error = Error,
InitError = (),
>,
{
app.service(
web::scope("/v1/craft_apps")
// Release info for ArtCraft Launcher (polled instead of GitHub).
.service(
web::resource("/release_info")
.route(web::get().to(get_craft_apps_release_info_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
)
}
@@ -3,6 +3,7 @@ mod api_keys_routes;
mod billing_fakeyou_routes;
mod character_routes;
mod comments_routes;
mod craft_apps_routes;
mod credits_routes;
mod dev_routes;
mod featured_item_routes;
@@ -37,6 +37,7 @@ use errors::AnyhowResult;
use log::{info, warn};
use server_environment::ServerEnvironment;
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use shared_env_var_config::redis::env_get_redis_0_connection_string_or_default;
use tokio::runtime::Runtime;
use user_traits_component::traits::internal_session_cache_purge::InternalSessionCachePurge;
@@ -56,6 +57,9 @@ use crate::startup::setup_metrics::build_metrics;
use crate::state::server_state::ServerState;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
use crate::threads::expire_login_challenges::expire_login_challenges;
use crate::threads::poll_craft_app_releases_thread::config::CraftAppsPollConfig;
use crate::threads::poll_craft_app_releases_thread::poll_craft_app_releases::poll_craft_app_releases;
use crate::threads::poll_craft_app_releases_thread::shared_store::build_poller_redis_pool;
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
use crate::threads::poll_model_token_info_thread::poll_model_token_info_thread;
@@ -150,6 +154,18 @@ async fn main() -> AnyhowResult<()> {
poll_model_token_info_thread(model_token_info_cache_clone, mysql_pool_clone).await;
});
info!("Spawning craft app release polling thread.");
let craft_apps_release_cache_clone = server_state.craft_apps_release_cache.clone();
let craft_apps_poll_config = CraftAppsPollConfig::from_env();
// Its own small pool with I/O timeouts, so a hung Redis can't tie up the handlers' pool.
let craft_apps_redis_pool = build_poller_redis_pool(&env_get_redis_0_connection_string_or_default())?;
let hostname_clone = server_state.hostname.clone();
tokio_runtime.spawn(async move {
poll_craft_app_releases(craft_apps_release_cache_clone, craft_apps_poll_config, craft_apps_redis_pool, hostname_clone).await;
});
// ==================== Metrics worker ==================== //
let (metrics_collector, maybe_metrics_worker) =
@@ -41,6 +41,7 @@ use crate::startup::setup_static_feature_flags::setup_static_feature_flags;
use crate::startup::setup_stripe_artcraft::setup_stripe_artcraft;
use crate::startup::setup_stripe_fakeyou::setup_stripe_fakeyou;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::craft_apps_release_cache::CraftAppsReleaseCache;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::state::server_state::{
DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches,
@@ -259,6 +260,7 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
}
},
ip_ban_list,
craft_apps_release_cache: CraftAppsReleaseCache::new(),
cidr_ban_set,
troll_bans: TrollBans {
user_tokens: user_token_troll_bans,
@@ -0,0 +1,279 @@
//! The latest release of every Craft App, as last fetched from GitHub by
//! `poll_craft_app_releases_thread` (directly, or from the fleet's shared copy in Redis), served
//! by `GET /v1/craft_apps/release_info`. The endpoint always answers from this memory, so trouble
//! with Redis or GitHub never breaks it.
//!
//! ## App keys
//!
//! Apps are keyed by their **app key**: the app's lowercase machine name, the same string the
//! Craft Apps use for their binaries, asset file names and `ai.storyteller.<key>` bundle ids.
//! For example `photocraft`, `pdfcraft`, `cadcraft`, and `artcraft-launcher` for ArtCraft Launcher
//! itself. The key is not the display name (`PhotoCraft`) and not the GitHub repo
//! (`storytold/photocraft`, whose project name can differ: the launcher's repo is
//! `storytold/craft-launcher`).
//!
//! The keys come from the polling config (`poll_craft_app_releases_thread::config`: the built-in
//! list, or `CRAFT_APPS_RELEASE_APPS`) and are served unchanged as the keys of
//! `release_info.apps`, which ArtCraft Launcher matches against its own app ids.
use std::collections::BTreeMap;
use std::sync::{Arc, PoisonError, RwLock};
use chrono::{DateTime, Utc};
use artcraft_api_defs::craft_apps::release_info::{CraftAppRelease, CraftAppReleaseInfo, CraftAppsReleaseInfo};
/// Shared between the polling thread and the HTTP handlers. Cheap to clone.
#[derive(Clone, Default)]
pub struct CraftAppsReleaseCache {
/// App key (lowercase machine name, e.g. `photocraft`; see the module docs) → what's known
/// about that app. A `BTreeMap` so the endpoint lists apps in a stable order.
inner: Arc<RwLock<BTreeMap<String, CachedCraftApp>>>,
}
/// One app: what's known about it, and the ETag to revalidate it with.
#[derive(Clone, Debug)]
pub struct CachedCraftApp {
/// Display name (`PhotoCraft`).
pub name: String,
/// GitHub repository, `organization/project` (`storytold/photocraft`).
pub repo: String,
/// The latest published release. `None` both before the first fetch and when the repository
/// has no release; `app_last_fetched` tells the two apart.
pub latest_release: Option<CraftAppRelease>,
/// When this app's release was last fetched (or confirmed unchanged) successfully. `None`
/// until the first success.
pub app_last_fetched: Option<DateTime<Utc>>,
/// GitHub's ETag for the last successful answer, sent back as `If-None-Match` so an unchanged
/// release costs a `304`.
pub etag: Option<String>,
/// When a request for this app last failed. Only meaningful when newer than
/// `app_last_fetched`; it delays the next attempt so a broken repo isn't retried every tick.
pub last_failed_at: Option<DateTime<Utc>>,
/// How many requests in a row have failed since the last success (when `last_failed_at` is
/// newer than `app_last_fetched`).
pub consecutive_failures: u32,
}
impl CraftAppsReleaseCache {
pub fn new() -> Self {
Self::default()
}
/// Makes sure every app in `apps` has an entry, so the endpoint reports apps that haven't been
/// fetched yet. Each tuple is `(app key, display name, repo)`, for example
/// `("photocraft", "PhotoCraft", "storytold/photocraft")`. Apps already present keep what
/// was fetched for them.
pub fn register_apps(&self, apps: &[(String, String, String)]) {
let mut map = self.inner.write().unwrap_or_else(PoisonError::into_inner);
for (key, name, repo) in apps {
map.entry(key.clone()).or_insert_with(|| CachedCraftApp {
name: name.clone(),
repo: repo.clone(),
latest_release: None,
app_last_fetched: None,
etag: None,
last_failed_at: None,
consecutive_failures: 0,
});
}
}
/// The ETag of the last successful answer for the app with app key `key` (`photocraft`), to
/// send as `If-None-Match`.
pub fn etag(&self, key: &str) -> Option<String> {
let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);
map.get(key).and_then(|app| app.etag.clone())
}
/// Records a complete, successful fetch of the app with app key `key` (`photocraft`): its
/// latest release (`None` if it has none). Ignored for keys that were never registered.
pub fn store(&self, key: &str, latest_release: Option<CraftAppRelease>, etag: Option<String>, fetched_at: DateTime<Utc>) {
let mut map = self.inner.write().unwrap_or_else(PoisonError::into_inner);
if let Some(app) = map.get_mut(key) {
app.latest_release = latest_release;
app.etag = etag;
app.app_last_fetched = Some(fetched_at);
}
}
/// Records that GitHub confirmed the app with app key `key` (`photocraft`) is unchanged
/// (`304 Not Modified`). Ignored for keys that were never registered.
pub fn confirm_unchanged(&self, key: &str, fetched_at: DateTime<Utc>) {
let mut map = self.inner.write().unwrap_or_else(PoisonError::into_inner);
if let Some(app) = map.get_mut(key) {
app.app_last_fetched = Some(fetched_at);
}
}
/// A copy of what's known about the app with app key `key` (`photocraft`).
pub fn app(&self, key: &str) -> Option<CachedCraftApp> {
let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);
map.get(key).cloned()
}
/// Takes a fetch made elsewhere (another instance, through Redis) for the app with app key
/// `key`, if it is newer than what this instance has. Returns whether it was taken. Ignored for
/// keys that were never registered, so the registered app list stays authoritative.
pub fn merge_fetched(&self, key: &str, latest_release: Option<CraftAppRelease>, etag: Option<String>, fetched_at: DateTime<Utc>) -> bool {
let mut map = self.inner.write().unwrap_or_else(PoisonError::into_inner);
let Some(app) = map.get_mut(key) else {
return false;
};
if app.app_last_fetched.is_some_and(|mine| mine >= fetched_at) {
return false;
}
app.latest_release = latest_release;
app.etag = etag;
app.app_last_fetched = Some(fetched_at);
true
}
/// Records a failed request for the app with app key `key` (`photocraft`) and returns how many
/// have failed in a row since its last success. Ignored (returns 0) for unregistered keys.
pub fn record_failure(&self, key: &str, failed_at: DateTime<Utc>) -> u32 {
let mut map = self.inner.write().unwrap_or_else(PoisonError::into_inner);
let Some(app) = map.get_mut(key) else {
return 0;
};
app.consecutive_failures = if app.is_failing() { app.consecutive_failures.saturating_add(1) } else { 1 };
app.last_failed_at = Some(failed_at);
app.consecutive_failures
}
/// Takes a failure recorded elsewhere (another instance, through Redis) for the app with app
/// key `key`, if it is newer than the one this instance knows.
pub fn merge_failure(&self, key: &str, failed_at: DateTime<Utc>, consecutive_failures: u32) {
let mut map = self.inner.write().unwrap_or_else(PoisonError::into_inner);
if let Some(app) = map.get_mut(key) {
if app.last_failed_at.is_none_or(|mine| mine < failed_at) {
app.last_failed_at = Some(failed_at);
app.consecutive_failures = consecutive_failures;
}
}
}
/// Everything, as the endpoint serves it: `apps` keyed by app key.
pub fn snapshot(&self) -> CraftAppsReleaseInfo {
let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);
let apps: BTreeMap<String, CraftAppReleaseInfo> = map
.iter()
.map(|(key, app)| {
(key.clone(), CraftAppReleaseInfo {
name: app.name.clone(),
repo: app.repo.clone(),
latest_release: app.latest_release.clone(),
app_last_fetched: app.app_last_fetched,
})
})
.collect();
let last_fetched = least_recently_fetched(&apps);
CraftAppsReleaseInfo { apps, last_fetched }
}
}
impl CachedCraftApp {
/// Whether the last request for this app failed (no success since).
pub fn is_failing(&self) -> bool {
match (self.last_failed_at, self.app_last_fetched) {
(Some(failed), Some(fetched)) => failed > fetched,
(Some(_), None) => true,
(None, _) => false,
}
}
}
/// The oldest `app_last_fetched`, or `None` while any app has never been fetched.
fn least_recently_fetched(apps: &BTreeMap<String, CraftAppReleaseInfo>) -> Option<DateTime<Utc>> {
if apps.is_empty() {
return None;
}
let mut oldest: Option<DateTime<Utc>> = None;
for app in apps.values() {
let fetched = app.app_last_fetched?;
oldest = Some(oldest.map_or(fetched, |o| o.min(fetched)));
}
oldest
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use super::*;
#[test]
fn last_fetched_is_the_oldest_app_and_absent_until_all_are_fetched() {
let cache = CraftAppsReleaseCache::new();
cache.register_apps(&[
("photocraft".into(), "PhotoCraft".into(), "storytold/photocraft".into()),
("filmcraft".into(), "FilmCraft".into(), "storytold/filmcraft".into()),
]);
assert_eq!(cache.snapshot().apps.len(), 2);
assert_eq!(cache.snapshot().last_fetched, None);
let early = Utc.with_ymd_and_hms(2026, 10, 10, 1, 0, 0).unwrap();
let late = Utc.with_ymd_and_hms(2026, 10, 10, 2, 0, 0).unwrap();
cache.store("photocraft", None, Some("\"abc\"".into()), late);
assert_eq!(cache.snapshot().last_fetched, None, "filmcraft hasn't been fetched yet");
cache.confirm_unchanged("filmcraft", early);
assert_eq!(cache.snapshot().last_fetched, Some(early));
assert_eq!(cache.etag("photocraft").as_deref(), Some("\"abc\""));
}
#[test]
fn registering_again_keeps_what_was_fetched() {
let cache = CraftAppsReleaseCache::new();
let apps = [("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string())];
cache.register_apps(&apps);
let now = Utc::now();
cache.confirm_unchanged("photocraft", now);
cache.register_apps(&apps);
assert_eq!(cache.snapshot().apps["photocraft"].app_last_fetched, Some(now));
}
#[test]
fn merging_takes_only_newer_fetches_of_registered_apps() {
let cache = CraftAppsReleaseCache::new();
cache.register_apps(&[("photocraft".into(), "PhotoCraft".into(), "storytold/photocraft".into())]);
let early = Utc.with_ymd_and_hms(2026, 10, 10, 1, 0, 0).unwrap();
let late = Utc.with_ymd_and_hms(2026, 10, 10, 2, 0, 0).unwrap();
assert!(cache.merge_fetched("photocraft", None, Some("\"new\"".into()), late));
assert!(!cache.merge_fetched("photocraft", None, Some("\"old\"".into()), early), "older than ours");
assert!(!cache.merge_fetched("photocraft", None, Some("\"same\"".into()), late), "no newer than ours");
assert!(!cache.merge_fetched("unknowncraft", None, None, late), "not registered");
let snapshot = cache.snapshot();
assert_eq!(snapshot.apps.len(), 1);
assert_eq!(snapshot.apps["photocraft"].app_last_fetched, Some(late));
assert_eq!(cache.etag("photocraft").as_deref(), Some("\"new\""));
}
#[test]
fn failures_count_up_until_a_success() {
let cache = CraftAppsReleaseCache::new();
cache.register_apps(&[("photocraft".into(), "PhotoCraft".into(), "storytold/photocraft".into())]);
let t = |h| Utc.with_ymd_and_hms(2026, 10, 10, h, 0, 0).unwrap();
assert_eq!(cache.record_failure("photocraft", t(1)), 1);
assert_eq!(cache.record_failure("photocraft", t(2)), 2);
assert!(cache.app("photocraft").unwrap().is_failing());
cache.store("photocraft", None, None, t(3));
assert!(!cache.app("photocraft").unwrap().is_failing());
assert_eq!(cache.record_failure("photocraft", t(4)), 1, "counting restarts after a success");
cache.merge_failure("photocraft", t(3), 9);
assert_eq!(cache.app("photocraft").unwrap().consecutive_failures, 1, "older failure ignored");
cache.merge_failure("photocraft", t(5), 4);
assert_eq!(cache.app("photocraft").unwrap().consecutive_failures, 4);
}
}
@@ -1,5 +1,6 @@
pub mod cached_queries;
pub mod flags;
pub mod certs;
pub mod craft_apps_release_cache;
pub mod memory_cache;
pub mod server_state;
@@ -1,6 +1,7 @@
use std::collections::HashSet;
use crate::configs::app_startup::username_set::UsernameSet;
use crate::state::craft_apps_release_cache::CraftAppsReleaseCache;
use crate::configs::static_api_tokens::StaticApiTokenSet;
use crate::http_server::deprecated_endpoints::categories::tts::list_fully_computed_assigned_tts_categories::list_fully_computed_assigned_tts_categories::ModelTokensByCategoryToken;
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::ListFeaturedMediaFilesQueryParams;
@@ -124,6 +125,9 @@ pub struct ServerState {
pub caches: InMemoryCaches,
/// The latest Craft App releases, kept fresh by `poll_craft_app_releases_thread`.
pub craft_apps_release_cache: CraftAppsReleaseCache,
pub google_sign_in_cert: GoogleSignInCert,
pub temp_dir_creator: ScopedTempDirCreator,
@@ -1,4 +1,5 @@
pub mod db_health_checker_thread;
pub mod expire_login_challenges;
pub mod poll_craft_app_releases_thread;
pub mod poll_ip_banlist_thread;
pub mod poll_model_token_info_thread;
@@ -0,0 +1,164 @@
//! How storyteller-web polls GitHub for Craft App releases, from the environment.
//!
//! | Variable | Default | Meaning |
//! |-------------------------------------------|-----------------------------|---------------------------------------------------------------|
//! | `CRAFT_APPS_RELEASE_POLLING_ENABLED` | `true` | Poll at all (the endpoint still answers) |
//! | `CRAFT_APPS_RELEASE_COORDINATION_ENABLED` | `true` | Share releases and backoff through Redis (see below) |
//! | `CRAFT_APPS_RELEASE_POLL_INTERVAL_SECS` | `120`; `1800` with no token | How old a release may get; at least `60`; `300` with no token |
//! | `CRAFT_APPS_RELEASE_MAX_BACKOFF_SECS` | `14400` (4 h) | Longest wait after repeated failures/rate limits |
//! | `CRAFT_APPS_RELEASE_GITHUB_TOKEN` | (none; optional) | Raises GitHub's limit from 60 to 5000 per hour |
//! | `CRAFT_APPS_RELEASE_APPS` | built-in list | `key=Name=org/repo,…` to replace the app list |
//!
//! The GitHub token is optional. Unset, empty or blank, polling still runs: requests go out
//! without an `Authorization` header, and the interval defaults to the slow no-token value.
//!
//! With coordination (the default), the fleet shares each app's release, ETag and fetch time in
//! Redis, so the whole fleet requests an app from GitHub about once per interval instead of every
//! pod doing so. That is what makes the 2 minute default safe with a token. Without a token
//! GitHub allows 60 requests an hour per IP address and also counts `304`s, so the interval stays
//! at 30 minutes (13 apps: about 26 requests an hour). While Redis is unreachable (or with
//! coordination off) every pod polls on its own at the same interval.
use std::time::Duration;
const DEFAULT_INTERVAL_SECS_WITH_TOKEN: u64 = 120;
const MIN_INTERVAL_SECS_WITH_TOKEN: u64 = 60;
const DEFAULT_INTERVAL_SECS_WITHOUT_TOKEN: u64 = 1800;
const MIN_INTERVAL_SECS_WITHOUT_TOKEN: u64 = 300;
const DEFAULT_MAX_BACKOFF_SECS: u64 = 14_400;
/// The Craft Apps the launcher knows: `(app key, display name, GitHub repo)`. The app key is
/// the app's lowercase machine name; it keys `CraftAppsReleaseCache` and the endpoint's
/// `release_info.apps`, and must match the app id ArtCraft Launcher uses. ArtCraft Launcher is
/// included (key `artcraft-launcher`, repo `storytold/craft-launcher`) so it can check for its
/// own updates.
const DEFAULT_APPS: &[(&str, &str, &str)] = &[
("photocraft", "PhotoCraft", "storytold/photocraft"),
("vectorcraft", "VectorCraft", "storytold/vectorcraft"),
("filmcraft", "FilmCraft", "storytold/filmcraft"),
("lightcraft", "LightCraft", "storytold/lightcraft"),
("pdfcraft", "PdfCraft", "storytold/pdfcraft"),
("effectcraft", "EffectCraft", "storytold/effectcraft"),
("designcraft", "DesignCraft", "storytold/designcraft"),
("wordcraft", "WordCraft", "storytold/wordcraft"),
("deckcraft", "DeckCraft", "storytold/deckcraft"),
("gridcraft", "GridCraft", "storytold/gridcraft"),
("cadcraft", "CADCraft", "storytold/cadcraft"),
("soundcraft", "SoundCraft", "storytold/soundcraft"),
("artcraft-launcher", "ArtCraft Launcher", "storytold/craft-launcher"),
];
#[derive(Clone, Debug)]
pub struct CraftAppsPollConfig {
pub enabled: bool,
/// Share releases and backoff with the fleet through Redis; when false (or while Redis is
/// unreachable) this pod polls on its own.
pub coordination_enabled: bool,
/// How old an app's release may get before it is requested from GitHub again.
pub interval: Duration,
pub max_backoff: Duration,
/// Never empty or blank: those are read as no token.
pub github_token: Option<String>,
/// `(app key, display name, repo)`; see [`DEFAULT_APPS`].
pub apps: Vec<(String, String, String)>,
}
impl CraftAppsPollConfig {
pub fn from_env() -> Self {
let github_token = non_blank_token(easyenv::get_env_string_optional("CRAFT_APPS_RELEASE_GITHUB_TOKEN"));
let has_token = github_token.is_some();
let default_interval = default_interval_secs(has_token);
let requested_interval = easyenv::get_env_num::<u64>("CRAFT_APPS_RELEASE_POLL_INTERVAL_SECS", default_interval)
.unwrap_or(default_interval);
let interval_secs = clamp_interval_secs(requested_interval, has_token);
let max_backoff_secs = easyenv::get_env_num::<u64>("CRAFT_APPS_RELEASE_MAX_BACKOFF_SECS", DEFAULT_MAX_BACKOFF_SECS)
.unwrap_or(DEFAULT_MAX_BACKOFF_SECS)
.max(interval_secs);
let apps = easyenv::get_env_string_optional("CRAFT_APPS_RELEASE_APPS")
.map(|v| parse_app_list(&v))
.filter(|apps| !apps.is_empty())
.unwrap_or_else(default_apps);
Self {
enabled: easyenv::get_env_bool_or_default("CRAFT_APPS_RELEASE_POLLING_ENABLED", true),
coordination_enabled: easyenv::get_env_bool_or_default("CRAFT_APPS_RELEASE_COORDINATION_ENABLED", true),
interval: Duration::from_secs(interval_secs),
max_backoff: Duration::from_secs(max_backoff_secs),
github_token,
apps,
}
}
}
pub fn default_apps() -> Vec<(String, String, String)> {
DEFAULT_APPS.iter().map(|(k, n, r)| (k.to_string(), n.to_string(), r.to_string())).collect()
}
/// The token, trimmed; `None` when unset, empty or blank.
fn non_blank_token(value: Option<String>) -> Option<String> {
value.map(|t| t.trim().to_string()).filter(|t| !t.is_empty())
}
fn default_interval_secs(has_token: bool) -> u64 {
if has_token { DEFAULT_INTERVAL_SECS_WITH_TOKEN } else { DEFAULT_INTERVAL_SECS_WITHOUT_TOKEN }
}
fn clamp_interval_secs(requested: u64, has_token: bool) -> u64 {
let min = if has_token { MIN_INTERVAL_SECS_WITH_TOKEN } else { MIN_INTERVAL_SECS_WITHOUT_TOKEN };
requested.max(min)
}
/// Reads `key=Name=org/repo,…` (app key, display name, repo). Keys are lowercased. Malformed
/// entries are skipped.
fn parse_app_list(value: &str) -> Vec<(String, String, String)> {
value
.split(',')
.filter_map(|entry| {
let mut parts = entry.trim().splitn(3, '=');
let key = parts.next()?.trim().to_lowercase();
let name = parts.next()?.trim().to_string();
let repo = parts.next()?.trim().to_string();
let valid_repo = repo.split('/').count() == 2 && !repo.starts_with('/') && !repo.ends_with('/');
(!key.is_empty() && !name.is_empty() && valid_repo).then_some((key, name, repo))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_an_app_list_and_skips_bad_entries() {
let apps = parse_app_list(" PhotoCraft=PhotoCraft=storytold/photocraft, junk, x=X=noslash, k=K=org/repo ");
assert_eq!(apps, vec![
("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string()),
("k".to_string(), "K".to_string(), "org/repo".to_string()),
]);
}
#[test]
fn the_default_list_has_the_suite_and_the_launcher() {
let apps = default_apps();
assert_eq!(apps.len(), 13);
assert!(apps.iter().any(|(k, _, r)| k == "artcraft-launcher" && r == "storytold/craft-launcher"));
}
#[test]
fn a_missing_empty_or_blank_token_is_no_token() {
assert_eq!(non_blank_token(None), None);
assert_eq!(non_blank_token(Some(String::new())), None);
assert_eq!(non_blank_token(Some(" \n".to_string())), None);
assert_eq!(non_blank_token(Some(" ghp_x ".to_string())).as_deref(), Some("ghp_x"));
}
#[test]
fn the_interval_defaults_and_minimum_depend_on_the_token() {
assert_eq!(default_interval_secs(true), 120);
assert_eq!(default_interval_secs(false), 1800);
assert_eq!(clamp_interval_secs(10, true), 60);
assert_eq!(clamp_interval_secs(10, false), 300);
assert_eq!(clamp_interval_secs(90, true), 90);
assert_eq!(clamp_interval_secs(90, false), 300);
assert_eq!(clamp_interval_secs(3600, false), 3600);
}
}
@@ -0,0 +1,97 @@
//! Falling back to polling alone when Redis is unreachable, and saying so without flooding the
//! log every tick.
use std::time::{Duration, Instant};
use log::{info, warn};
/// While Redis keeps failing, repeat the warning at most this often.
const WARN_EVERY: Duration = Duration::from_secs(10 * 60);
/// How this tick works.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PollMode {
/// With the fleet: shared releases, claims and backoff in Redis.
Coordinated,
/// Alone, from this instance's memory: coordination is off or Redis is unreachable. Same
/// interval and backoff rules, so a Redis outage never stops releases from refreshing.
Standalone,
}
/// Remembers Redis trouble so it is logged when it starts, every [`WARN_EVERY`] while it lasts,
/// and when it ends.
#[derive(Debug, Default)]
pub struct RedisTroubleLog {
failing_since: Option<Instant>,
last_warned: Option<Instant>,
suppressed: u32,
}
impl PollMode {
/// Coordinated only when coordination is on and the shared state could be read.
pub fn choose(coordination_enabled: bool, shared_state_readable: bool) -> Self {
if coordination_enabled && shared_state_readable { Self::Coordinated } else { Self::Standalone }
}
}
impl RedisTroubleLog {
/// Notes a Redis error; returns whether it was logged.
pub fn failed(&mut self, err: &anyhow::Error, now: Instant) -> bool {
self.failing_since.get_or_insert(now);
if self.last_warned.is_some_and(|last| now.duration_since(last) < WARN_EVERY) {
self.suppressed = self.suppressed.saturating_add(1);
return false;
}
warn!(
"Craft app releases: Redis unavailable ({:#}); polling GitHub on this instance alone until it is back ({} similar errors not logged).",
err, self.suppressed,
);
self.last_warned = Some(now);
self.suppressed = 0;
true
}
/// Notes that Redis works; logs the recovery once.
pub fn succeeded(&mut self, now: Instant) {
if let Some(since) = self.failing_since.take() {
info!("Craft app releases: Redis is back after {:?}; coordinating with the fleet again.", now.duration_since(since));
self.last_warned = None;
self.suppressed = 0;
}
}
pub fn is_failing(&self) -> bool {
self.failing_since.is_some()
}
}
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use super::*;
#[test]
fn coordinates_only_when_enabled_and_redis_answers() {
assert_eq!(PollMode::choose(true, true), PollMode::Coordinated);
assert_eq!(PollMode::choose(true, false), PollMode::Standalone);
assert_eq!(PollMode::choose(false, true), PollMode::Standalone);
assert_eq!(PollMode::choose(false, false), PollMode::Standalone);
}
#[test]
fn warns_once_per_window_while_redis_keeps_failing() {
let mut log = RedisTroubleLog::default();
let start = Instant::now();
let err = anyhow!("connection refused");
assert!(log.failed(&err, start));
assert!(!log.failed(&err, start + Duration::from_secs(30)));
assert!(!log.failed(&err, start + Duration::from_secs(599)));
assert!(log.failed(&err, start + Duration::from_secs(600)));
assert!(log.is_failing());
log.succeeded(start + Duration::from_secs(700));
assert!(!log.is_failing());
assert!(log.failed(&err, start + Duration::from_secs(730)), "a new outage warns right away");
}
}
@@ -0,0 +1,304 @@
//! One request to GitHub for an app's latest release, and reading the answer.
use std::time::Duration;
use chrono::{DateTime, Utc};
use wreq::header::{ACCEPT, AUTHORIZATION, ETAG, IF_NONE_MATCH, RETRY_AFTER, USER_AGENT};
use wreq::StatusCode;
use serde_derive::Deserialize;
use artcraft_api_defs::craft_apps::release_info::{
CraftAppArch, CraftAppAssetPlatform, CraftAppOs, CraftAppPackageType, CraftAppRelease, CraftAppReleaseAsset,
};
const API_BASE: &str = "https://api.github.com";
const MAX_NOTES_BYTES: usize = 64 * 1024;
const MAX_ASSETS: usize = 200;
const MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
/// What one request produced.
#[derive(Debug)]
pub enum FetchOutcome {
/// The latest release (`None`: the repository has no published release).
Fetched { release: Option<CraftAppRelease>, etag: Option<String> },
/// `If-None-Match` matched: what we have is current.
NotModified,
/// GitHub refused: out of quota until this time, if it said.
RateLimited { retry_at: Option<DateTime<Utc>> },
/// Network error, unexpected status or unreadable answer. Nothing is stored.
Failed(String),
}
pub fn build_client() -> wreq::Result<wreq::Client> {
wreq::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
}
/// `GET /repos/{repo}/releases/latest`, revalidated with `etag`. `token` is optional: without
/// one the request is unauthenticated.
pub async fn fetch_latest_release(client: &wreq::Client, repo: &str, etag: Option<&str>, token: Option<&str>) -> FetchOutcome {
let request = build_request(client, repo, etag, token);
let response = match request.send().await {
Ok(r) => r,
Err(err) => return FetchOutcome::Failed(format!("request failed: {err}")),
};
let status = response.status();
let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string);
match status {
StatusCode::NOT_MODIFIED => FetchOutcome::NotModified,
StatusCode::NOT_FOUND => FetchOutcome::Fetched { release: None, etag: None },
StatusCode::FORBIDDEN | StatusCode::TOO_MANY_REQUESTS => {
let remaining = header("x-ratelimit-remaining");
let reset = header("x-ratelimit-reset").and_then(|r| r.parse::<i64>().ok()).and_then(|s| DateTime::from_timestamp(s, 0));
let retry_after = header(RETRY_AFTER.as_str())
.and_then(|r| r.parse::<i64>().ok())
.map(|s| Utc::now() + chrono::Duration::seconds(s.clamp(0, 86_400)));
if status == StatusCode::TOO_MANY_REQUESTS || remaining.as_deref() == Some("0") || retry_after.is_some() {
FetchOutcome::RateLimited { retry_at: retry_after.or(reset) }
} else {
FetchOutcome::Failed(format!("HTTP {status}"))
}
}
StatusCode::OK => {
let etag = header(ETAG.as_str());
let body = match response.bytes().await {
Ok(b) if b.len() <= MAX_BODY_BYTES => b,
Ok(b) => return FetchOutcome::Failed(format!("answer too large ({} bytes)", b.len())),
Err(err) => return FetchOutcome::Failed(format!("unreadable answer: {err}")),
};
match parse_release(&body, repo) {
Ok(release) => FetchOutcome::Fetched { release: Some(release), etag },
Err(err) => FetchOutcome::Failed(err),
}
}
other => FetchOutcome::Failed(format!("HTTP {other}")),
}
}
/// The request for `repo`'s latest release. Sends `Authorization: Bearer` only for a non-blank
/// token (an authenticated `304` doesn't count against the rate limit); never an empty header.
fn build_request(client: &wreq::Client, repo: &str, etag: Option<&str>, token: Option<&str>) -> wreq::RequestBuilder {
let url = format!("{API_BASE}/repos/{repo}/releases/latest");
let mut request = client
.get(&url)
.header(USER_AGENT, "storyteller-web (craft apps release poller)")
.header(ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28");
if let Some(etag) = etag {
request = request.header(IF_NONE_MATCH, etag);
}
if let Some(token) = token.map(str::trim).filter(|t| !t.is_empty()) {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
request
}
/// The parts of GitHub's release object we use. Everything optional: GitHub's answer is input.
#[derive(Deserialize)]
struct GithubRelease {
#[serde(default)]
tag_name: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
html_url: Option<String>,
#[serde(default)]
published_at: Option<DateTime<Utc>>,
#[serde(default)]
prerelease: bool,
#[serde(default)]
draft: bool,
#[serde(default)]
body: Option<String>,
#[serde(default)]
assets: Vec<GithubAsset>,
}
#[derive(Deserialize)]
struct GithubAsset {
#[serde(default)]
name: Option<String>,
#[serde(default)]
size: u64,
#[serde(default)]
browser_download_url: Option<String>,
/// `sha256:<hex>` on assets uploaded since mid-2025.
#[serde(default)]
digest: Option<String>,
}
/// Reads a release, keeping only downloads that really belong to `repo`.
pub fn parse_release(body: &[u8], repo: &str) -> Result<CraftAppRelease, String> {
let raw: GithubRelease = serde_json::from_slice(body).map_err(|e| format!("unreadable release: {e}"))?;
if raw.draft {
return Err("the latest release is a draft".into());
}
let tag = raw.tag_name.filter(|t| !t.is_empty() && t.len() <= 64).ok_or("the release has no tag")?;
let releases_page = format!("https://github.com/{repo}/releases/");
let html_url = raw.html_url.filter(|u| u.starts_with(&releases_page)).unwrap_or_else(|| format!("{releases_page}tag/{tag}"));
let download_prefix = format!("https://github.com/{repo}/releases/download/");
let assets = raw
.assets
.into_iter()
.filter_map(|a| {
let name = a.name.filter(|n| valid_file_name(n))?;
let download_url = a.browser_download_url.filter(|u| u.starts_with(&download_prefix))?;
let sha256 = a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit())).map(str::to_ascii_lowercase);
let platform = parse_platform(&name);
Some(CraftAppReleaseAsset { name, size_bytes: a.size, download_url, sha256, platform })
})
.take(MAX_ASSETS)
.collect();
Ok(CraftAppRelease {
version: tag.strip_prefix(['v', 'V']).unwrap_or(&tag).to_string(),
name: raw.name.filter(|n| !n.is_empty()).map(|n| truncate(&n, 200)).unwrap_or_else(|| tag.clone()),
tag,
published_at: raw.published_at,
html_url,
prerelease: raw.prerelease,
notes_markdown: raw.body.map(|b| truncate(&b, MAX_NOTES_BYTES)).unwrap_or_default(),
assets,
})
}
/// Reads `<app>-<version>-<os>-<arch>[-portable].<ext>`, `<app>-cli-<version>-macos-universal.zip`
/// and `<app>-web-<version>.zip`. `None` for anything else (checksums, unknown files).
pub fn parse_platform(name: &str) -> Option<CraftAppAssetPlatform> {
if name.contains("-web-") && name.ends_with(".zip") {
return Some(CraftAppAssetPlatform { os: CraftAppOs::Web, arch: None, package_type: CraftAppPackageType::WebZip });
}
const KINDS: [(&str, CraftAppPackageType); 10] = [
("-portable.zip", CraftAppPackageType::PortableZip),
(".AppImage.zsync", CraftAppPackageType::AppImageZsync),
(".AppImage", CraftAppPackageType::AppImage),
(".tar.gz", CraftAppPackageType::TarGz),
(".msi", CraftAppPackageType::Msi),
(".dmg", CraftAppPackageType::Dmg),
(".deb", CraftAppPackageType::Deb),
(".rpm", CraftAppPackageType::Rpm),
(".flatpak", CraftAppPackageType::Flatpak),
(".zip", CraftAppPackageType::CliZip),
];
let (base, package_type) = KINDS.iter().find_map(|(suffix, kind)| name.strip_suffix(suffix).map(|b| (b, *kind)))?;
let mut parts = base.rsplitn(3, '-');
let (arch, os) = (parts.next()?, parts.next()?);
let os = match os {
"windows" => CraftAppOs::Windows,
"macos" => CraftAppOs::Macos,
"linux" => CraftAppOs::Linux,
"freebsd" => CraftAppOs::Freebsd,
_ => return None,
};
let arch = match arch {
"x64" | "x86_64" => CraftAppArch::X64,
"x86" => CraftAppArch::X86,
"arm64" | "aarch64" => CraftAppArch::Arm64,
"universal" => CraftAppArch::Universal,
_ => return None,
};
// A bare `.zip` is only the macOS command-line tools.
if package_type == CraftAppPackageType::CliZip && !(os == CraftAppOs::Macos && base.contains("-cli-")) {
return None;
}
Some(CraftAppAssetPlatform { os, arch: Some(arch), package_type })
}
fn valid_file_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 200
&& !name.starts_with('.')
&& !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*'))
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
const REPO: &str = "storytold/photocraft";
const HASH: &str = "8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1";
fn release_json(download_url: &str) -> String {
format!(
r#"{{"tag_name":"v0.5.0","name":"PhotoCraft v0.5.0","draft":false,"prerelease":false,
"html_url":"https://github.com/storytold/photocraft/releases/tag/v0.5.0",
"published_at":"2026-10-08T14:52:50Z","body":"Notes","unknown_field":[1,2,3],
"assets":[{{"name":"photocraft-0.5.0-windows-x64.msi","size":7,"browser_download_url":"{download_url}","digest":"sha256:{HASH}"}}]}}"#
)
}
#[test]
fn sends_the_token_as_a_bearer_header() {
let client = build_client().unwrap();
let request = build_request(&client, REPO, Some("\"etag\""), Some("ghp_x")).build().unwrap();
assert_eq!(request.headers().get(AUTHORIZATION).unwrap(), "Bearer ghp_x");
assert_eq!(request.headers().get(IF_NONE_MATCH).unwrap(), "\"etag\"");
}
#[test]
fn without_a_token_the_request_is_unauthenticated() {
let client = build_client().unwrap();
for token in [None, Some(""), Some(" ")] {
let request = build_request(&client, REPO, None, token).build().unwrap();
assert!(request.headers().get(AUTHORIZATION).is_none(), "{:?}", token);
assert!(request.headers().get(IF_NONE_MATCH).is_none());
assert_eq!(request.uri().to_string(), "https://api.github.com/repos/storytold/photocraft/releases/latest");
}
}
#[test]
fn reads_a_release() {
let r = parse_release(release_json("https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-windows-x64.msi").as_bytes(), REPO).unwrap();
assert_eq!((r.tag.as_str(), r.version.as_str(), r.name.as_str()), ("v0.5.0", "0.5.0", "PhotoCraft v0.5.0"));
assert_eq!(r.assets.len(), 1);
assert_eq!(r.assets[0].sha256.as_deref(), Some(HASH));
let p = r.assets[0].platform.as_ref().unwrap();
assert_eq!((p.os, p.arch, p.package_type), (CraftAppOs::Windows, Some(CraftAppArch::X64), CraftAppPackageType::Msi));
}
#[test]
fn drops_downloads_that_point_elsewhere_and_rejects_garbage() {
for url in ["https://evil.example/x.msi", "https://github.com/storytold/other/releases/download/v1/x.msi"] {
assert!(parse_release(release_json(url).as_bytes(), REPO).unwrap().assets.is_empty(), "{url}");
}
assert!(parse_release(b"<html>", REPO).is_err());
assert!(parse_release(br#"{"assets":[]}"#, REPO).is_err());
assert!(parse_release(br#"{"tag_name":"v1.0.0","draft":true}"#, REPO).is_err());
}
#[test]
fn reads_every_asset_name_the_suite_publishes() {
let cases = [
("photocraft-0.5.0-macos-universal.dmg", Some((CraftAppOs::Macos, Some(CraftAppArch::Universal), CraftAppPackageType::Dmg))),
("photocraft-cli-0.5.0-macos-universal.zip", Some((CraftAppOs::Macos, Some(CraftAppArch::Universal), CraftAppPackageType::CliZip))),
("photocraft-0.5.0-windows-arm64-portable.zip", Some((CraftAppOs::Windows, Some(CraftAppArch::Arm64), CraftAppPackageType::PortableZip))),
("photocraft-0.5.0-windows-x86.msi", Some((CraftAppOs::Windows, Some(CraftAppArch::X86), CraftAppPackageType::Msi))),
("photocraft-0.5.0-linux-aarch64.AppImage", Some((CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::AppImage))),
("photocraft-0.5.0-linux-x86_64.AppImage.zsync", Some((CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::AppImageZsync))),
("photocraft-0.5.0-linux-x86_64.deb", Some((CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Deb))),
("photocraft-0.5.0-linux-x86_64.rpm", Some((CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Rpm))),
("photocraft-0.5.0-linux-x86_64.flatpak", Some((CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak))),
("photocraft-0.5.0-freebsd-x86_64.tar.gz", Some((CraftAppOs::Freebsd, Some(CraftAppArch::X64), CraftAppPackageType::TarGz))),
("photocraft-0.2.0-rc.1-linux-x86_64.tar.gz", Some((CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::TarGz))),
("photocraft-web-0.5.0.zip", Some((CraftAppOs::Web, None, CraftAppPackageType::WebZip))),
("SHA256SUMS.txt", None),
("photocraft-0.5.0-plan9-x64.msi", None),
];
for (name, want) in cases {
let got = parse_platform(name).map(|p| (p.os, p.arch, p.package_type));
assert_eq!(got, want, "{name}");
}
}
}
@@ -0,0 +1,7 @@
pub mod config;
pub mod fallback;
pub mod github;
pub mod poll_craft_app_releases;
pub mod scheduling;
pub mod shared_entry;
pub mod shared_store;
@@ -0,0 +1,469 @@
//! Keeps [`CraftAppsReleaseCache`] holding the latest release of every Craft App, which
//! `GET /v1/craft_apps/release_info` serves to ArtCraft Launcher (so thousands of launchers don't
//! each poll GitHub).
//!
//! Every instance runs this loop, waking every [`TICK`] (30 s, with jitter). At startup it first
//! takes the fleet's shared releases from Redis, so a new instance serves them at once without
//! asking GitHub. With coordination (the default) the fleet shares its work through Redis
//! (`shared_entry` documents the keys):
//!
//! 1. Read every app's shared entry and the fleet's backoff; take anything newer into memory.
//! The endpoint always serves memory, so it keeps answering whatever Redis does.
//! 2. If the fleet is backing off (GitHub rate limited us, or a whole round failed), stop.
//! 3. For each app that is due (its shared fetch is `interval - CLOCK_SKEW_MARGIN` old, and any
//! failure retry has waited), read its entry again (another instance may just have fetched
//! it), claim it so instances ticking at the same moment don't all ask, then ask GitHub with
//! the shared ETag and share the answer. Shared writes never go back in time.
//! 4. After a round with requests, update the fleet's backoff (a good round clears it).
//!
//! Each app is all-or-nothing: either its latest release is stored with a fresh
//! `app_last_fetched`, or nothing about it changes.
//!
//! If Redis is unreachable (or coordination is off) the instance does the same from its own
//! memory, as every instance did before coordination, and returns to coordinating when Redis
//! answers again. Nothing here panics; trouble is logged and retried.
use std::time::{Duration, Instant};
use chrono::Utc;
use log::{error, info, warn};
use crate::state::craft_apps_release_cache::CraftAppsReleaseCache;
use crate::threads::poll_craft_app_releases_thread::config::CraftAppsPollConfig;
use crate::threads::poll_craft_app_releases_thread::fallback::{PollMode, RedisTroubleLog};
use crate::threads::poll_craft_app_releases_thread::github;
use crate::threads::poll_craft_app_releases_thread::github::FetchOutcome;
use crate::threads::poll_craft_app_releases_thread::scheduling::{backoff_after, is_due, jittered, Backoff, RoundResult, TICK};
use crate::threads::poll_craft_app_releases_thread::shared_entry::{SharedAppEntry, SharedFailure};
use crate::threads::poll_craft_app_releases_thread::shared_store::{PollerRedisPool, SharedStore};
/// Spreads a fleet's first tick over this window, so a deploy doesn't hit GitHub all at once.
const STARTUP_JITTER: Duration = Duration::from_secs(15);
/// Pause between the requests of one round.
const BETWEEN_REQUESTS: Duration = Duration::from_millis(1_000);
pub async fn poll_craft_app_releases(cache: CraftAppsReleaseCache, config: CraftAppsPollConfig, redis_pool: PollerRedisPool, hostname: String) {
cache.register_apps(&config.apps);
if !config.enabled {
info!("Craft app release polling is disabled (CRAFT_APPS_RELEASE_POLLING_ENABLED=false).");
return;
}
let client = match github::build_client() {
Ok(client) => client,
Err(err) => {
error!("Craft app release polling can't start: no HTTP client: {:?}", err);
return;
}
};
info!(
"Polling GitHub for {} craft app releases, each at most every {:?} (GitHub token: {}; coordinated through Redis: {}).",
config.apps.len(), config.interval, config.github_token.is_some(), config.coordination_enabled,
);
let store = config.coordination_enabled.then(|| SharedStore::new(redis_pool, hostname));
let mut poller = Poller::new(client, cache, config, store);
// Warm memory from the fleet's shared copy right away: it costs no GitHub quota, and the
// jitter below only needs to spread out requests to GitHub.
if poller.refresh_from_shared().await {
info!("Craft app releases: took the fleet's shared releases from Redis at startup.");
}
tokio::time::sleep(jittered(STARTUP_JITTER, 1.0)).await;
loop {
poller.tick().await;
tokio::time::sleep(jittered(TICK, 0.1)).await;
}
}
/// The polling loop's state.
struct Poller {
client: wreq::Client,
cache: CraftAppsReleaseCache,
config: CraftAppsPollConfig,
/// `None` when coordination is off.
store: Option<SharedStore>,
/// The backoff this instance knows: the fleet's while Redis answers, its own otherwise.
backoff: Backoff,
redis_log: RedisTroubleLog,
}
/// What one tick did.
#[derive(Debug)]
struct TickReport {
mode: PollMode,
round: RoundResult,
}
/// Whether to ask GitHub for an app, after looking at its shared entry once more.
enum SharedCheck {
Ask,
/// Fresh after all, or another instance is asking right now.
Skip,
/// The fleet started backing off.
StopRound,
}
impl Poller {
fn new(client: wreq::Client, cache: CraftAppsReleaseCache, config: CraftAppsPollConfig, store: Option<SharedStore>) -> Self {
Self { client, cache, config, store, backoff: Backoff::default(), redis_log: RedisTroubleLog::default() }
}
async fn tick(&mut self) -> TickReport {
let shared_state_readable = self.refresh_from_shared().await;
let mode = PollMode::choose(self.store.is_some(), shared_state_readable);
let mut report = TickReport { mode, round: RoundResult::default() };
let now = Utc::now();
if self.backoff.is_active(now) {
return report;
}
let due: Vec<(String, String)> = self
.config
.apps
.iter()
.filter(|(key, _, _)| self.cache.app(key).is_some_and(|app| is_due(&app, now, self.config.interval)))
.map(|(key, _, repo)| (key.clone(), repo.clone()))
.collect();
if due.is_empty() {
return report;
}
let (round, mode) = self.poll_round(&due, mode).await;
report.round = round;
report.mode = mode;
if report.round.requests() == 0 {
return report;
}
self.update_backoff(&report.round, mode).await;
info!(
"Craft app releases ({:?}): {} fetched, {} unchanged, {} failed{}{}.",
mode, report.round.fetched, report.round.unchanged, report.round.failed,
if report.round.rate_limited.is_some() { ", rate limited" } else { "" },
self.backoff.until.map(|until| format!("; backing off until {until}")).unwrap_or_default(),
);
report
}
/// Takes the fleet's shared state into memory. False when coordination is off or Redis
/// couldn't be read (this tick then works alone).
async fn refresh_from_shared(&mut self) -> bool {
let Some(store) = self.store.clone() else {
return false;
};
let keys: Vec<String> = self.config.apps.iter().map(|(key, _, _)| key.clone()).collect();
match store.read(&keys).await {
Ok((entries, backoff)) => {
self.redis_log.succeeded(Instant::now());
for (key, entry) in keys.iter().zip(entries) {
if let Some(entry) = entry {
merge_shared_entry(&self.cache, key, entry);
}
}
self.backoff = backoff;
true
}
Err(err) => {
self.redis_log.failed(&err, Instant::now());
false
}
}
}
/// Asks GitHub about each due app. Returns what happened and the mode it ended in (a Redis
/// error mid-round finishes the round alone).
async fn poll_round(&mut self, due: &[(String, String)], mut mode: PollMode) -> (RoundResult, PollMode) {
let mut round = RoundResult::default();
for (key, repo) in due {
if let Some(store) = self.coordinated_store(mode) {
match self.check_shared(&store, key).await {
Ok(SharedCheck::Ask) => {}
Ok(SharedCheck::Skip) => continue,
Ok(SharedCheck::StopRound) => break,
Err(err) => {
self.redis_log.failed(&err, Instant::now());
mode = PollMode::Standalone;
}
}
}
if round.requests() > 0 {
tokio::time::sleep(BETWEEN_REQUESTS).await;
}
let etag = self.cache.etag(key);
let outcome = github::fetch_latest_release(&self.client, repo, etag.as_deref(), self.config.github_token.as_deref()).await;
let now = Utc::now();
match outcome {
FetchOutcome::Fetched { release, etag } => {
round.fetched += 1;
if let Some(store) = self.coordinated_store(mode) {
let shared = store.write_fetched(key, release.as_ref(), etag.as_deref(), now).await;
mode = self.after_shared_write(shared, mode);
}
self.cache.store(key, release, etag, now);
}
FetchOutcome::NotModified => {
round.unchanged += 1;
self.cache.confirm_unchanged(key, now);
if let (Some(store), Some(etag)) = (self.coordinated_store(mode), etag.as_deref()) {
let shared = store.write_unchanged(key, etag, now).await;
mode = self.after_shared_write(shared, mode);
}
}
FetchOutcome::RateLimited { retry_at } => {
warn!("GitHub rate limit reached polling {repo}; retry after {:?}.", retry_at);
round.rate_limited = Some(retry_at);
// The rest of the round would be refused too.
break;
}
FetchOutcome::Failed(why) => {
warn!("Couldn't fetch the latest release of {repo}: {why}");
round.failed += 1;
let consecutive_failures = self.cache.record_failure(key, now);
if let Some(store) = self.coordinated_store(mode) {
let failure = SharedFailure { failed_at: now, consecutive_failures };
let shared = store.write_failure(key, failure).await;
mode = self.after_shared_write(shared, mode);
}
}
}
}
(round, mode)
}
/// Right before asking GitHub about `key`: takes its latest shared entry, and claims it if it
/// is still due and the fleet isn't backing off.
async fn check_shared(&mut self, store: &SharedStore, key: &str) -> anyhow::Result<SharedCheck> {
let (mut entries, backoff) = store.read(&[key.to_string()]).await?;
if let Some(entry) = entries.pop().flatten() {
merge_shared_entry(&self.cache, key, entry);
}
self.backoff = backoff;
let now = Utc::now();
if backoff.is_active(now) {
return Ok(SharedCheck::StopRound);
}
if !self.cache.app(key).is_some_and(|app| is_due(&app, now, self.config.interval)) {
return Ok(SharedCheck::Skip);
}
Ok(if store.claim(key, now).await? { SharedCheck::Ask } else { SharedCheck::Skip })
}
/// Sets the backoff after a round with requests, and shares it if it changed.
async fn update_backoff(&mut self, round: &RoundResult, mode: PollMode) {
let previous = self.backoff;
self.backoff = backoff_after(&self.config, round, previous, Utc::now());
if self.backoff == previous {
return;
}
if let Some(store) = self.coordinated_store(mode) {
if let Err(err) = store.write_backoff(self.backoff).await {
self.redis_log.failed(&err, Instant::now());
}
}
}
/// Logs a failed shared write; the rest of the round then works alone.
fn after_shared_write(&mut self, result: anyhow::Result<bool>, mode: PollMode) -> PollMode {
match result {
Ok(_) => mode,
Err(err) => {
self.redis_log.failed(&err, Instant::now());
PollMode::Standalone
}
}
}
/// The shared store, in [`PollMode::Coordinated`].
fn coordinated_store(&self, mode: PollMode) -> Option<SharedStore> {
self.store.clone().filter(|_| mode == PollMode::Coordinated)
}
}
/// Takes a shared entry into memory (only what is newer than what this instance has).
fn merge_shared_entry(cache: &CraftAppsReleaseCache, key: &str, entry: SharedAppEntry) {
if let Some(fetched) = entry.fetched {
cache.merge_fetched(key, fetched.latest_release, fetched.etag, fetched.fetched_at);
}
if let Some(failure) = entry.failure {
cache.merge_failure(key, failure.failed_at, failure.consecutive_failures);
}
}
#[cfg(test)]
mod tests {
use chrono::DateTime;
use redis_schema::keys::craft_apps::craft_app_release_redis_key::CraftAppReleaseRedisKey;
use crate::threads::poll_craft_app_releases_thread::config::default_apps;
use crate::threads::poll_craft_app_releases_thread::shared_entry::{decode_app_entry, encode_fetch};
use crate::threads::poll_craft_app_releases_thread::shared_store::build_poller_redis_pool;
use super::*;
/// Live: real rounds against GitHub (two apps), then the endpoint's response, written to
/// `CRAFT_APPS_SAMPLE_OUT` when set (ArtCraft Launcher's parser reads it back). Uses
/// `CRAFT_APPS_RELEASE_GITHUB_TOKEN` if set, and works without it.
/// `cargo test -p storyteller-web --bin storyteller-web live_round -- --ignored`
#[tokio::test]
#[ignore = "needs network access"]
async fn live_round_fetches_real_releases() {
use artcraft_api_defs::craft_apps::release_info::{CraftAppsImportantNotices, GetCraftAppsReleaseInfoResponse};
let mut config = config();
config.github_token = std::env::var("CRAFT_APPS_RELEASE_GITHUB_TOKEN").ok().filter(|t| !t.trim().is_empty());
config.apps.retain(|(key, _, _)| key == "photocraft" || key == "artcraft-launcher");
let cache = CraftAppsReleaseCache::new();
cache.register_apps(&config.apps);
let mut poller = Poller::new(github::build_client().unwrap(), cache.clone(), config.clone(), None);
let due: Vec<(String, String)> = config.apps.iter().map(|(k, _, r)| (k.clone(), r.clone())).collect();
let (round, _) = poller.poll_round(&due, PollMode::Standalone).await;
assert_eq!(round.fetched + round.unchanged, 2, "{round:?}");
// A second round revalidates with ETags.
let (again, _) = poller.poll_round(&due, PollMode::Standalone).await;
assert_eq!(again.fetched + again.unchanged, 2, "{again:?}");
let info = cache.snapshot();
assert!(info.last_fetched.is_some());
let photocraft = info.apps["photocraft"].latest_release.as_ref().unwrap();
assert!(photocraft.assets.iter().any(|a| a.platform.is_some() && a.sha256.is_some()));
let response = GetCraftAppsReleaseInfoResponse {
success: true,
release_info: info,
app_renames: Vec::new(),
repo_renames: Vec::new(),
important_notices: CraftAppsImportantNotices::default(),
};
let json = serde_json::to_string_pretty(&response).unwrap();
if let Ok(path) = std::env::var("CRAFT_APPS_SAMPLE_OUT") {
std::fs::write(path, &json).unwrap();
}
}
/// Against a real Redis (`CRAFT_APPS_TEST_REDIS_URL`, default `redis://127.0.0.1:6379/`): an
/// instance takes another's fresh fetches from Redis instead of asking GitHub.
/// `cargo test -p storyteller-web --bin storyteller-web takes_fresh -- --ignored`
#[tokio::test]
#[ignore = "needs a Redis server"]
async fn takes_fresh_releases_from_redis_instead_of_github() {
let url = std::env::var("CRAFT_APPS_TEST_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/".to_string());
let pool = build_poller_redis_pool(&url).unwrap();
let mut config = config();
let prefix = format!("test-craft-{}", rand::random::<u32>());
config.apps = vec![(format!("{prefix}-a"), "A".into(), "storytold/a".into()), (format!("{prefix}-b"), "B".into(), "storytold/b".into())];
let pod_a = SharedStore::new(pool.clone(), "pod-a".into());
let now = Utc::now();
for (key, _, _) in &config.apps {
assert!(pod_a.write_fetched(key, None, Some("\"e\""), now).await.unwrap());
}
let cache = CraftAppsReleaseCache::new();
cache.register_apps(&config.apps);
let mut pod_b = Poller::new(github::build_client().unwrap(), cache.clone(), config.clone(), Some(SharedStore::new(pool.clone(), "pod-b".into())));
let report = pod_b.tick().await;
assert_eq!(report.mode, PollMode::Coordinated);
assert_eq!(report.round.requests(), 0, "fresh in Redis, so GitHub isn't asked");
for (key, _, _) in &config.apps {
assert_eq!(fetched_at(&cache, key).map(|t| t.timestamp_millis()), Some(now.timestamp_millis()));
assert_eq!(cache.etag(key).as_deref(), Some("\"e\""));
}
let keys: Vec<String> = config.apps.iter().map(|(k, _, _)| CraftAppReleaseRedisKey::new_for_app(k).to_string()).collect();
let mut conn = pool.get().unwrap();
redis::cmd("DEL").arg(&keys).query::<()>(&mut *conn).unwrap();
}
/// Against a real Redis, as above: a new instance serves the fleet's releases right after
/// startup, before its startup jitter ends, without asking GitHub.
/// `cargo test -p storyteller-web --bin storyteller-web warm -- --ignored`
#[tokio::test]
#[ignore = "needs a Redis server"]
async fn a_new_instance_is_warm_from_redis_at_startup() {
let url = std::env::var("CRAFT_APPS_TEST_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/".to_string());
let pool = build_poller_redis_pool(&url).unwrap();
let mut config = config();
let prefix = format!("test-craft-{}", rand::random::<u32>());
config.apps = vec![(format!("{prefix}-a"), "A".into(), "storytold/a".into()), (format!("{prefix}-b"), "B".into(), "storytold/b".into())];
let fetched_by_another_pod = Utc::now();
let other_pod = SharedStore::new(pool.clone(), "other-pod".into());
for (key, _, _) in &config.apps {
assert!(other_pod.write_fetched(key, None, Some("\"e\""), fetched_by_another_pod).await.unwrap());
}
let cache = CraftAppsReleaseCache::new();
let poller = tokio::spawn(poll_craft_app_releases(cache.clone(), config.clone(), pool.clone(), "new-pod".into()));
let deadline = Instant::now() + Duration::from_secs(2);
while config.apps.iter().any(|(key, _, _)| fetched_at(&cache, key).is_none()) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
poller.abort();
for (key, _, _) in &config.apps {
assert_eq!(fetched_at(&cache, key).map(|t| t.timestamp_millis()), Some(fetched_by_another_pod.timestamp_millis()), "{}", key);
}
let keys: Vec<String> = config.apps.iter().map(|(k, _, _)| CraftAppReleaseRedisKey::new_for_app(k).to_string()).collect();
let mut conn = pool.get().unwrap();
redis::cmd("DEL").arg(&keys).query::<()>(&mut *conn).unwrap();
}
#[tokio::test]
async fn an_unreachable_redis_falls_back_to_polling_alone() {
let pool = build_poller_redis_pool("redis://127.0.0.1:1/").unwrap();
let mut poller = fresh_poller(Some(SharedStore::new(pool, "pod".into())));
let report = poller.tick().await;
assert_eq!(report.mode, PollMode::Standalone);
assert_eq!(report.round.requests(), 0, "everything was fresh");
assert!(poller.redis_log.is_failing());
}
#[tokio::test]
async fn with_coordination_off_redis_is_never_used() {
let mut poller = fresh_poller(None);
let report = poller.tick().await;
assert_eq!(report.mode, PollMode::Standalone);
assert!(!poller.redis_log.is_failing());
}
#[tokio::test]
async fn an_active_backoff_skips_github_even_when_apps_are_due() {
let mut poller = Poller::new(github::build_client().unwrap(), registered_cache(), config(), None);
poller.backoff = Backoff { until: Some(Utc::now() + chrono::Duration::minutes(5)), trouble: 1 };
let report = poller.tick().await;
assert_eq!(report.round.requests(), 0);
assert_eq!(fetched_at(&poller.cache, "photocraft"), None);
}
#[test]
fn shared_entries_merge_into_memory() {
let cache = registered_cache();
let at = Utc::now();
let fields = encode_fetch(None, Some("\"e\""), at, "pod").unwrap();
let map = fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
merge_shared_entry(&cache, "photocraft", decode_app_entry(&map).unwrap());
assert_eq!(fetched_at(&cache, "photocraft").map(|t| t.timestamp_millis()), Some(at.timestamp_millis()));
assert_eq!(cache.etag("photocraft").as_deref(), Some("\"e\""));
}
fn config() -> CraftAppsPollConfig {
CraftAppsPollConfig {
enabled: true,
coordination_enabled: true,
interval: Duration::from_secs(1800),
max_backoff: Duration::from_secs(14_400),
github_token: None,
apps: default_apps(),
}
}
fn registered_cache() -> CraftAppsReleaseCache {
let cache = CraftAppsReleaseCache::new();
cache.register_apps(&config().apps);
cache
}
/// A poller whose apps were all just fetched, so a tick makes no GitHub requests.
fn fresh_poller(store: Option<SharedStore>) -> Poller {
let cache = registered_cache();
for (key, _, _) in &config().apps {
cache.store(key, None, None, Utc::now());
}
Poller::new(github::build_client().unwrap(), cache, config(), store)
}
fn fetched_at(cache: &CraftAppsReleaseCache, app_key: &str) -> Option<DateTime<Utc>> {
cache.app(app_key).and_then(|app| app.app_last_fetched)
}
}
@@ -0,0 +1,286 @@
//! When to ask GitHub: per-app freshness, retries after failures, and the backoff after rate
//! limits. Pure functions of what's known, so the same rules apply whether this instance works
//! from the fleet's shared state in Redis or (as a fallback) only from its own memory.
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::state::craft_apps_release_cache::CachedCraftApp;
use crate::threads::poll_craft_app_releases_thread::config::CraftAppsPollConfig;
/// How often every instance wakes up to look at the shared state (with jitter).
pub const TICK: Duration = Duration::from_secs(30);
/// Allowance for clocks that differ between instances: an app fetched by another instance
/// counts as fresh until `interval - CLOCK_SKEW_MARGIN` has passed.
pub const CLOCK_SKEW_MARGIN: Duration = Duration::from_secs(30);
/// First wait after a failed request (or a round where every request failed); doubles up to the
/// normal interval.
const FIRST_FAILURE_RETRY: Duration = Duration::from_secs(60);
/// Extra margin after GitHub's announced rate-limit reset.
const RESET_MARGIN: Duration = Duration::from_secs(60);
/// What one round did.
#[derive(Debug, Default)]
pub struct RoundResult {
pub fetched: usize,
pub unchanged: usize,
pub failed: usize,
/// GitHub refused; `Some(Some(t))` when it said when to come back.
pub rate_limited: Option<Option<DateTime<Utc>>>,
}
/// Don't ask GitHub before `until`. `trouble` counts the rounds in a row that were rate limited
/// or failed entirely; each one makes the next wait longer.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Backoff {
pub until: Option<DateTime<Utc>>,
pub trouble: u32,
}
impl RoundResult {
/// How many requests reached a conclusion (a rate limit counts as one).
pub fn requests(&self) -> usize {
self.fetched + self.unchanged + self.failed + usize::from(self.rate_limited.is_some())
}
}
impl Backoff {
pub fn is_active(&self, now: DateTime<Utc>) -> bool {
self.until.is_some_and(|until| until > now)
}
}
/// Whether `app` should be requested from GitHub now. An app is due once its last fetch (by any
/// instance) is `interval - CLOCK_SKEW_MARGIN` old. After a failure it waits a minute, then two,
/// four, … up to the interval, so one broken repository is never retried every tick.
pub fn is_due(app: &CachedCraftApp, now: DateTime<Utc>, interval: Duration) -> bool {
if let Some(fetched) = app.app_last_fetched {
if age(fetched, now) < interval.saturating_sub(CLOCK_SKEW_MARGIN) {
return false;
}
}
if app.is_failing() {
if let Some(failed) = app.last_failed_at {
let retry = scaled(FIRST_FAILURE_RETRY, app.consecutive_failures).min(interval);
if age(failed, now) < retry.saturating_sub(CLOCK_SKEW_MARGIN) {
return false;
}
}
}
true
}
/// The backoff after a round that made at least one request, starting from `previous`. A good
/// round clears it.
pub fn backoff_after(config: &CraftAppsPollConfig, round: &RoundResult, previous: Backoff, now: DateTime<Utc>) -> Backoff {
let mut trouble = previous.trouble;
let wait = next_wait(config, round, &mut trouble, now);
if trouble == 0 {
return Backoff::default();
}
let wait = chrono::Duration::from_std(wait).unwrap_or(chrono::Duration::MAX);
Backoff { until: now.checked_add_signed(wait), trouble }
}
/// How long to wait before the next round. Rate limits wait for GitHub's reset and back off
/// further each time; rounds where everything failed retry sooner, but never faster than a
/// minute, doubling up to the normal interval.
fn next_wait(config: &CraftAppsPollConfig, round: &RoundResult, consecutive_trouble: &mut u32, now: DateTime<Utc>) -> Duration {
let base = if let Some(retry_at) = round.rate_limited {
*consecutive_trouble = consecutive_trouble.saturating_add(1);
let until_reset = retry_at
.and_then(|t| (t - now).to_std().ok())
.map(|d| d + RESET_MARGIN)
.unwrap_or(Duration::ZERO);
let backoff = scaled(config.interval, *consecutive_trouble);
until_reset.max(backoff).min(config.max_backoff)
} else if round.fetched == 0 && round.unchanged == 0 && round.failed > 0 {
*consecutive_trouble = consecutive_trouble.saturating_add(1);
scaled(FIRST_FAILURE_RETRY, *consecutive_trouble).min(config.interval)
} else {
*consecutive_trouble = 0;
config.interval
};
jittered(base, 0.1)
}
/// `base` ± `fraction`, so a fleet doesn't poll in lockstep. With `fraction == 1.0`, anywhere in
/// `0..=2×base`; used for the startup spread.
pub fn jittered(base: Duration, fraction: f64) -> Duration {
let factor = 1.0 + fraction * (rand::random::<f64>() * 2.0 - 1.0);
base.mul_f64(factor.max(0.0))
}
/// `base × 2^(n-1)`, without overflow.
fn scaled(base: Duration, n: u32) -> Duration {
base.saturating_mul(1u32 << n.saturating_sub(1).min(16))
}
/// How long ago `then` was; zero if it is in the future (another instance's clock runs ahead).
fn age(then: DateTime<Utc>, now: DateTime<Utc>) -> Duration {
(now - then).to_std().unwrap_or(Duration::ZERO)
}
#[cfg(test)]
mod tests {
use crate::threads::poll_craft_app_releases_thread::config::default_apps;
use super::*;
mod freshness {
use super::*;
#[test]
fn an_app_never_fetched_is_due() {
assert!(is_due(&app(None, None, 0), Utc::now(), Duration::from_secs(120)));
}
#[test]
fn a_fetch_counts_as_fresh_until_the_interval_minus_the_skew_margin() {
let now = Utc::now();
let interval = Duration::from_secs(120);
assert!(!is_due(&app(Some(now - secs(89)), None, 0), now, interval));
assert!(is_due(&app(Some(now - secs(90)), None, 0), now, interval));
assert!(is_due(&app(Some(now - secs(600)), None, 0), now, interval));
}
#[test]
fn a_fetch_from_a_clock_ahead_of_ours_is_fresh() {
let now = Utc::now();
assert!(!is_due(&app(Some(now + secs(20)), None, 0), now, Duration::from_secs(120)));
}
#[test]
fn a_failing_app_waits_longer_after_each_failure_up_to_the_interval() {
let now = Utc::now();
let interval = Duration::from_secs(1800);
// 1st failure: retry after 60 s (less the margin); 3rd: 240 s.
assert!(!is_due(&app(None, Some(now - secs(29)), 1), now, interval));
assert!(is_due(&app(None, Some(now - secs(30)), 1), now, interval));
assert!(!is_due(&app(None, Some(now - secs(200)), 3), now, interval));
assert!(is_due(&app(None, Some(now - secs(210)), 3), now, interval));
// Many failures: never longer than the interval.
assert!(is_due(&app(None, Some(now - secs(1770)), 30), now, interval));
}
#[test]
fn a_failure_older_than_the_last_fetch_is_ignored() {
let now = Utc::now();
let app = app(Some(now - secs(200)), Some(now - secs(300)), 5);
assert!(is_due(&app, now, Duration::from_secs(120)));
}
}
mod backoff {
use super::*;
#[test]
fn a_good_round_clears_the_backoff() {
let now = Utc::now();
let round = RoundResult { fetched: 1, unchanged: 3, failed: 1, ..RoundResult::default() };
let previous = Backoff { until: Some(now), trouble: 3 };
assert_eq!(backoff_after(&config(), &round, previous, now), Backoff::default());
}
#[test]
fn a_rate_limit_backs_off_the_fleet_until_after_the_reset() {
let now = Utc::now();
let round = RoundResult { rate_limited: Some(Some(now + chrono::Duration::minutes(50))), ..RoundResult::default() };
let b = backoff_after(&config(), &round, Backoff::default(), now);
assert_eq!(b.trouble, 1);
assert!(b.is_active(now + chrono::Duration::minutes(45)));
assert!(!b.is_active(now + chrono::Duration::minutes(57)));
}
#[test]
fn trouble_carries_over_between_rounds_and_instances() {
let now = Utc::now();
let round = RoundResult { failed: 2, ..RoundResult::default() };
let first = backoff_after(&config(), &round, Backoff::default(), now);
let second = backoff_after(&config(), &round, first, now);
assert_eq!((first.trouble, second.trouble), (1, 2));
assert!(second.until.unwrap() - now >= chrono::Duration::seconds(107), "{:?}", second);
}
#[test]
fn an_expired_backoff_is_not_active() {
let now = Utc::now();
assert!(!Backoff::default().is_active(now));
assert!(!Backoff { until: Some(now - secs(1)), trouble: 4 }.is_active(now));
assert!(Backoff { until: Some(now + secs(1)), trouble: 4 }.is_active(now));
}
}
mod waits {
use super::*;
#[test]
fn a_good_round_waits_the_interval_and_resets_trouble() {
let mut trouble = 3;
let round = RoundResult { fetched: 5, unchanged: 8, ..RoundResult::default() };
assert!(within(next_wait(&config(), &round, &mut trouble, Utc::now()), Duration::from_secs(1800)));
assert_eq!(trouble, 0);
}
#[test]
fn rate_limits_wait_for_the_reset_and_back_off_further_each_time() {
let now = Utc::now();
let mut trouble = 0;
// GitHub says come back in 50 minutes: wait that (plus margin), more than the interval.
let round = RoundResult { rate_limited: Some(Some(now + chrono::Duration::minutes(50))), ..RoundResult::default() };
assert!(within(next_wait(&config(), &round, &mut trouble, now), Duration::from_secs(51 * 60)));
// Again and again, with no reset time: 2×, 4×, … the interval, capped at the maximum.
let round = RoundResult { rate_limited: Some(None), ..RoundResult::default() };
assert!(within(next_wait(&config(), &round, &mut trouble, now), Duration::from_secs(3600)));
assert!(within(next_wait(&config(), &round, &mut trouble, now), Duration::from_secs(7200)));
for _ in 0..10 {
assert!(next_wait(&config(), &round, &mut trouble, now) <= Duration::from_secs(14_400).mul_f64(1.11));
}
}
#[test]
fn a_round_where_everything_failed_retries_sooner_but_not_in_a_tight_loop() {
let now = Utc::now();
let mut trouble = 0;
let round = RoundResult { failed: 13, ..RoundResult::default() };
assert!(within(next_wait(&config(), &round, &mut trouble, now), Duration::from_secs(60)));
assert!(within(next_wait(&config(), &round, &mut trouble, now), Duration::from_secs(120)));
for _ in 0..20 {
let w = next_wait(&config(), &round, &mut trouble, now);
assert!(w >= Duration::from_secs(53) && w <= Duration::from_secs(1800).mul_f64(1.11), "{:?}", w);
}
}
}
fn config() -> CraftAppsPollConfig {
CraftAppsPollConfig {
enabled: true,
coordination_enabled: true,
interval: Duration::from_secs(1800),
max_backoff: Duration::from_secs(14_400),
github_token: None,
apps: default_apps(),
}
}
fn app(fetched: Option<DateTime<Utc>>, failed: Option<DateTime<Utc>>, failures: u32) -> CachedCraftApp {
CachedCraftApp {
name: "PhotoCraft".into(),
repo: "storytold/photocraft".into(),
latest_release: None,
app_last_fetched: fetched,
etag: None,
last_failed_at: failed,
consecutive_failures: failures,
}
}
fn secs(n: i64) -> chrono::Duration {
chrono::Duration::seconds(n)
}
fn within(d: Duration, around: Duration) -> bool {
d >= around.mul_f64(0.89) && d <= around.mul_f64(1.11)
}
}
@@ -0,0 +1,267 @@
//! What the fleet shares in Redis about Craft App releases, and how it is encoded. Pure: the
//! Redis I/O is in `shared_store`.
//!
//! ## `craftApps:releases:app:<app key>` (hash, 7 day TTL, refreshed on every write)
//!
//! | Field | Meaning |
//! |--------------|--------------------------------------------------------------------------------|
//! | `v` | Format version, `1`. Entries with another version are ignored (and replaced) |
//! | `fetched_ms` | When the release was last fetched or confirmed by GitHub (Unix ms) |
//! | `etag` | GitHub's ETag for that answer; empty for none |
//! | `release` | The latest release as JSON (`CraftAppRelease`), `null` if the repo has none |
//! | `failed_ms` | When a request for the app last failed (Unix ms); stale once `fetched_ms` newer |
//! | `failures` | Failed requests in a row as of `failed_ms` |
//! | `claimed_ms` | When an instance last started a request for the app (Unix ms), to skip doubles |
//! | `by` | Hostname of the instance that wrote last, for debugging |
//!
//! ## `craftApps:releases:backoff` (hash, 1 day TTL; deleted after a good round)
//!
//! | Field | Meaning |
//! |------------|-----------------------------------------------------------------|
//! | `v` | Format version, `1` |
//! | `until_ms` | No instance asks GitHub before this time (Unix ms) |
//! | `trouble` | Rounds in a row that were rate limited or failed entirely |
//! | `by` | Hostname of the instance that wrote last, for debugging |
//!
//! Anything unreadable (wrong version, bad numbers, bad JSON) reads as absent, so it is
//! refetched and overwritten rather than trusted.
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use artcraft_api_defs::craft_apps::release_info::CraftAppRelease;
use crate::threads::poll_craft_app_releases_thread::scheduling::Backoff;
pub const FORMAT_VERSION: &str = "1";
pub const FIELD_VERSION: &str = "v";
pub const FIELD_FETCHED_MS: &str = "fetched_ms";
pub const FIELD_ETAG: &str = "etag";
pub const FIELD_RELEASE: &str = "release";
pub const FIELD_FAILED_MS: &str = "failed_ms";
pub const FIELD_FAILURES: &str = "failures";
pub const FIELD_CLAIMED_MS: &str = "claimed_ms";
pub const FIELD_BY: &str = "by";
pub const FIELD_UNTIL_MS: &str = "until_ms";
pub const FIELD_TROUBLE: &str = "trouble";
/// One app's shared entry, as read from Redis.
#[derive(Clone, Debug, Default)]
pub struct SharedAppEntry {
pub fetched: Option<SharedFetch>,
pub failure: Option<SharedFailure>,
}
/// A successful fetch (or `304` confirmation) by some instance.
#[derive(Clone, Debug)]
pub struct SharedFetch {
pub latest_release: Option<CraftAppRelease>,
pub etag: Option<String>,
pub fetched_at: DateTime<Utc>,
}
/// The last failed request by some instance.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SharedFailure {
pub failed_at: DateTime<Utc>,
pub consecutive_failures: u32,
}
/// Reads an app's hash. `None` for an empty hash or another format version.
pub fn decode_app_entry(fields: &HashMap<String, String>) -> Option<SharedAppEntry> {
if fields.get(FIELD_VERSION).map(String::as_str) != Some(FORMAT_VERSION) {
return None;
}
let fetched = decode_fetch(fields);
let failure = time_field(fields, FIELD_FAILED_MS).map(|failed_at| SharedFailure {
failed_at,
consecutive_failures: fields.get(FIELD_FAILURES).and_then(|f| f.parse().ok()).unwrap_or(1).max(1),
});
Some(SharedAppEntry { fetched, failure })
}
/// The fields written for a successful fetch.
pub fn encode_fetch(latest_release: Option<&CraftAppRelease>, etag: Option<&str>, fetched_at: DateTime<Utc>, by: &str) -> Result<Vec<(&'static str, String)>, serde_json::Error> {
Ok(vec![
(FIELD_VERSION, FORMAT_VERSION.to_string()),
(FIELD_FETCHED_MS, fetched_at.timestamp_millis().to_string()),
(FIELD_ETAG, etag.unwrap_or_default().to_string()),
(FIELD_RELEASE, serde_json::to_string(&latest_release)?),
(FIELD_BY, by.to_string()),
])
}
/// The fields written for a `304`: only the time moves.
pub fn encode_unchanged(fetched_at: DateTime<Utc>, by: &str) -> Vec<(&'static str, String)> {
vec![
(FIELD_VERSION, FORMAT_VERSION.to_string()),
(FIELD_FETCHED_MS, fetched_at.timestamp_millis().to_string()),
(FIELD_BY, by.to_string()),
]
}
/// The fields written for a failed request.
pub fn encode_failure(failure: SharedFailure, by: &str) -> Vec<(&'static str, String)> {
vec![
(FIELD_VERSION, FORMAT_VERSION.to_string()),
(FIELD_FAILED_MS, failure.failed_at.timestamp_millis().to_string()),
(FIELD_FAILURES, failure.consecutive_failures.to_string()),
(FIELD_BY, by.to_string()),
]
}
/// Reads the backoff hash; anything absent or unreadable is no backoff.
pub fn decode_backoff(fields: &HashMap<String, String>) -> Backoff {
if fields.get(FIELD_VERSION).map(String::as_str) != Some(FORMAT_VERSION) {
return Backoff::default();
}
Backoff {
until: time_field(fields, FIELD_UNTIL_MS),
trouble: fields.get(FIELD_TROUBLE).and_then(|t| t.parse().ok()).unwrap_or(0),
}
}
pub fn encode_backoff(backoff: Backoff, by: &str) -> Vec<(&'static str, String)> {
vec![
(FIELD_VERSION, FORMAT_VERSION.to_string()),
(FIELD_UNTIL_MS, backoff.until.map(|u| u.timestamp_millis()).unwrap_or(0).to_string()),
(FIELD_TROUBLE, backoff.trouble.to_string()),
(FIELD_BY, by.to_string()),
]
}
/// The fetch part of an app's hash; `None` unless the time, the ETag and the release all read.
fn decode_fetch(fields: &HashMap<String, String>) -> Option<SharedFetch> {
let fetched_at = time_field(fields, FIELD_FETCHED_MS)?;
let latest_release: Option<CraftAppRelease> = serde_json::from_str(fields.get(FIELD_RELEASE)?).ok()?;
let etag = fields.get(FIELD_ETAG)?;
Some(SharedFetch {
latest_release,
etag: (!etag.is_empty()).then(|| etag.clone()),
fetched_at,
})
}
/// A Unix-milliseconds field; `None` when absent, unreadable or zero.
fn time_field(fields: &HashMap<String, String>, name: &str) -> Option<DateTime<Utc>> {
let ms = fields.get(name)?.parse::<i64>().ok().filter(|ms| *ms > 0)?;
DateTime::from_timestamp_millis(ms)
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use super::*;
const BY: &str = "storyteller-web-abc";
mod app_entries {
use super::*;
#[test]
fn a_fetch_round_trips() {
let release = release();
let at = time();
let fields = to_map(encode_fetch(Some(&release), Some("\"e1\""), at, BY).unwrap());
let entry = decode_app_entry(&fields).unwrap();
let fetched = entry.fetched.unwrap();
assert_eq!((fetched.etag.as_deref(), fetched.fetched_at), (Some("\"e1\""), at));
assert_eq!(json(&fetched.latest_release), json(&Some(release)));
assert_eq!(entry.failure, None);
}
#[test]
fn a_repo_without_release_or_etag_round_trips() {
let fields = to_map(encode_fetch(None, None, time(), BY).unwrap());
let fetched = decode_app_entry(&fields).unwrap().fetched.unwrap();
assert!(fetched.latest_release.is_none());
assert_eq!(fetched.etag, None);
}
#[test]
fn a_304_moves_only_the_time() {
let mut fields = to_map(encode_fetch(Some(&release()), Some("\"e1\""), time(), BY).unwrap());
let later = time() + chrono::Duration::minutes(2);
fields.extend(to_map(encode_unchanged(later, BY)));
let fetched = decode_app_entry(&fields).unwrap().fetched.unwrap();
assert_eq!((fetched.fetched_at, fetched.etag.as_deref()), (later, Some("\"e1\"")));
assert_eq!(json(&fetched.latest_release), json(&Some(release())));
}
#[test]
fn a_failure_round_trips_alongside_a_fetch() {
let mut fields = to_map(encode_fetch(None, None, time(), BY).unwrap());
let failure = SharedFailure { failed_at: time() + chrono::Duration::minutes(1), consecutive_failures: 3 };
fields.extend(to_map(encode_failure(failure, BY)));
let entry = decode_app_entry(&fields).unwrap();
assert_eq!(entry.failure, Some(failure));
assert!(entry.fetched.is_some());
}
#[test]
fn garbage_reads_as_absent() {
assert!(decode_app_entry(&HashMap::new()).is_none());
let good = to_map(encode_fetch(Some(&release()), Some("\"e1\""), time(), BY).unwrap());
let with = |field: &str, value: &str| {
let mut fields = good.clone();
fields.insert(field.to_string(), value.to_string());
fields
};
assert!(decode_app_entry(&with(FIELD_VERSION, "2")).is_none(), "unknown version");
assert!(decode_app_entry(&with(FIELD_RELEASE, "{not json")).unwrap().fetched.is_none());
assert!(decode_app_entry(&with(FIELD_RELEASE, "{\"tag\":1}")).unwrap().fetched.is_none());
assert!(decode_app_entry(&with(FIELD_FETCHED_MS, "yesterday")).unwrap().fetched.is_none());
assert!(decode_app_entry(&with(FIELD_FAILED_MS, "-5")).unwrap().failure.is_none());
}
}
mod backoffs {
use super::*;
#[test]
fn a_backoff_round_trips() {
let backoff = Backoff { until: Some(time()), trouble: 4 };
assert_eq!(decode_backoff(&to_map(encode_backoff(backoff, BY))), backoff);
}
#[test]
fn garbage_reads_as_no_backoff() {
assert_eq!(decode_backoff(&HashMap::new()), Backoff::default());
let mut fields = to_map(encode_backoff(Backoff { until: Some(time()), trouble: 4 }, BY));
fields.insert(FIELD_VERSION.into(), "zz".into());
assert_eq!(decode_backoff(&fields), Backoff::default());
fields.insert(FIELD_VERSION.into(), FORMAT_VERSION.into());
fields.insert(FIELD_UNTIL_MS.into(), "soon".into());
fields.insert(FIELD_TROUBLE.into(), "-1".into());
assert_eq!(decode_backoff(&fields), Backoff::default());
}
}
fn time() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 10, 10, 12, 0, 0).unwrap()
}
fn release() -> CraftAppRelease {
CraftAppRelease {
tag: "v0.5.0".into(),
version: "0.5.0".into(),
name: "PhotoCraft v0.5.0".into(),
published_at: Some(time()),
html_url: "https://github.com/storytold/photocraft/releases/tag/v0.5.0".into(),
prerelease: false,
notes_markdown: "Notes".into(),
assets: Vec::new(),
}
}
fn json(release: &Option<CraftAppRelease>) -> String {
serde_json::to_string(release).unwrap()
}
fn to_map(fields: Vec<(&'static str, String)>) -> HashMap<String, String> {
fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
}
}
@@ -0,0 +1,644 @@
//! Reads and writes the fleet's shared Craft App release state in Redis (keys and fields are
//! documented in `shared_entry`).
//!
//! Writes are last-write-wins but guarded by time: a Lua script compares a timestamp field
//! before writing, so a slow instance never replaces a newer fetch with an older one. A `304`
//! moves only `fetched_ms`, and only if the stored ETag is still the one that was revalidated.
//!
//! Some Redis setups refuse scripts (scripting disabled, an ACL without `EVAL`, a proxy). The
//! first time Redis refuses the script, this store switches to applying the same rule itself:
//! it `WATCH`es the hash, reads and decodes the guarding fields, decides, and writes in a
//! `MULTI`/`EXEC` that Redis drops if another instance wrote in between (then it decides again).
//!
//! The pool is r2d2 (blocking), so every call runs on tokio's blocking pool with a timeout:
//! Redis trouble costs the polling loop a few seconds at most, never a stuck thread of the
//! async runtime. The poller gets its own small pool ([`build_poller_redis_pool`]) whose
//! connections time out reads and writes, so a hung Redis can neither strand blocking threads
//! nor take connections from the request handlers' pool.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use log::warn;
use r2d2::{ManageConnection, Pool};
use redis::{Client, Connection, ConnectionLike, ErrorKind, RedisError, RedisResult, Script, ServerErrorKind, Value};
use artcraft_api_defs::craft_apps::release_info::CraftAppRelease;
use redis_schema::keys::craft_apps::craft_app_release_redis_key::CraftAppReleaseRedisKey;
use redis_schema::keys::craft_apps::craft_apps_release_backoff_redis_key::CraftAppsReleaseBackoffRedisKey;
use crate::threads::poll_craft_app_releases_thread::scheduling::Backoff;
use crate::threads::poll_craft_app_releases_thread::shared_entry;
use crate::threads::poll_craft_app_releases_thread::shared_entry::{SharedAppEntry, SharedFailure};
/// Connections in the poller's pool: one per instance is all the loop uses at a time.
const POOL_MAX_SIZE: u32 = 2;
/// Longest wait for a pooled connection.
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(3);
/// Longest wait for one read or write on a connection; after it, the connection gives up
/// instead of holding its blocking thread until the operating system notices a dead peer.
const IO_TIMEOUT: Duration = Duration::from_secs(5);
/// Longest wait for a whole call, connection included.
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
/// How long a claim on an app keeps other instances from requesting it too: longer than one
/// request can take (10 s to connect, 30 s for the answer), plus a margin.
pub const CLAIM_WINDOW: Duration = Duration::from_secs(60);
/// Without scripts: how many times a guarded write decides again after another instance wrote
/// between its read and its write, before giving up (as if it lost the race).
const WATCH_ATTEMPTS: usize = 3;
/// Writes `ARGV[6..]` (field/value pairs) to the hash `KEYS[1]` and sets its TTL to `ARGV[4]` ms,
/// unless the hash's time field `ARGV[1]` already holds a value greater than
/// `ARGV[2] - ARGV[3]` (new time minus a minimum gap; a gap of 0 means "never go back in time"),
/// or `ARGV[5]` is non-empty and differs from the hash's `etag`. Returns 1 if written, else 0.
/// Unreadable stored times don't block the write, so garbage gets overwritten.
const GUARDED_HSET_LUA: &str = r#"
local current = tonumber(redis.call('HGET', KEYS[1], ARGV[1]))
if current and current + tonumber(ARGV[3]) > tonumber(ARGV[2]) then
return 0
end
if ARGV[5] ~= '' and redis.call('HGET', KEYS[1], 'etag') ~= ARGV[5] then
return 0
end
redis.call('HSET', KEYS[1], unpack(ARGV, 6))
redis.call('PEXPIRE', KEYS[1], ARGV[4])
return 1
"#;
static GUARDED_HSET: LazyLock<Script> = LazyLock::new(|| Script::new(GUARDED_HSET_LUA));
/// Handle on the shared state. Cheap to clone.
#[derive(Clone)]
pub struct SharedStore {
pool: PollerRedisPool,
/// Written as `by`, for debugging.
hostname: String,
/// Set once Redis refuses the guarded-write script; from then on guarded writes use `WATCH`.
scripts_refused: Arc<AtomicBool>,
}
/// The poller's Redis pool; see [`build_poller_redis_pool`].
pub type PollerRedisPool = Pool<PollerRedisConnections>;
/// Makes the poller's Redis connections, every one of them with I/O timeouts.
pub struct PollerRedisConnections {
client: Client,
}
/// A guarded write: which time field guards it, and what to write.
struct GuardedWrite {
key: String,
time_field: &'static str,
time: DateTime<Utc>,
min_gap: Duration,
required_etag: Option<String>,
fields: Vec<(&'static str, String)>,
}
impl SharedStore {
pub fn new(pool: PollerRedisPool, hostname: String) -> Self {
Self { pool, hostname, scripts_refused: Arc::new(AtomicBool::new(false)) }
}
/// The shared entries of `app_keys` (in order; `None` for absent or unreadable ones) and the
/// fleet's backoff, in one round trip.
pub async fn read(&self, app_keys: &[String]) -> anyhow::Result<(Vec<Option<SharedAppEntry>>, Backoff)> {
let keys: Vec<String> = app_keys.iter().map(|k| CraftAppReleaseRedisKey::new_for_app(k).to_string()).collect();
let mut hashes: Vec<HashMap<String, String>> = self
.run(move |conn| {
let mut pipe = redis::pipe();
for key in &keys {
pipe.hgetall(key);
}
pipe.hgetall(CraftAppsReleaseBackoffRedisKey::new_for_fleet().as_str());
pipe.query(conn)
})
.await?;
let backoff = hashes.pop().map(|fields| shared_entry::decode_backoff(&fields)).unwrap_or_default();
let entries = hashes.iter().map(shared_entry::decode_app_entry).collect();
Ok((entries, backoff))
}
/// Claims the next request for `app_key` at `now`. False when another instance claimed it
/// within [`CLAIM_WINDOW`]: it is asking GitHub already, so this one doesn't. Not a lock: it
/// is never released and simply lapses, so a crashed instance can't block anyone.
pub async fn claim(&self, app_key: &str, now: DateTime<Utc>) -> anyhow::Result<bool> {
self.guarded_write(GuardedWrite {
key: CraftAppReleaseRedisKey::new_for_app(app_key).to_string(),
time_field: shared_entry::FIELD_CLAIMED_MS,
time: now,
min_gap: CLAIM_WINDOW,
required_etag: None,
fields: vec![
(shared_entry::FIELD_VERSION, shared_entry::FORMAT_VERSION.to_string()),
(shared_entry::FIELD_CLAIMED_MS, now.timestamp_millis().to_string()),
(shared_entry::FIELD_BY, self.hostname.clone()),
],
}).await
}
/// Shares a successful fetch, unless a newer one is already shared. Returns whether written.
pub async fn write_fetched(&self, app_key: &str, latest_release: Option<&CraftAppRelease>, etag: Option<&str>, fetched_at: DateTime<Utc>) -> anyhow::Result<bool> {
self.guarded_write(GuardedWrite {
key: CraftAppReleaseRedisKey::new_for_app(app_key).to_string(),
time_field: shared_entry::FIELD_FETCHED_MS,
time: fetched_at,
min_gap: Duration::ZERO,
required_etag: None,
fields: shared_entry::encode_fetch(latest_release, etag, fetched_at, &self.hostname)?,
}).await
}
/// Shares a `304` for the release with ETag `etag`: moves only the fetch time, and only if
/// that release is still the shared one and nothing newer was shared. Returns whether written.
pub async fn write_unchanged(&self, app_key: &str, etag: &str, fetched_at: DateTime<Utc>) -> anyhow::Result<bool> {
if etag.is_empty() {
return Ok(false);
}
self.guarded_write(GuardedWrite {
key: CraftAppReleaseRedisKey::new_for_app(app_key).to_string(),
time_field: shared_entry::FIELD_FETCHED_MS,
time: fetched_at,
min_gap: Duration::ZERO,
required_etag: Some(etag.to_string()),
fields: shared_entry::encode_unchanged(fetched_at, &self.hostname),
}).await
}
/// Shares a failed request, unless a newer failure is already shared. Returns whether written.
pub async fn write_failure(&self, app_key: &str, failure: SharedFailure) -> anyhow::Result<bool> {
self.guarded_write(GuardedWrite {
key: CraftAppReleaseRedisKey::new_for_app(app_key).to_string(),
time_field: shared_entry::FIELD_FAILED_MS,
time: failure.failed_at,
min_gap: Duration::ZERO,
required_etag: None,
fields: shared_entry::encode_failure(failure, &self.hostname),
}).await
}
/// Shares the fleet's backoff; no trouble deletes it.
pub async fn write_backoff(&self, backoff: Backoff) -> anyhow::Result<()> {
let key = CraftAppsReleaseBackoffRedisKey::new_for_fleet().to_string();
if backoff.trouble == 0 {
return self.run(move |conn| redis::cmd("DEL").arg(&key).query::<()>(conn)).await;
}
let fields = shared_entry::encode_backoff(backoff, &self.hostname);
let ttl_ms = CraftAppsReleaseBackoffRedisKey::get_redis_ttl().num_milliseconds();
self
.run(move |conn| {
redis::pipe()
.hset_multiple(&key, &fields).ignore()
.pexpire(&key, ttl_ms).ignore()
.query::<()>(conn)
})
.await
}
/// Applies `write` with the script, or (once Redis has refused the script) with `WATCH`.
async fn guarded_write(&self, write: GuardedWrite) -> anyhow::Result<bool> {
let ttl_ms = CraftAppReleaseRedisKey::get_redis_ttl().num_milliseconds();
let scripts_refused = self.scripts_refused.clone();
self
.run(move |conn| {
if !scripts_refused.load(Ordering::Relaxed) {
match guarded_write_with_script(conn, &write, ttl_ms) {
Err(err) if is_script_refusal(&err) => {
if !scripts_refused.swap(true, Ordering::Relaxed) {
warn!("Craft app releases: Redis refused the guarded-write script ({err}); writing with WATCH/MULTI/EXEC instead.");
}
}
result => return result,
}
}
guarded_write_with_watch(conn, &write, ttl_ms)
})
.await
}
/// Runs `call` on a pooled connection, off the async runtime, within [`CALL_TIMEOUT`].
async fn run<T, F>(&self, call: F) -> anyhow::Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> RedisResult<T> + Send + 'static,
{
let pool = self.pool.clone();
let task = tokio::task::spawn_blocking(move || -> anyhow::Result<T> {
let mut conn = pool.get_timeout(CONNECTION_TIMEOUT)?;
Ok(call(&mut conn)?)
});
match tokio::time::timeout(CALL_TIMEOUT, task).await {
Ok(Ok(result)) => result,
Ok(Err(join_err)) => Err(anyhow!("redis task failed: {join_err}")),
Err(_) => Err(anyhow!("redis call timed out after {CALL_TIMEOUT:?}")),
}
}
}
/// The poller's own Redis pool, apart from the one the request handlers use. Built without
/// connecting, so an unreachable Redis at startup doesn't stop the server: the poller then
/// works alone until Redis answers.
pub fn build_poller_redis_pool(redis_url: &str) -> RedisResult<PollerRedisPool> {
let manager = PollerRedisConnections { client: Client::open(redis_url)? };
Ok(Pool::builder()
.max_size(POOL_MAX_SIZE)
.min_idle(Some(0))
.connection_timeout(CONNECTION_TIMEOUT)
.build_unchecked(manager))
}
impl ManageConnection for PollerRedisConnections {
type Connection = Connection;
type Error = RedisError;
/// Connects (handshake included) within [`CONNECTION_TIMEOUT`], then makes every later read
/// and write give up after [`IO_TIMEOUT`], before the connection is ever used.
fn connect(&self) -> RedisResult<Connection> {
let conn = self.client.get_connection_with_timeout(CONNECTION_TIMEOUT)?;
conn.set_read_timeout(Some(IO_TIMEOUT))?;
conn.set_write_timeout(Some(IO_TIMEOUT))?;
Ok(conn)
}
/// The pool's check before lending a connection; bounded by [`IO_TIMEOUT`] like any command.
fn is_valid(&self, conn: &mut Connection) -> RedisResult<()> {
redis::cmd("PING").query::<()>(conn)
}
fn has_broken(&self, conn: &mut Connection) -> bool {
!conn.is_open()
}
}
/// [`GUARDED_HSET_LUA`] decides and writes on the server, in one step.
fn guarded_write_with_script(conn: &mut Connection, write: &GuardedWrite, ttl_ms: i64) -> RedisResult<bool> {
let mut invocation = GUARDED_HSET.key(&write.key);
invocation
.arg(write.time_field)
.arg(write.time.timestamp_millis())
.arg(write.min_gap.as_millis() as u64)
.arg(ttl_ms)
.arg(write.required_etag.as_deref().unwrap_or_default());
for (field, value) in &write.fields {
invocation.arg(*field).arg(value);
}
let written: i64 = invocation.invoke(conn)?;
Ok(written == 1)
}
/// The same rule without scripts: `WATCH` the hash, read and decode its guarding fields, decide
/// with [`write_wins`], then write in a transaction. If another instance wrote the hash in
/// between, Redis drops the transaction and this decides again with what it wrote.
fn guarded_write_with_watch(conn: &mut Connection, write: &GuardedWrite, ttl_ms: i64) -> RedisResult<bool> {
for _ in 0..WATCH_ATTEMPTS {
redis::cmd("WATCH").arg(&write.key).query::<()>(conn)?;
let stored: RedisResult<(Option<String>, Option<String>)> = redis::cmd("HMGET")
.arg(&write.key)
.arg(write.time_field)
.arg(shared_entry::FIELD_ETAG)
.query(conn);
let (stored_time, stored_etag) = match stored {
Ok(stored) => stored,
Err(err) => {
// The connection goes back to the shared pool; don't leave it watching.
let _ = redis::cmd("UNWATCH").query::<()>(conn);
return Err(err);
}
};
if !write_wins(write, stored_time.as_deref(), stored_etag.as_deref()) {
redis::cmd("UNWATCH").query::<()>(conn)?;
return Ok(false);
}
// `EXEC` ends the watch either way; `None` means another write got in first.
let committed: Option<Value> = redis::pipe()
.atomic()
.hset_multiple(&write.key, &write.fields)
.pexpire(&write.key, ttl_ms)
.query(conn)?;
if committed.is_some() {
return Ok(true);
}
}
Ok(false)
}
/// Whether `write` may replace what the hash holds, by the script's rule: not if the stored time
/// plus the minimum gap is later than the new time, nor if an ETag is required and the stored
/// one differs. An unreadable stored time doesn't block the write, so garbage gets overwritten.
fn write_wins(write: &GuardedWrite, stored_time: Option<&str>, stored_etag: Option<&str>) -> bool {
let new_ms = write.time.timestamp_millis();
let gap_ms = write.min_gap.as_millis().min(i64::MAX as u128) as i64;
if let Some(current_ms) = stored_time.and_then(|t| t.parse::<i64>().ok()) {
if current_ms.saturating_add(gap_ms) > new_ms {
return false;
}
}
match write.required_etag.as_deref().filter(|etag| !etag.is_empty()) {
Some(required) => stored_etag == Some(required),
None => true,
}
}
/// Whether Redis answered that it won't run the script (as opposed to being unreachable, busy,
/// out of memory or read-only, which the `WATCH` path wouldn't get past either): an ACL
/// refusal, an unknown `EVALSHA`/`EVAL`/`SCRIPT` command, or scripting switched off.
fn is_script_refusal(err: &RedisError) -> bool {
if err.is_io_error() || err.is_timeout() || err.is_connection_dropped() || err.is_connection_refusal() {
return false;
}
match err.kind() {
ErrorKind::Server(ServerErrorKind::NoPerm) => true,
ErrorKind::Server(ServerErrorKind::ResponseError) | ErrorKind::Extension => {
if err.code() == Some("BUSY") {
return false;
}
let detail = err.detail().unwrap_or_default().to_ascii_lowercase();
detail.contains("unknown command") || detail.contains("eval") || detail.contains("script")
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use chrono::TimeZone;
use url::Url;
use super::*;
const DEFAULT_TEST_REDIS_URL: &str = "redis://127.0.0.1:6379/";
/// Against a real Redis (`CRAFT_APPS_TEST_REDIS_URL`, default `redis://127.0.0.1:6379/`). Each
/// test uses app keys of its own, deletes them afterwards, and leaves the fleet backoff key
/// alone unless it was absent.
/// `cargo test -p storyteller-web --bin storyteller-web shared_store -- --ignored`
mod live_redis_tests {
use super::*;
#[tokio::test]
#[ignore = "needs a Redis server"]
async fn guarded_writes_with_the_script() {
let pool = test_pool(&test_redis_url());
let (pod_a, pod_b) = (SharedStore::new(pool.clone(), "pod-a".into()), SharedStore::new(pool, "pod-b".into()));
exercise_guarded_writes(&pod_a, &pod_b).await;
assert!(!pod_a.scripts_refused.load(Ordering::Relaxed));
}
#[tokio::test]
#[ignore = "needs a Redis server"]
async fn guarded_writes_with_watch() {
let pool = test_pool(&test_redis_url());
let (pod_a, pod_b) = (SharedStore::new(pool.clone(), "pod-a".into()), SharedStore::new(pool, "pod-b".into()));
pod_a.scripts_refused.store(true, Ordering::Relaxed);
pod_b.scripts_refused.store(true, Ordering::Relaxed);
exercise_guarded_writes(&pod_a, &pod_b).await;
}
/// A Redis user without scripting permissions: the first guarded write is refused, and the
/// store switches to `WATCH` and still applies every rule.
#[tokio::test]
#[ignore = "needs a Redis server that supports ACLs"]
async fn a_refused_script_falls_back_to_watch() {
let admin_url = test_redis_url();
let admin = test_pool(&admin_url);
let user = format!("craft-test-{}", rand::random::<u32>());
let password = format!("pw-{}", rand::random::<u64>());
let mut conn = admin.get().unwrap();
redis::cmd("ACL").arg("SETUSER").arg(&user).arg("reset").arg("on").arg(format!(">{password}")).arg("~*").arg("&*").arg("+@all").arg("-@scripting")
.query::<()>(&mut *conn).unwrap();
let mut url = Url::parse(&admin_url).unwrap();
url.set_username(&user).unwrap();
url.set_password(Some(&password)).unwrap();
let pool = test_pool(url.as_str());
let (pod_a, pod_b) = (SharedStore::new(pool.clone(), "pod-a".into()), SharedStore::new(pool, "pod-b".into()));
exercise_guarded_writes(&pod_a, &pod_b).await;
redis::cmd("ACL").arg("DELUSER").arg(&user).query::<()>(&mut *conn).unwrap();
assert!(pod_a.scripts_refused.load(Ordering::Relaxed), "the script should have been refused");
assert!(pod_b.scripts_refused.load(Ordering::Relaxed), "the script should have been refused");
}
}
/// An unreachable Redis fails fast, so the loop can fall back to polling on its own.
#[tokio::test]
async fn an_unreachable_redis_fails_fast() {
let pool = build_poller_redis_pool("redis://127.0.0.1:1/").unwrap();
let store = SharedStore::new(pool, "pod".into());
let started = std::time::Instant::now();
assert!(store.read(&["photocraft".to_string()]).await.is_err());
assert!(started.elapsed() < CALL_TIMEOUT + Duration::from_secs(1));
}
/// A Redis that completes the connection handshake and then stops answering (a wedged server,
/// a half-open connection): the call fails by itself, before [`CALL_TIMEOUT`] gives up on it,
/// so its blocking thread and connection are released rather than stranded until the
/// operating system notices. Without I/O timeouts the pool's `PING` would hang here forever.
#[tokio::test]
async fn a_redis_that_stops_answering_releases_the_blocking_thread() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("redis://{}/", listener.local_addr().unwrap());
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
std::thread::spawn(move || answer_handshake_then_go_silent(stream));
}
});
let store = SharedStore::new(build_poller_redis_pool(&url).unwrap(), "pod".into());
let started = std::time::Instant::now();
let err = store.read(&["photocraft".to_string()]).await.unwrap_err();
assert!(started.elapsed() < CALL_TIMEOUT, "{:?} after {:?}", err, started.elapsed());
}
/// [`write_wins`] must decide exactly as [`GUARDED_HSET_LUA`] does.
mod write_rule_tests {
use super::*;
#[test]
fn a_write_never_goes_back_in_time() {
let write = guarded_write_at(t(2), Duration::ZERO, None);
assert!(write_wins(&write, None, None), "nothing stored");
assert!(write_wins(&write, Some(&ms(t(1))), None), "newer than stored");
assert!(write_wins(&write, Some(&ms(t(2))), None), "same time, as the script allows");
assert!(!write_wins(&write, Some(&ms(t(3))), None), "older than stored");
}
#[test]
fn a_claim_waits_for_the_window() {
let claim = guarded_write_at(t(1), CLAIM_WINDOW, None);
assert!(!write_wins(&claim, Some(&ms(t(0) + chrono::Duration::seconds(30))), None));
assert!(write_wins(&claim, Some(&ms(t(0))), None), "exactly one window later");
}
#[test]
fn a_required_etag_must_match() {
let write = guarded_write_at(t(2), Duration::ZERO, Some("\"e2\""));
assert!(write_wins(&write, Some(&ms(t(1))), Some("\"e2\"")));
assert!(!write_wins(&write, Some(&ms(t(1))), Some("\"e1\"")), "another release is shared");
assert!(!write_wins(&write, Some(&ms(t(1))), None), "no release is shared");
assert!(write_wins(&guarded_write_at(t(2), Duration::ZERO, Some("")), None, None), "empty means none required");
}
#[test]
fn an_unreadable_stored_time_is_overwritten() {
let write = guarded_write_at(t(2), Duration::ZERO, None);
assert!(write_wins(&write, Some("soon"), None));
assert!(write_wins(&write, Some(""), None));
}
}
mod script_refusal_tests {
use redis::make_extension_error;
use super::*;
#[test]
fn redis_refusing_scripts_is_a_refusal() {
let refusals = [
server_error(ServerErrorKind::NoPerm, "this user has no permissions to run the 'evalsha' command"),
server_error(ServerErrorKind::ResponseError, "unknown command 'EVALSHA', with args beginning with: "),
server_error(ServerErrorKind::ResponseError, "This Redis instance has scripting disabled"),
make_extension_error("ERR".to_string(), Some("unknown command 'SCRIPT'".to_string())),
];
for err in refusals {
assert!(is_script_refusal(&err), "{:?}", err);
}
}
#[test]
fn redis_being_unavailable_is_not_a_refusal() {
let unavailable = [
RedisError::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused")),
RedisError::from(std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out")),
make_extension_error("OOM".to_string(), Some("command not allowed when used memory > 'maxmemory'.".to_string())),
make_extension_error("BUSY".to_string(), Some("Redis is busy running a script.".to_string())),
server_error(ServerErrorKind::ReadOnly, "You can't write against a read only replica."),
server_error(ServerErrorKind::ResponseError, "wrong number of arguments for 'hset' command"),
];
for err in unavailable {
assert!(!is_script_refusal(&err), "{:?}", err);
}
}
fn server_error(kind: ServerErrorKind, detail: &str) -> RedisError {
RedisError::from((ErrorKind::Server(kind), "server error", detail.to_string()))
}
}
/// Every guarded-write rule, as two instances sharing one Redis see it.
async fn exercise_guarded_writes(pod_a: &SharedStore, pod_b: &SharedStore) {
let app = format!("test-craft-{}", rand::random::<u32>());
let other = format!("{app}-other");
let keys = vec![app.clone(), other.clone()];
let read_app = |store: SharedStore, keys: Vec<String>| async move { store.read(&keys).await.unwrap().0.remove(0) };
// Nothing yet.
let (entries, _) = pod_a.read(&keys).await.unwrap();
assert!(entries.iter().all(Option::is_none));
// Two instances tick together: only one gets to ask GitHub; the claim lapses after the window.
assert!(pod_a.claim(&app, t(0)).await.unwrap());
assert!(!pod_b.claim(&app, t(0) + chrono::Duration::seconds(5)).await.unwrap());
assert!(pod_b.claim(&app, t(2)).await.unwrap());
// A newer fetch is never replaced by an older one.
assert!(pod_a.write_fetched(&app, None, Some("\"e2\""), t(2)).await.unwrap());
assert!(!pod_b.write_fetched(&app, None, Some("\"e1\""), t(1)).await.unwrap());
let fetched = read_app(pod_a.clone(), keys.clone()).await.unwrap().fetched.unwrap();
assert_eq!((fetched.etag.as_deref(), fetched.fetched_at), (Some("\"e2\""), t(2)));
// A 304 moves the time only, and only for the release it revalidated.
assert!(!pod_b.write_unchanged(&app, "\"e1\"", t(3)).await.unwrap(), "stale ETag");
assert!(!pod_b.write_unchanged(&app, "\"e2\"", t(1)).await.unwrap(), "older than the shared fetch");
assert!(pod_b.write_unchanged(&app, "\"e2\"", t(3)).await.unwrap());
let fetched = read_app(pod_a.clone(), keys.clone()).await.unwrap().fetched.unwrap();
assert_eq!((fetched.etag.as_deref(), fetched.fetched_at), (Some("\"e2\""), t(3)));
// Failures: newest wins.
let failure = SharedFailure { failed_at: t(5), consecutive_failures: 2 };
assert!(pod_a.write_failure(&app, failure).await.unwrap());
assert!(!pod_b.write_failure(&app, SharedFailure { failed_at: t(4), consecutive_failures: 1 }).await.unwrap());
assert_eq!(read_app(pod_a.clone(), keys.clone()).await.unwrap().failure, Some(failure));
// Garbage in an entry is overwritten, not trusted.
let key = CraftAppReleaseRedisKey::new_for_app(&other).to_string();
let garbage_key = key.clone();
pod_a.run(move |conn| redis::cmd("HSET").arg(&garbage_key).arg("v").arg("1").arg("fetched_ms").arg("soon").arg("release").arg("{").query::<()>(conn)).await.unwrap();
assert!(pod_a.read(&keys).await.unwrap().0[1].as_ref().unwrap().fetched.is_none());
assert!(pod_a.write_fetched(&other, None, None, t(1)).await.unwrap());
assert!(pod_a.read(&keys).await.unwrap().0[1].as_ref().unwrap().fetched.is_some());
// Entries expire if nobody refreshes them.
let ttl_key = key.clone();
let ttl: i64 = pod_a.run(move |conn| redis::cmd("PTTL").arg(&ttl_key).query(conn)).await.unwrap();
assert!(ttl > 6 * 24 * 3600 * 1000, "{}", ttl);
// The fleet backoff round-trips (only touched when absent, to leave a real one alone).
let (_, existing) = pod_a.read(&keys).await.unwrap();
if existing == Backoff::default() {
let backoff = Backoff { until: Some(t(30)), trouble: 2 };
pod_a.write_backoff(backoff).await.unwrap();
assert_eq!(pod_b.read(&keys).await.unwrap().1, backoff);
pod_b.write_backoff(Backoff::default()).await.unwrap();
assert_eq!(pod_a.read(&keys).await.unwrap().1, Backoff::default());
}
let cleanup: Vec<String> = keys.iter().map(|k| CraftAppReleaseRedisKey::new_for_app(k).to_string()).collect();
pod_a.run(move |conn| redis::cmd("DEL").arg(&cleanup).query::<()>(conn)).await.unwrap();
}
fn guarded_write_at(time: DateTime<Utc>, min_gap: Duration, required_etag: Option<&str>) -> GuardedWrite {
GuardedWrite {
key: "craftApps:releases:app:test".to_string(),
time_field: shared_entry::FIELD_FETCHED_MS,
time,
min_gap,
required_etag: required_etag.map(str::to_string),
fields: Vec::new(),
}
}
/// Answers `+OK` to connection setup (`CLIENT SETINFO`, `SELECT`, `HELLO`), then keeps the
/// connection open without another word once a real command (`PING`, `HGETALL`, ...) arrives.
fn answer_handshake_then_go_silent(mut stream: TcpStream) {
let mut buf = [0u8; 4096];
loop {
let n = match stream.read(&mut buf) {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let chunk = String::from_utf8_lossy(&buf[..n]).to_ascii_uppercase();
if !(chunk.contains("CLIENT") || chunk.contains("SELECT") || chunk.contains("HELLO")) {
std::thread::sleep(Duration::from_secs(60));
return;
}
let commands = chunk.lines().filter(|line| line.starts_with('*')).count();
if stream.write_all("+OK\r\n".repeat(commands).as_bytes()).is_err() {
return;
}
}
}
fn test_redis_url() -> String {
std::env::var("CRAFT_APPS_TEST_REDIS_URL").unwrap_or_else(|_| DEFAULT_TEST_REDIS_URL.to_string())
}
fn test_pool(url: &str) -> PollerRedisPool {
build_poller_redis_pool(url).unwrap()
}
fn t(minute: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 10, 10, 12, minute, 0).unwrap()
}
fn ms(time: DateTime<Utc>) -> String {
time.timestamp_millis().to_string()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import {
AppDemo,
AppFamily,
AppGallery,
AppGetIt,
@@ -8,7 +9,10 @@ import {
import {
AppHeroDownloadButton,
AppHeroOtherDownloadsLink,
LauncherDownloadButton,
} from "@/components/apps/app-downloads";
import { craftLauncherRecommended } from "@/components/apps/launcher-callout";
import { craftLauncherDownloads } from "@/lib/craft-launcher";
import {
AppIcon,
AppTab,
@@ -79,9 +83,18 @@ export default async function CraftAppPage({ params }: { params: Params }) {
const name = craftAppName(app);
const release = craftAppRelease(app);
// The hero's download is ArtCraft Launcher, which installs this app and keeps
// it updated; the app's own installers are in #get-it, under the launcher.
// An app with no release yet has nothing for the launcher to install.
const launcherDownloads = release ? craftLauncherRecommended() : [];
const allLauncherDownloads = craftLauncherDownloads();
const hasDownload = release !== null;
const [hero] = app.shots;
const [before, accent, after] = app.headline;
const shareUrl = siteUrl(craftAppPath(app));
// A demo takes section 02 and pushes the rest down one.
const sectionIndex = (n: number) =>
String(n + (app.demo ? 1 : 0)).padStart(2, "0");
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
@@ -145,18 +158,26 @@ export default async function CraftAppPage({ params }: { params: Params }) {
mediaTag="Screenshot"
>
<div className="flex flex-col items-center gap-6">
<div className="flex flex-wrap justify-center gap-3">
{release && (
<AppHeroDownloadButton
name={name}
recommended={craftReleaseDownloads(app, release).filter(
(download) => download.recommended,
)}
<div className="flex flex-wrap items-start justify-center gap-3">
{launcherDownloads.length > 0 ? (
<LauncherDownloadButton
recommended={launcherDownloads}
allDownloads={allLauncherDownloads}
fallbackHref="#get-it"
/>
) : (
release && (
<AppHeroDownloadButton
name={name}
recommended={craftReleaseDownloads(app, release).filter(
(download) => download.recommended,
)}
/>
)
)}
<DiscordButton
size="lg"
variant={release ? "secondary" : "primary"}
variant={hasDownload ? "secondary" : "primary"}
>
Join the Discord
</DiscordButton>
@@ -171,7 +192,7 @@ export default async function CraftAppPage({ params }: { params: Params }) {
View on GitHub
</Button>
</div>
{release && <AppHeroOtherDownloadsLink />}
{hasDownload && <AppHeroOtherDownloadsLink />}
<p className="hud-label text-faint">
{app.platforms.join(" · ")} · Free and open source
</p>
@@ -179,9 +200,11 @@ export default async function CraftAppPage({ params }: { params: Params }) {
</div>
</CampaignHero>
{app.demo && <AppDemo app={app} demo={app.demo} index="02" />}
<CampaignSection
id="highlights"
index="02"
index={sectionIndex(2)}
label="Highlights"
annotation={app.category}
title={
@@ -193,9 +216,9 @@ export default async function CraftAppPage({ params }: { params: Params }) {
<TipGrid items={app.features} columns={3} />
</CampaignSection>
<AppGallery app={app} index="03" />
<AppGetIt app={app} index="04" />
<AppFamily app={app} index="05" />
<AppGallery app={app} index={sectionIndex(3)} />
<AppGetIt app={app} index={sectionIndex(4)} />
<AppFamily app={app} index={sectionIndex(5)} />
<ClosingCta
eyebrow="Open source · Free"
@@ -9,6 +9,8 @@ import {
KeyboardIcon,
} from "lucide-react";
import AppCard from "@/components/apps/app-card";
import { LauncherDownloadButton } from "@/components/apps/app-downloads";
import { craftLauncherRecommended } from "@/components/apps/launcher-callout";
import ShareBar from "@/components/apps/share-bar";
import {
CampaignSection,
@@ -23,6 +25,12 @@ import { PageHeader } from "@/components/page/page-header";
import RevealManager from "@/components/reveal-manager";
import { Button } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
CRAFT_LAUNCHER_NAME,
CRAFT_LAUNCHER_REPO,
craftLauncherDownloads,
craftLauncherReleasePageUrl,
} from "@/lib/craft-launcher";
import {
CRAFTING_APPS,
CRAFTING_APPS_COUNT_WORD,
@@ -37,7 +45,7 @@ import { siteUrl } from "@/lib/links";
const TITLE = "Crafting Apps: open-source creative tools";
const COVERAGE =
"Image editing, vector illustration, video, photography, PDFs, motion graphics and page layout";
"Image editing, vector illustration, video, photography, PDFs, motion graphics, page layout, interface design, beat making, word processing, audio, presentations, CAD and spreadsheets";
const DESCRIPTION = `${COVERAGE}: ${CRAFTING_APPS_COUNT_WORD.toLowerCase()} native, open-source apps from the ArtCraft team, built in Rust and free to use.`;
export const metadata: Metadata = {
@@ -65,6 +73,8 @@ const PRINCIPLE_ICONS = [
];
export default function CraftingAppsPage() {
const launcherDownloads = craftLauncherRecommended();
const allLauncherDownloads = craftLauncherDownloads();
return (
<>
<RevealManager />
@@ -76,13 +86,55 @@ export default function CraftingAppsPage() {
annotation={`${CRAFTING_APPS_COUNT_WORD} apps · Open source · Pure Rust`}
title={
<>
{CRAFTING_APPS_COUNT_WORD} apps. One <ColorAccent>craft</ColorAccent>.
{CRAFTING_APPS_COUNT_WORD} apps.{" "}
<span className="whitespace-nowrap">
One <ColorAccent>craft</ColorAccent>.
</span>
</>
}
lede={`${COVERAGE}. Native, open-source apps from the ArtCraft team, built in Rust and free to use.`}
aside={
<nav
aria-label="Crafting Apps"
data-reveal
// Under the text: two even rows from sm up (7 + 6). Beside it from
// xl up: centered rows of five.
className="mt-12 grid w-fit grid-cols-4 gap-x-3 gap-y-6 sm:grid-cols-7 sm:gap-x-5 xl:mt-0 xl:flex xl:w-120 xl:shrink-0 xl:flex-wrap xl:justify-center"
>
{CRAFTING_APPS.map((app) => (
<Link
key={app.slug}
href={craftAppPath(app)}
className={`group flex w-18 flex-col items-center gap-2 sm:w-20 ${appThemeClass(app)}`}
{...trackAttrs("app_select", { app_name: app.slug })}
>
<AppIcon
app={app}
priority
className="h-14 w-14 transition-transform duration-300 ease-out group-hover:-translate-y-1 sm:h-20 sm:w-20"
/>
<span className="text-xs font-medium text-muted group-hover:text-(--app-ink)">
{craftAppName(app)}
</span>
</Link>
))}
</nav>
}
>
<div data-reveal className="mt-8 flex flex-wrap gap-3">
<DiscordButton size="lg">Join the Discord</DiscordButton>
<div data-reveal className="mt-8 flex flex-wrap items-start gap-3">
{launcherDownloads.length > 0 && (
<LauncherDownloadButton
recommended={launcherDownloads}
allDownloads={allLauncherDownloads}
fallbackHref={craftLauncherReleasePageUrl()}
/>
)}
<DiscordButton
size="lg"
variant={launcherDownloads.length > 0 ? "secondary" : "primary"}
>
Join the Discord
</DiscordButton>
<Button
href={CRAFTING_APPS_GITHUB_ORG}
variant="secondary"
@@ -94,34 +146,26 @@ export default function CraftingAppsPage() {
Browse on GitHub
</Button>
</div>
{launcherDownloads.length > 0 && (
<p data-reveal className="mt-4 max-w-xl leading-relaxed text-muted">
<span className="text-ink">Recommended:</span> {CRAFT_LAUNCHER_NAME}{" "}
installs every app below and keeps them all up to date. Each app
also has its own installers on its page.{" "}
<a
href={CRAFT_LAUNCHER_REPO}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline decoration-line-strong underline-offset-4 hover:decoration-current"
>
Learn more
</a>
</p>
)}
<ShareBar
url={siteUrl("/apps")}
text={`Crafting Apps: ${CRAFTING_APPS_COUNT_WORD.toLowerCase()} open-source creative apps from ArtCraft`}
className="mt-6"
/>
<nav
aria-label="Crafting Apps"
data-reveal
className="mt-12 flex flex-wrap gap-x-3 gap-y-6 sm:gap-x-5"
>
{CRAFTING_APPS.map((app) => (
<Link
key={app.slug}
href={craftAppPath(app)}
className={`group flex w-18 flex-col items-center gap-2 sm:w-20 ${appThemeClass(app)}`}
{...trackAttrs("app_select", { app_name: app.slug })}
>
<AppIcon
app={app}
priority
className="h-14 w-14 transition-transform duration-300 ease-out group-hover:-translate-y-1 sm:h-20 sm:w-20"
/>
<span className="text-xs font-medium text-muted group-hover:text-(--app-ink)">
{craftAppName(app)}
</span>
</Link>
))}
</nav>
</PageHeader>
<SectionShell id="lineup">
@@ -324,6 +324,13 @@ html[data-ruler="on"]::-webkit-scrollbar {
.app-pdfcraft { --app: #12a58a; --app-ink: #0a7563; }
.app-effectcraft { --app: #e0368f; --app-ink: #b0206c; }
.app-designcraft { --app: #7bb51c; --app-ink: #457008; }
.app-figcraft { --app: #02da96; --app-ink: #017a54; }
.app-loopcraft { --app: #f0d824; --app-ink: #6f6300; }
.app-wordcraft { --app: #4c6ef5; --app-ink: #253e99; }
.app-soundcraft { --app: #14a9c4; --app-ink: #0b6f82; }
.app-deckcraft { --app: #f26b1d; --app-ink: #ad4306; }
.app-cadcraft { --app: #14a3c7; --app-ink: #09596d; }
.app-gridcraft { --app: #1f9d55; --app-ink: #136938; }
:root[data-theme="dark"] .app-photocraft { --app-ink: #7fb0ff; }
:root[data-theme="dark"] .app-vectorcraft { --app-ink: #ff8a70; }
:root[data-theme="dark"] .app-filmcraft { --app-ink: #b69cff; }
@@ -331,6 +338,13 @@ html[data-ruler="on"]::-webkit-scrollbar {
:root[data-theme="dark"] .app-pdfcraft { --app-ink: #3fd6b8; }
:root[data-theme="dark"] .app-effectcraft { --app-ink: #ff7ab8; }
:root[data-theme="dark"] .app-designcraft { --app-ink: #a6e04a; }
:root[data-theme="dark"] .app-figcraft { --app-ink: #3fe6ae; }
:root[data-theme="dark"] .app-loopcraft { --app-ink: #f5e04f; }
:root[data-theme="dark"] .app-wordcraft { --app-ink: #8da2fb; }
:root[data-theme="dark"] .app-soundcraft { --app-ink: #52d1ea; }
:root[data-theme="dark"] .app-deckcraft { --app-ink: #ff9657; }
:root[data-theme="dark"] .app-cadcraft { --app-ink: #4cc7e6; }
:root[data-theme="dark"] .app-gridcraft { --app-ink: #5ee089; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .app-photocraft { --app-ink: #7fb0ff; }
:root:not([data-theme="light"]) .app-vectorcraft { --app-ink: #ff8a70; }
@@ -339,6 +353,13 @@ html[data-ruler="on"]::-webkit-scrollbar {
:root:not([data-theme="light"]) .app-pdfcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-effectcraft { --app-ink: #ff7ab8; }
:root:not([data-theme="light"]) .app-designcraft { --app-ink: #a6e04a; }
:root:not([data-theme="light"]) .app-figcraft { --app-ink: #3fe6ae; }
:root:not([data-theme="light"]) .app-loopcraft { --app-ink: #f5e04f; }
:root:not([data-theme="light"]) .app-wordcraft { --app-ink: #8da2fb; }
:root:not([data-theme="light"]) .app-soundcraft { --app-ink: #52d1ea; }
:root:not([data-theme="light"]) .app-deckcraft { --app-ink: #ff9657; }
:root:not([data-theme="light"]) .app-cadcraft { --app-ink: #4cc7e6; }
:root:not([data-theme="light"]) .app-gridcraft { --app-ink: #5ee089; }
}
/* Plan cell: a color wash pooling under the index tab and fading into the
@@ -1,15 +1,17 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
AppleIcon,
ArrowDownIcon,
ArrowDownToLineIcon,
ChevronDownIcon,
SquareTerminalIcon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { WindowsIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { Button, type ButtonProps } from "@/components/ui";
import { craftLauncherDownloads } from "@/lib/craft-launcher";
import {
CRAFT_DESKTOP_PLATFORMS,
type CraftDesktopPlatform,
@@ -35,9 +37,17 @@ const PLATFORM_ICONS: Record<CraftDesktopPlatform, ReactNode> = {
/**
* One card per desktop platform: its recommended build as the main button,
* then every other file for that platform. The visitor's platform is tagged
* "Your system" and gets the solid button.
* "Your system" and gets the solid button. These are the app's standalone
* installers (the launcher above them installs the whole family), so each
* button says "Standalone for <platform>" and each file list names the app.
*/
export function AppPlatformDownloads({ downloads }: { downloads: CraftDownload[] }) {
export function AppPlatformDownloads({
name,
downloads,
}: {
name: string;
downloads: CraftDownload[];
}) {
const detected = useDetectedDesktop();
return (
@@ -50,7 +60,7 @@ export function AppPlatformDownloads({ downloads }: { downloads: CraftDownload[]
const others = files.filter((download) => download !== main);
return (
<article key={platform} data-reveal className="flex flex-col bg-bg">
<article key={platform} data-reveal className="flex min-w-0 flex-col bg-bg">
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5 md:px-8">
<p className="hud-label text-muted">
{isDetected ? (
@@ -75,14 +85,16 @@ export function AppPlatformDownloads({ downloads }: { downloads: CraftDownload[]
<Button
href={main.href}
variant={isDetected ? "primary" : "secondary"}
className="mt-6 w-full"
className="mt-6 w-full whitespace-normal text-center"
>
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download for {platform}
Standalone for {platform}
</Button>
{others.length > 0 && (
<>
<p className="hud-label mt-8 text-faint">Other {platform} downloads</p>
<p className="hud-label mt-8 text-faint">
Other {name} files for {platform}
</p>
<ul className="mt-2 border-t border-line">
{others.map((download) => (
<li key={download.href} className="border-b border-line">
@@ -126,7 +138,10 @@ export function DownloadRow({ download, className }: { download: CraftDownload;
);
}
/** Hero CTA: a direct download once the OS is known, else a jump to #get-it. */
/**
* The app's own hero CTA, used only while ArtCraft Launcher has no release: a
* direct download once the OS is known, else a jump to #get-it.
*/
export function AppHeroDownloadButton({
recommended,
name,
@@ -144,6 +159,216 @@ export function AppHeroDownloadButton({
);
}
/**
* ArtCraft Launcher's CTA: a direct download for the visitor's system once
* it is known (with the file's platform and build underneath), else a jump
* to `fallbackHref`, where every platform is listed.
*
* On Linux, offers Flatpak as the default button action, plus a dropdown
* arrow on the right to select and download other formats: Flatpak first,
* then AppImage, then deb, then rpm.
*/
export function LauncherDownloadButton({
recommended,
allDownloads,
fallbackHref,
size = "lg",
variant = "primary",
fullWidth = false,
className,
}: {
recommended: CraftDownload[];
allDownloads?: CraftDownload[];
fallbackHref: string;
size?: ButtonProps["size"];
variant?: ButtonProps["variant"];
fullWidth?: boolean;
className?: string;
}) {
const detected = useDetectedDesktop();
const mine = detected && pickDownload(recommended, detected);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [selectedDownload, setSelectedDownload] = useState<CraftDownload | null>(null);
useEffect(() => {
if (!menuOpen) return;
const onPointerDown = (e: PointerEvent) => {
if (!menuRef.current?.contains(e.target as Node)) {
setMenuOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setMenuOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [menuOpen]);
const isLinux = detected?.platform === "Linux";
const downloads = allDownloads ?? (isLinux ? craftLauncherDownloads() : []);
const linuxDownloads = isLinux
? downloads.filter(
(download) =>
download.group === "Linux" &&
(!detected.arch || !download.arch || download.arch === detected.arch),
)
: [];
const linuxOptions = isLinux
? [...linuxDownloads].sort(
(a, b) => linuxFormatPriority(a.fileName) - linuxFormatPriority(b.fileName),
)
: [];
const defaultLinuxDownload =
linuxOptions.find((d) => d.fileName.endsWith(".flatpak")) ?? mine;
const activeDownload = selectedDownload ?? defaultLinuxDownload ?? mine;
if (isLinux && linuxOptions.length > 0 && activeDownload) {
const isPrimary = variant === "primary";
return (
<span className={twMerge("flex flex-col gap-2", className)}>
<div
ref={menuRef}
className={twMerge("relative inline-flex items-stretch", fullWidth ? "w-full" : "w-fit")}
>
<Button
href={activeDownload.href}
size={size}
variant={variant}
className={twMerge(
fullWidth ? "flex-1 w-full" : undefined,
"pr-4",
)}
>
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download Launcher
</Button>
<button
type="button"
onClick={() => setMenuOpen((prev) => !prev)}
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label="Other Linux download formats"
className={twMerge(
"flex items-center justify-center transition-colors duration-150 cursor-pointer",
size === "lg" ? "h-12 px-3.5" : size === "sm" ? "px-2 py-1.5" : "px-3 py-2",
isPrimary
? "bg-invert-bg text-invert-fg border-l border-invert-fg/20 hover:opacity-80"
: "border border-line-strong border-l-0 bg-bg text-ink hover:bg-invert-bg hover:text-invert-fg",
)}
>
<ChevronDownIcon
aria-hidden
className={twMerge(
"h-4 w-4 transition-transform duration-150",
menuOpen && "rotate-180",
)}
/>
</button>
{menuOpen && (
<div
role="menu"
className="absolute top-full left-0 z-50 mt-1 w-full min-w-[280px] border border-line bg-bg-raised p-1 shadow-lg text-left"
>
<div className="border-b border-line px-3 py-2">
<p className="hud-label text-[11px] text-faint">Select Linux format</p>
</div>
<ul className="flex flex-col">
{linuxOptions.map((opt) => {
const isDefault = opt.fileName.endsWith(".flatpak");
const isSelected = activeDownload.fileName === opt.fileName;
return (
<li key={opt.href}>
<a
href={opt.href}
download={opt.fileName}
role="menuitem"
onClick={() => {
setSelectedDownload(opt);
setMenuOpen(false);
}}
className={twMerge(
"group/opt flex items-center justify-between gap-3 p-2.5 text-left transition-colors duration-150 hover:bg-bg-sunken",
isSelected && "bg-ink/5",
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="block text-sm font-medium text-ink">
{opt.label}
</span>
{isDefault && (
<span className="hud-label text-[10px] text-(--app-ink,var(--accent-ink))">
Default
</span>
)}
</span>
<span className="block text-xs text-muted">
{opt.description}
</span>
<span className="mt-0.5 block truncate font-mono text-[10px] text-faint">
{opt.fileName}
</span>
</span>
<ArrowDownToLineIcon
aria-hidden
className="h-4 w-4 shrink-0 text-muted group-hover/opt:text-ink"
/>
</a>
</li>
);
})}
</ul>
</div>
)}
</div>
<span aria-hidden={!activeDownload} className="hud-label min-h-4 w-0 min-w-full whitespace-nowrap text-faint">
{activeDownload ? `For Linux · ${activeDownload.label}` : "\u00a0"}
</span>
</span>
);
}
return (
<span className={twMerge("flex flex-col gap-2", className)}>
<Button
href={mine ? mine.href : fallbackHref}
size={size}
variant={variant}
className={fullWidth ? "w-full" : undefined}
>
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download Launcher
</Button>
{/* Reserved line, so the layout doesn't shift when detection lands. It
takes the button's width rather than setting it (w-0 min-w-full), so
a long platform line can't push the buttons beside it away. */}
<span aria-hidden={!mine} className="hud-label min-h-4 w-0 min-w-full whitespace-nowrap text-faint">
{mine ? `For ${mine.group} · ${mine.label}` : "\u00a0"}
</span>
</span>
);
}
function linuxFormatPriority(fileName: string): number {
if (fileName.endsWith(".flatpak")) return 1;
if (fileName.endsWith(".AppImage")) return 2;
if (fileName.endsWith(".deb")) return 3;
if (fileName.endsWith(".rpm")) return 4;
if (fileName.endsWith(".tar.gz")) return 5;
return 6;
}
/**
* Shown under the hero once the direct download is live, so visitors on
* another machine, CPU or package format can still find their build.
@@ -2,11 +2,14 @@ import Link from "next/link";
import type { ReactNode } from "react";
import { ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { CampaignSection } from "@/components/campaign/sections";
import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import LazyVideo from "@/components/lazy-video";
import { Badge, Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import { CRAFT_LAUNCHER_RELEASE } from "@/lib/crafting-app-releases";
import {
CRAFTING_APPS,
craftAppBuildCommand,
@@ -14,6 +17,7 @@ import {
craftAppName,
craftAppRelease,
craftAppRepo,
craftDemoPosterUrl,
craftReleaseDownloads,
craftReleasePageUrl,
craftReleasesUrl,
@@ -21,10 +25,12 @@ import {
craftShotUrl,
formatList,
type CraftApp,
type CraftDemo,
} from "@/lib/crafting-apps";
import AppCard from "./app-card";
import { AppPlatformDownloads, DownloadRow } from "./app-downloads";
import { ColorAccent } from "./app-wordmark";
import { LauncherCallout } from "./launcher-callout";
// Body sections of an /apps/<slug> page, in page order. Server components;
// the client islands are the CopyButton and the OS-aware platform cards.
@@ -32,6 +38,50 @@ import { ColorAccent } from "./app-wordmark";
const CELL_HEADING_CLASSES =
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
// A feature clip (apps with a `demo`), directly under the hero.
export function AppDemo({
app,
demo,
index,
}: {
app: CraftApp;
demo: CraftDemo;
index: string;
}) {
const [before, accent, after] = demo.headline;
return (
<CampaignSection
id="demo"
index={index}
label={demo.label}
annotation={craftAppName(app)}
title={
<>
{before}
<ColorAccent>{accent}</ColorAccent>
{after}
</>
}
lede={demo.lede}
>
<figure data-reveal className="border-t border-line">
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
<LazyVideo
src={demo.src}
poster={craftDemoPosterUrl(app, demo)}
label={demo.caption}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
<figcaption className="flex items-center justify-between gap-4 border-t border-line px-6 py-3 md:px-10">
<span className="hud-label text-muted">{demo.caption}</span>
<span className="hud-label text-faint">Video</span>
</figcaption>
</figure>
</CampaignSection>
);
}
// The hero carries shot 01. Window captures pair up, and an odd one out
// leads at full width; portrait pages (layout spreads) sit four across.
export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
@@ -64,7 +114,11 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
rel="noopener noreferrer"
className={twMerge(
"group relative block overflow-hidden bg-bg-sunken",
shot.portrait ? "aspect-[7/9]" : "aspect-[16/10]",
shot.portrait
? "aspect-[7/9]"
: shot.wide
? "aspect-video"
: "aspect-[16/10]",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -98,7 +152,8 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
);
}
// Platform cards (your system first-class, every other build one click
// ArtCraft Launcher first (installs and updates every app), then this app's
// own platform cards (your system first-class, every other build one click
// away) above release details and build-from-source. Versions and files come
// from crafting-app-releases.ts; a null release there drops the cards and
// shows the Discord waitlist instead.
@@ -118,9 +173,27 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
annotation="Free · Open source"
/>
{release && (
<div className="border-b border-line">
<AppPlatformDownloads downloads={downloads} />
</div>
<>
{CRAFT_LAUNCHER_RELEASE && (
<>
<LauncherCallout app={app} />
<div data-reveal className="border-y border-line bg-bg p-6 md:p-10">
<p className="hud-label text-faint">Option 2 · Standalone</p>
<h3 className={CELL_HEADING_CLASSES}>
Just {name}? Download it <ColorAccent>standalone</ColorAccent>.
</h3>
<p className="mt-4 max-w-xl leading-relaxed text-muted">
These installers add {name} on its own, without the
launcher. Come back here for new versions, or install the
launcher later and it will find {name} and keep it updated.
</p>
</div>
</>
)}
<div className="border-b border-line">
<AppPlatformDownloads name={name} downloads={downloads} />
</div>
</>
)}
<div className="grid gap-px bg-line md:grid-cols-2">
<div data-reveal className="flex flex-col bg-bg p-6 md:p-10">
@@ -0,0 +1,99 @@
import { ArrowUpRightIcon, CheckIcon } from "lucide-react";
import {
CRAFT_LAUNCHER_NAME,
CRAFT_LAUNCHER_REPO,
craftLauncherDownloads,
craftLauncherReleasePageUrl,
} from "@/lib/craft-launcher";
import { CRAFT_LAUNCHER_RELEASE } from "@/lib/crafting-app-releases";
import { craftAppName, type CraftApp, type CraftDownload } from "@/lib/crafting-apps";
import { LauncherDownloadButton } from "./app-downloads";
import { ColorAccent } from "./app-wordmark";
// ArtCraft Launcher on the Crafting Apps pages: the "Recommended" callout
// that leads every app's Get-it section. The /apps hub header and every app
// page's hero use craftLauncherRecommended for their main download button. Server component;
// the download button is the client island.
const CALLOUT_POINTS = ["Every Crafting App", "One-click updates", "Verified downloads"];
/** The launcher's main download per platform, for the OS-aware buttons. */
export function craftLauncherRecommended(): CraftDownload[] {
return craftLauncherDownloads().filter((download) => download.recommended);
}
// Leads an app's Get-it section, above that app's own installers: the pitch
// beside its download panel, matching the release and build-from-source
// cells below it. Renders nothing while the launcher has no release.
export function LauncherCallout({ app }: { app: CraftApp }) {
const recommended = craftLauncherRecommended();
if (!CRAFT_LAUNCHER_RELEASE || recommended.length === 0) return null;
const name = craftAppName(app);
return (
<div
data-reveal
className="grid gap-px bg-line lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]"
>
<div className="flex flex-col bg-bg p-6 md:p-10">
<div className="flex items-center gap-3">
<LauncherMark />
<p className="hud-label text-(--app-ink,var(--accent-ink))">
Option 1 · Recommended
</p>
</div>
<h3 className="mt-6 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl">
Get {name} with the <ColorAccent>Launcher</ColorAccent>.
</h3>
<p className="mt-4 max-w-xl leading-relaxed text-muted">
{CRAFT_LAUNCHER_NAME} installs {name} and every other Crafting App
from one place, picks the right build for your computer and keeps
them all up to date. Install it once and every update is a click
away.
</p>
<ul className="mt-6 flex flex-wrap gap-x-6 gap-y-2">
{CALLOUT_POINTS.map((point) => (
<li key={point} className="hud-label flex items-center gap-1.5 text-muted">
<CheckIcon aria-hidden className="h-3.5 w-3.5 text-(--app-ink,var(--accent-ink))" />
{point}
</li>
))}
</ul>
</div>
<div className="flex flex-col justify-center bg-bg p-6 md:p-10">
<p className="hud-label text-faint">
{CRAFT_LAUNCHER_NAME} · v{CRAFT_LAUNCHER_RELEASE.version}
</p>
<LauncherDownloadButton
recommended={recommended}
allDownloads={craftLauncherDownloads()}
fallbackHref={craftLauncherReleasePageUrl()}
fullWidth
className="mt-4"
/>
<a
href={CRAFT_LAUNCHER_REPO}
target="_blank"
rel="noopener noreferrer"
className="hud-label mt-4 flex w-fit items-center gap-1.5 text-muted hover:text-ink"
>
About the Launcher
<ArrowUpRightIcon aria-hidden className="h-3.5 w-3.5" />
</a>
</div>
</div>
);
}
// The ArtCraft mark in a hairline square, like the platform icons in the
// download cards. Decorative — it always sits beside the launcher's name.
function LauncherMark() {
return (
<span
aria-hidden
className="flex h-10 w-10 shrink-0 items-center justify-center border border-line"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/artcraft-icon.svg" alt="" className="h-5 w-5" />
</span>
);
}
@@ -7,10 +7,12 @@ import { useEffect, useRef } from "react";
// ambient motion.
export default function LazyVideo({
src,
poster,
className,
label,
}: {
src: string;
poster?: string;
className?: string;
label?: string;
}) {
@@ -45,6 +47,7 @@ export default function LazyVideo({
<video
ref={ref}
src={src}
poster={poster}
className={className}
muted
loop
@@ -16,6 +16,7 @@ export function PageHeader({
title,
lede,
children,
aside,
align = "left",
className,
}: {
@@ -28,10 +29,31 @@ export function PageHeader({
lede?: ReactNode;
/** Extra content under the lede (CTAs, back links, …). */
children?: ReactNode;
/** Beside the text from xl up (vertically centered), beneath it below. */
aside?: ReactNode;
align?: "left" | "center";
className?: string;
}) {
const centered = align === "center";
const text = (
<>
<h1
data-reveal
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
>
{title}
</h1>
{lede && (
<p
data-reveal
className="mt-5 max-w-xl text-lg leading-relaxed text-muted"
>
{lede}
</p>
)}
{children}
</>
);
return (
<SectionShell id={id} className={className}>
<SectionEyebrow index={index} label={label} annotation={annotation} />
@@ -39,23 +61,11 @@ export function PageHeader({
className={twMerge(
"px-6 py-14 md:px-10 md:py-20",
centered && "flex flex-col items-center text-center",
aside && "xl:flex xl:items-center xl:justify-between xl:gap-12",
)}
>
<h1
data-reveal
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
>
{title}
</h1>
{lede && (
<p
data-reveal
className="mt-5 max-w-xl text-lg leading-relaxed text-muted"
>
{lede}
</p>
)}
{children}
{aside ? <div className="min-w-0">{text}</div> : text}
{aside}
</div>
</SectionShell>
);
@@ -0,0 +1,31 @@
import { CRAFT_LAUNCHER_RELEASE } from "./crafting-app-releases";
import {
CRAFTING_APPS_GITHUB_ORG,
craftReleaseTag,
releaseDownloads,
type CraftDownload,
} from "./crafting-apps";
// ArtCraft Launcher: one small native app that installs, updates and opens
// every Crafting App. The /apps hub's main button downloads it and every
// /apps/<slug> Get-it section recommends it above that app's own installers.
//
// Facts come from the craft-launcher README — keep them in sync. Version and
// files live in crafting-app-releases.ts (CRAFT_LAUNCHER_RELEASE); null there
// hides every launcher download.
export const CRAFT_LAUNCHER_NAME = "ArtCraft Launcher";
export const CRAFT_LAUNCHER_REPO = `${CRAFTING_APPS_GITHUB_ORG}/craft-launcher`;
/** Every launcher release file, or none while the release is null. */
export function craftLauncherDownloads(): CraftDownload[] {
return CRAFT_LAUNCHER_RELEASE
? releaseDownloads(CRAFT_LAUNCHER_REPO, "artcraft-launcher", CRAFT_LAUNCHER_RELEASE)
: [];
}
export function craftLauncherReleasePageUrl(): string {
return CRAFT_LAUNCHER_RELEASE
? `${CRAFT_LAUNCHER_REPO}/releases/tag/${craftReleaseTag(CRAFT_LAUNCHER_RELEASE)}`
: `${CRAFT_LAUNCHER_REPO}/releases`;
}
@@ -105,20 +105,20 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
description: "Unzip and run on Windows on ARM.",
arch: "aarch64",
},
{
file: "{slug}-{version}-linux-x86_64.AppImage",
group: "Linux",
label: "AppImage (x86_64)",
description: "Any distro. Mark it executable and run.",
arch: "x86_64",
recommended: true,
},
{
file: "{slug}-{version}-linux-x86_64.flatpak",
group: "Linux",
label: "Flatpak (x86_64)",
description: "Sandboxed. Install with flatpak install --user.",
arch: "x86_64",
recommended: true,
},
{
file: "{slug}-{version}-linux-x86_64.AppImage",
group: "Linux",
label: "AppImage (x86_64)",
description: "Any distro. Mark it executable and run.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-x86_64.deb",
@@ -141,20 +141,20 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
description: "Plain binaries to unpack anywhere.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-aarch64.AppImage",
group: "Linux",
label: "AppImage (ARM64)",
description: "Any distro on 64-bit ARM.",
arch: "aarch64",
recommended: true,
},
{
file: "{slug}-{version}-linux-aarch64.flatpak",
group: "Linux",
label: "Flatpak (ARM64)",
description: "Sandboxed, on 64-bit ARM.",
arch: "aarch64",
recommended: true,
},
{
file: "{slug}-{version}-linux-aarch64.AppImage",
group: "Linux",
label: "AppImage (ARM64)",
description: "Any distro on 64-bit ARM.",
arch: "aarch64",
},
{
file: "{slug}-{version}-linux-aarch64.deb",
@@ -204,13 +204,28 @@ export function craftRelease(
return { version, assets };
}
// ArtCraft Launcher (github.com/storytold/craft-launcher) installs and updates
// every app above, and the /apps pages recommend it first. Its releases ship
// the same file set minus the macOS CLI, named artcraft-launcher-*.
export const CRAFT_LAUNCHER_RELEASE: CraftRelease | null = craftRelease(
"0.3.2",
CRAFT_RELEASE_ASSETS.filter((asset) => !asset.file.startsWith("{slug}-cli-")),
);
// Every app must be listed, so a new slug fails the type check until it is.
export const CRAFT_APP_RELEASES: Record<CraftAppSlug, CraftRelease | null> = {
photocraft: craftRelease("0.5.0"),
vectorcraft: craftRelease("0.6.0"),
filmcraft: craftRelease("0.4.0"),
lightcraft: craftRelease("0.4.0"),
pdfcraft: craftRelease("0.4.0"),
effectcraft: craftRelease("0.6.0"),
designcraft: craftRelease("0.4.0"),
photocraft: craftRelease("0.7.0"),
vectorcraft: craftRelease("0.9.0"),
filmcraft: craftRelease("0.6.0"),
lightcraft: craftRelease("0.6.0"),
pdfcraft: craftRelease("0.6.0"),
effectcraft: craftRelease("0.8.0"),
designcraft: craftRelease("0.6.0"),
figcraft: craftRelease("0.5.1"),
loopcraft: null,
wordcraft: craftRelease("0.5.0"),
soundcraft: craftRelease("0.5.0"),
deckcraft: craftRelease("0.5.0"),
cadcraft: craftRelease("0.5.0"),
gridcraft: craftRelease("0.5.0"),
};
@@ -18,7 +18,8 @@ import { mediaUrl } from "./links";
// release there is null show a Discord waitlist instead of installers.
//
// Assets live in public/images/apps/<slug>/: icon.webp (the repo's 512 px
// hicolor render at 256 px), og.jpg (1200×630 share card) and the shots.
// hicolor render at 256 px), og.jpg (1200×630 share card), the shots and,
// for an app with a demo, its poster frame.
export type CraftAppSlug =
| "photocraft"
@@ -27,7 +28,14 @@ export type CraftAppSlug =
| "lightcraft"
| "pdfcraft"
| "effectcraft"
| "designcraft";
| "designcraft"
| "figcraft"
| "loopcraft"
| "wordcraft"
| "soundcraft"
| "deckcraft"
| "cadcraft"
| "gridcraft";
export type CraftPlatform = "macOS" | "Windows" | "Linux" | "Web";
@@ -40,6 +48,21 @@ export type CraftShot = {
alt: string;
/** Portrait page (7:9) rather than a 16:10 window capture. */
portrait?: boolean;
/** 16:9 window capture rather than 16:10. */
wide?: boolean;
};
/** A feature clip with its own section under the hero. */
export type CraftDemo = {
/** Muted, looping 16:9 clip. */
src: string;
/** Still under public/images/apps/<slug>/, shown until the clip plays. */
poster: string;
label: string;
/** Headline split around its one color-accented contrast word. */
headline: [before: string, accent: string, after: string];
lede: string;
caption: string;
};
export type CraftApp = {
@@ -60,6 +83,7 @@ export type CraftApp = {
features: Tip[];
/** First shot is the hero. */
shots: [CraftShot, ...CraftShot[]];
demo?: CraftDemo;
};
/** One release file, resolved to a URL for a specific app. */
@@ -535,9 +559,523 @@ export const CRAFTING_APPS: CraftApp[] = [
},
],
},
{
slug: "figcraft",
prefix: "Fig",
category: "Interface design",
status: "Early alpha",
headline: ["Interface design, ", "rebuilt", " in pure Rust."],
pitch: "Open-source interface design and prototyping, rebuilt in pure Rust.",
lede: "Frames, auto layout, components and variants, vector networks, styles, variables and prototypes in a native app that opens instantly and works offline. No account, no telemetry, no subscription.",
schemaCategory: "DesignApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.95",
features: [
{
title: "Familiar by design",
body: "The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. Productive on day one, no retraining.",
},
{
title: "Layouts that build themselves",
body: "Auto layout in both directions with wrap and grid, padding, gaps and Hug, Fill and Fixed sizing, plus constraints and layout grids for everything else.",
},
{
title: "Components that scale",
body: "Instances with overrides that survive edits, component sets with variants and properties, and styles and variables with modes for light and dark.",
},
{
title: "Real vector networks",
body: "Pen and pencil, a shape builder, booleans whose operands stay editable, variable-width strokes and smooth corners.",
},
{
title: "Prototype and hand off",
body: "Connections, triggers and animated transitions, a presentation view, and Dev Mode with measurements, annotations and generated CSS, HTML, React, SwiftUI and Compose code.",
},
{
title: "Agent-ready",
body: "Every menu item, tool and gesture runs one of 400+ commands, so the CLI, a JSON control channel and an MCP server can do anything you can click.",
},
],
shots: [
{
file: "hero.webp",
source: "shot-1-hero.png",
caption: "Ridgeline — auto layout screens in light and dark",
alt: "FigCraft editing the Ridgeline hiking app: three phone screens on the canvas, the Featured trail card instance selected, the layers panel showing its auto layout tree, and the Design panel showing Fill and Hug sizing and the Theme variable mode",
},
{
file: "components.webp",
source: "shot-2-components.png",
caption: "Components, variants and the Assets panel",
alt: "FigCraft's Assets panel with live component thumbnails beside a Button component set of nine variants, and the Design panel listing its Style and State variant properties",
},
{
file: "prototype.webp",
source: "shot-3-prototype.png",
caption: "Prototype connections and flows",
alt: "Four onboarding phone screens in FigCraft's Prototype tab joined by connection curves, with the Prototype panel showing an On click, Navigate to interaction",
},
{
file: "dev-mode.webp",
source: "shot-4-devmode.png",
caption: "Dev Mode with annotations and generated code",
alt: "FigCraft's Dev Mode on the Ridgeline home screen with a Ready for dev badge, an annotation callout, and the Inspect panel showing generated React code for the selected trail row",
},
{
file: "vector.webp",
source: "shot-5-vector.png",
caption: "Vector networks and variable-width strokes",
alt: "FigCraft's vector edit mode on a wave path with a tapered gradient stroke, its anchor points and bezier handles visible, above a boolean-built mountain mark and a knot of excluded rings",
},
{
file: "dashboard.webp",
source: "shot-6-dashboard-light.png",
caption: "Layout grids and styles, light theme",
alt: "FigCraft in its light theme with a web analytics dashboard of KPI cards, bar, donut and area charts, and the frame's 12-column layout grid showing as tinted columns",
},
{
file: "collab.webp",
source: "shot-7-collab.png",
caption: "Live session with cursor chat and spotlight",
alt: "A FigCraft live session with three participant avatars, a spotlight banner with a Follow button, two collaborators' cursors and selections on the canvas, and a cursor chat bubble",
},
],
demo: {
src: "https://frontend-cdn.fakeyou.com/videos/figcraft-collab-web.mp4",
poster: "collab-poster.webp",
label: "Live sessions",
headline: ["Design ", "together", ", in real time."],
lede: "Host the open file from the app or a server of your own, share the invite link, and edit it together. Changes sync live with cursors, selections and cursor chat, and undo takes back only your own.",
caption: "A host and a guest editing one file",
},
},
{
slug: "loopcraft",
prefix: "Loop",
category: "Beat making & music production",
status: "Early alpha",
headline: ["Beats, patterns and ", "songs", ", in pure Rust."],
pitch: "An open-source beat maker and music production studio, built in pure Rust.",
lede: "A pattern-based music workstation: a step sequencer and piano roll for writing parts, a playlist for arranging them, and a mixer with effects for finishing them. Native, open source, and drivable end to end by AI agents.",
schemaCategory: "MultimediaApplication",
platforms: ["macOS", "Windows", "Linux"],
rustVersion: "1.95",
features: [
{
title: "Step sequencer",
body: "Program drums in the channel rack, up to four bars a row, with swing, step fills and a graph editor for each step's velocity, pan, pitch and shift.",
},
{
title: "A deep piano roll",
body: "Draw, paint and slice notes, stamp chords and scale runs, then quantise, strum, arpeggiate or humanise them, with slide notes and ghost notes from other channels.",
},
{
title: "Arrange in the playlist",
body: "Place patterns and audio clips on tracks, resize, slice and duplicate them, drop time markers, and draw automation for any knob, fader or the tempo.",
},
{
title: "Instruments and effects",
body: "Twelve instruments, from a sampler and synths to drum machines and a slicer, and twenty-four effects, each with factory presets. The drum sounds are synthesised, not sampled.",
},
{
title: "Record and mix",
body: "Play parts in from a MIDI or computer keyboard, record audio takes, and mix on a master and twenty insert tracks with sends and sidechain compression.",
},
{
title: "Agent-ready",
body: "Every action is a command with an id, so a command-line tool, an MCP server and a control channel can write, arrange and export a track end to end.",
},
],
shots: [
{
file: "hero.webp",
source: "loopcraft-hero.png",
caption: "First Light — channel rack, mixer and playlist",
alt: "LoopCraft with its demo song open: the browser on the left, the channel rack with its step sequencer, the mixer with an effect rack, and the playlist with coloured pattern clips",
wide: true,
},
{
file: "light.webp",
source: "light-theme.png",
caption: "Light theme",
alt: "LoopCraft in its light theme: the same windows on white panels with a dark application bar",
wide: true,
},
{
file: "piano-roll.webp",
source: "piano-roll.png",
caption: "Piano roll with a velocity lane",
alt: "LoopCraft's piano roll showing a melody as green notes on a grid beside a piano keyboard, with a velocity lane underneath",
wide: true,
},
{
file: "instruments.webp",
source: "instrument-and-mixer.png",
caption: "Instrument editor over the playlist and mixer",
alt: "LoopCraft's three-oscillator instrument editor, a grid of knobs for the oscillators, an envelope and a filter, floating over the playlist and mixer",
wide: true,
},
],
},
{
slug: "wordcraft",
prefix: "Word",
category: "Word processing",
status: "In development",
headline: ["A fast, open-source ", "word processor", ", rebuilt in pure Rust."],
pitch: "Fast, open-source word processing, rebuilt in pure Rust.",
lede: "The ribbon, styles, tables, track changes, references and mail merge in a native word processor that reads and writes .docx. Open source, offline, and fast.",
schemaCategory: "BusinessApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The ribbon you know",
body: "Tabs, groups, shortcuts and the live Styles gallery. Enter continues a list, Tab demotes it, and formatting works as your hands expect.",
},
{
title: "Real document formats",
body: "Opens and saves .docx natively, imports and exports .odt, .rtf, .html, .md and .txt, and exports clean PDF with selectable text.",
},
{
title: "Instant layout",
body: "Paragraph layout is cached: typing in a 188-page document re-lays it out in about 1.4 ms, with pages rendered on demand.",
},
{
title: "Track changes & review",
body: "Comment balloons in the margin, accept and reject changes, document comparison, plus offline spelling and grammar as you type.",
},
{
title: "Academic & business references",
body: "Generate tables of contents, footnotes, citations and bibliographies in APA, MLA, Chicago and IEEE styles, indexes, and captions.",
},
{
title: "Agent-ready commands",
body: "389 discrete commands drive the ribbon, keyboard shortcuts, a command-line tool, a JSON control channel and an MCP server.",
},
],
shots: [
{
file: "hero.webp",
source: "hero.png",
caption: "The Open Studio Handbook — ribbon, styles and navigation",
alt: "WordCraft with the Home tab of the ribbon open over a two-page document titled The Open Studio Handbook, the Navigation pane on the left, and the Styles gallery showing live previews",
},
{
file: "review.webp",
source: "review.png",
caption: "Review and track changes",
alt: "WordCraft's Review tab with Track Changes on, inline insertions and deletions, and margin comment balloons",
},
{
file: "references.webp",
source: "references.png",
caption: "Table of contents and citations",
alt: "WordCraft's References tab showing a table of contents with dot leaders on page one and citations on page two",
},
{
file: "layout.webp",
source: "layout.png",
caption: "Drop caps and text wrap",
alt: "WordCraft layout tab showing a three-line drop cap, text wrapping around an object, and margin line numbers",
},
{
file: "dark.webp",
source: "dark.png",
caption: "Dark mode with formatting marks",
alt: "WordCraft in dark mode with the Insert tab open and formatting marks visible",
},
],
},
{
slug: "soundcraft",
prefix: "Sound",
category: "Digital audio workstation",
status: "In development",
headline: ["Professional audio recording and ", "mixing", ", rebuilt in pure Rust."],
pitch: "Professional audio recording, editing and mixing in pure Rust.",
lede: "Dedicated Edit and Mix windows, multitrack audio and MIDI, ten inserts and sends per track, surround panning, and offline AudioSuite in a native DAW built from scratch.",
schemaCategory: "MultimediaApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The studio workflow",
body: "Dedicated Edit and Mix windows, Shuffle, Slip, Spot and Grid modes, comping playlists, memory locations, and single-key Commands Focus shortcuts.",
},
{
title: "Full mixing console",
body: "Ten inserts and ten sends per track, busses, aux inputs, VCAs, routing folders, solo-in-place, and automatic plugin delay compensation.",
},
{
title: "Live automation",
body: "Write, Touch, Latch and Trim automation passes recorded live for volume, pan, mutes, sends and every plugin parameter.",
},
{
title: "Original plugins & CLAP support",
body: "Built-in EQ, dynamic processors, reverbs, delays, modulation, synthesisers, plus support for loading external CLAP plugins.",
},
{
title: "MIDI & notation",
body: "Piano-roll MIDI editor with velocity lane, step input, quantize, transposition, score notation editor, and Audio-to-MIDI pitch detection.",
},
{
title: "Surround & video sync",
body: "Surround panning up to 9.1.6, live speaker rendering, and a dedicated picture track with video playback synchronized to the playhead.",
},
],
shots: [
{
file: "hero.webp",
source: "edit.png",
caption: "Edit window — arranging and editing the demo session",
alt: "SoundCraft Edit window showing audio waveforms, MIDI tracks, rulers, markers and transport controls for a multi-track session",
},
{
file: "mix.webp",
source: "mix.png",
caption: "Mix console with inserts and sends",
alt: "SoundCraft Mix window with ten inserts and sends per track, pan knobs, faders, meters and an EQ plugin window",
},
{
file: "midi.webp",
source: "midi.png",
caption: "Piano-roll MIDI editor",
alt: "SoundCraft MIDI editor showing note velocity lanes and piano roll editing",
},
{
file: "automation.webp",
source: "automation.png",
caption: "Breakpoint automation and memory locations",
alt: "SoundCraft displaying volume automation curves on a track alongside the Memory Locations window",
},
{
file: "surround.webp",
source: "surround.png",
caption: "Immersive surround renderer",
alt: "SoundCraft Renderer window displaying a 7.1.4 surround speaker layout and spatial panners",
},
],
},
{
slug: "deckcraft",
prefix: "Deck",
category: "Presentations & slide shows",
status: "In development",
headline: ["Fast, dynamic slide ", "presentations", ", in pure Rust."],
pitch: "Build and deliver dynamic slide presentations, in pure Rust.",
lede: "Slide masters, themes, 150+ vector shapes, transitions, animations and presenter view in a native app that opens and saves presentation files. No subscription, no cloud.",
schemaCategory: "BusinessApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "Master slides & themes",
body: "Slide masters and eleven layouts with inheriting placeholders, eight original themes with font and colour schemes, sections, and presenter notes.",
},
{
title: "Vector shapes & drawing",
body: "Over 150 preset shapes with adjustment handles, sticky connectors, curve booleans, gradient fills, and outline styling.",
},
{
title: "Transitions & animation",
body: "Slide transitions including Morph, entrance, emphasis, exit and motion-path animations, with by-paragraph builds.",
},
{
title: "Presenter view",
body: "Full-screen slide show delivery with speaker notes, upcoming slide previews, elapsed timers, rehearsal timings, and pen drawing.",
},
{
title: "Format compatibility",
body: "Open and save presentation files (.pptx), export vector PDF slides and handouts, and render slide images.",
},
{
title: "Automated with MCP",
body: "Over 200 commands scriptable from the CLI, a JSON control channel, and an MCP server for autonomous agents.",
},
],
shots: [
{
file: "hero.webp",
source: "editor.png",
caption: "Slide editor with process diagram",
alt: "DeckCraft slide editor showing the ribbon, slide thumbnails, notes panel, and a vector process diagram on the canvas",
},
{
file: "shape-format.webp",
source: "shape-format.png",
caption: "Shape formatting and geometry",
alt: "DeckCraft Shape Format tab with selection handles and alignment tools on an active shape",
},
{
file: "format-pane.webp",
source: "format-pane.png",
caption: "Format Shape pane and gradient stops",
alt: "DeckCraft Format Shape pane configuring gradient fill angles, stops and transparency",
},
{
file: "slide-sorter.webp",
source: "slide-sorter.png",
caption: "Slide Sorter view with transitions",
alt: "DeckCraft Slide Sorter showing all slides in the deck with assigned transition badges",
},
{
file: "dark-mode.webp",
source: "dark-mode.png",
caption: "Dark mode with chart slide",
alt: "DeckCraft dark mode editing a presentation slide featuring a native chart",
},
],
},
{
slug: "cadcraft",
prefix: "CAD",
category: "Computer-aided design & drafting",
status: "In development",
headline: ["Computer-aided design and drafting, ", "reimagined", " in pure Rust."],
pitch: "Fast, open-source computer-aided design and drafting in pure Rust.",
lede: "The command line, object snaps, layers, dimensions, hatches, blocks and drawings you already know. Fast, native, and built for both people and AI agents.",
schemaCategory: "DesignApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The drafting workflow",
body: "Type commands, click points, direct distance entry, object snaps, polar tracking, ortho, grips, and right-click-to-repeat.",
},
{
title: "Open drawing formats",
body: "Reads and writes DXF (ASCII and binary, R12–2018), opens DWG (R13–2018), and exports to PDF, SVG, and PNG.",
},
{
title: "Paper space & viewports",
body: "Layout sheets with floating viewports, per-viewport layer overrides, page setups, and scale-exact plotting.",
},
{
title: "Associative annotations",
body: "Linear, aligned, angular and radial dimensions that follow geometry, multileaders, tables, and custom drafting fonts.",
},
{
title: "Hatching & block libraries",
body: "Pick-point boundary hatching with islands, pattern fills, block definitions with attributes, and nested blocks.",
},
{
title: "Parametric constraints",
body: "Geometric and dimensional constraints with expression parameters that automatically re-solve as you edit.",
},
],
shots: [
{
file: "hero.webp",
source: "ui-apartment.png",
caption: "Apartment floor plan with hatching and dimensions",
alt: "CADCraft displaying an architectural floor plan with pick-point wall hatching, room schedules, dimensions and the command line",
},
{
file: "layout.webp",
source: "ui-layout.png",
caption: "Paper space layout with scaled viewport",
alt: "CADCraft paper space layout showing a title block sheet with an active scaled model viewport",
},
{
file: "bracket.webp",
source: "ui-bracket.png",
caption: "Mechanical part drawing with section hatch",
alt: "CADCraft drafting a mechanical mounting bracket with section lines, dimensions and title block",
},
],
},
{
slug: "gridcraft",
prefix: "Grid",
category: "Spreadsheets & data analysis",
status: "In development",
headline: ["Fast, native spreadsheets, ", "rebuilt", " in pure Rust."],
pitch: "Fast, open-source spreadsheets and data analysis, rebuilt in pure Rust.",
lede: "Ribbon tabs, formula bar, 500+ calculation functions, dynamic arrays, PivotTables and charts in a native spreadsheet app that reads and writes .xlsx files.",
schemaCategory: "BusinessApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The spreadsheet workflow",
body: "Formula bar, Name Box, sheet tabs, fill handle, colored cell references, conditional formatting, and familiar shortcuts.",
},
{
title: "Native XLSX compatibility",
body: "Styles, themes, formulas, dynamic arrays, tables, validation, comments, sparklines and PivotTables round-trip cleanly.",
},
{
title: "500+ calculation functions",
body: "Full dependency-graph calculation engine with dynamic arrays, spilling, LET, LAMBDA, MAP, REDUCE, and table references.",
},
{
title: "Charts & PivotTables",
body: "Seventeen chart types with customizable formatting, plus PivotTables with filters, field lists, layouts, and date grouping.",
},
{
title: "High-performance engine",
body: "Copy-on-write workbooks with instant undo snapshots, incremental recalculation, and a virtualised grid.",
},
{
title: "Agent-native automation",
body: "Every ribbon button, menu item, and formula can be driven by AI agents over an MCP server, CLI, or JSON control channel.",
},
],
shots: [
{
file: "hero.webp",
source: "hero-sales.png",
caption: "Quarterly sales dashboard with charts and sparklines",
alt: "GridCraft sales dashboard featuring a styled table with conditional formatting, sparklines, and column and pie charts",
},
{
file: "budget.webp",
source: "budget.png",
caption: "Household budget with conditional formatting",
alt: "GridCraft budget sheet showing accounting currency formats, color scales, and comparison bar charts",
},
{
file: "grades-formulas.webp",
source: "grades-formulas.png",
caption: "Formulas and function library",
alt: "GridCraft formula bar and Formulas ribbon tab calculating averages and letter grades",
},
{
file: "pivot.webp",
source: "pivot.png",
caption: "PivotTable and field list",
alt: "GridCraft PivotTable report summarizing data with the PivotTable Fields pane open on the right",
},
{
file: "format-chart.webp",
source: "format-chart.png",
caption: "Chart Design and Format Chart Area pane",
alt: "GridCraft configuring a column chart with the Format Chart Area pane and highlighted source range",
},
],
},
];
const COUNT_WORDS = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
const COUNT_WORDS = [
"Zero",
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Ten",
"Eleven",
"Twelve",
"Thirteen",
"Fourteen",
"Fifteen",
"Sixteen",
];
/** "Seven" — the family size as a capitalized word, for headlines. */
export const CRAFTING_APPS_COUNT_WORD =
@@ -618,6 +1156,10 @@ export function craftShotSourceUrl(app: CraftApp, shot: CraftShot): string {
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/docs/images/${shot.source}`;
}
export function craftDemoPosterUrl(app: CraftApp, demo: CraftDemo): string {
return mediaUrl(`/images/apps/${app.slug}/${demo.poster}`);
}
export function craftAppIconPath(app: CraftApp): string {
return `/images/apps/${app.slug}/icon.webp`;
}
@@ -652,10 +1194,19 @@ export function craftReleaseDownloads(
app: CraftApp,
release: CraftRelease,
): CraftDownload[] {
const base = `${craftAppRepo(app)}/releases/download/${craftReleaseTag(release)}`;
return releaseDownloads(craftAppRepo(app), app.slug, release);
}
/** Every file in a release of `repo`, in catalog order; `{slug}` defaults to `fileSlug`. */
export function releaseDownloads(
repo: string,
fileSlug: string,
release: CraftRelease,
): CraftDownload[] {
const base = `${repo}/releases/download/${craftReleaseTag(release)}`;
return release.assets.map(({ file, ...asset }) => {
const fileName = file
.replaceAll("{slug}", release.fileSlug ?? app.slug)
.replaceAll("{slug}", release.fileSlug ?? fileSlug)
.replaceAll("{version}", release.version);
return { ...asset, fileName, href: `${base}/${fileName}` };
});