diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9f9c4f4bd..c8097ab47 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -107,7 +107,10 @@ jobs: tool: wasm-bindgen@${{ steps.wasm-bindgen-version.outputs.version }} - name: Build web bundle - run: trunk build --locked --release --public-url /app/ --dist dist/app --html-output index.html web-app.html + # scripts/build-web.sh runs the same Trunk build and then verifies the + # font set landed in dist/app/fonts, so a deploy can never silently + # ship without the fonts the app fetches from fonts/*.ttf. + run: sh scripts/build-web.sh --locked --html-output index.html web-app.html - name: Check out current website uses: actions/checkout@v4 diff --git a/README.md b/README.md index 38413d747..ab1c890bd 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,14 @@ Start the development server: trunk serve ``` +Build the deployable bundle (runs the release Trunk build, then re-syncs and +verifies `web/fonts` in `dist/app/fonts`, so the fonts the app fetches can +never go missing from the bundle): + +```bash +sh scripts/build-web.sh +``` + ## Automation The desktop binary supports one-shot conversion, a persistent headless server, and a client-neutral MCP endpoint for AI applications: diff --git a/Trunk.toml b/Trunk.toml index 73c9a13b4..f29f93c94 100644 --- a/Trunk.toml +++ b/Trunk.toml @@ -1,6 +1,6 @@ # Trunk config for the web (wasm) build of Open CAD Studio (issue #45). # -# Build: trunk build --release +# Build: sh scripts/build-web.sh (trunk build --release + fonts sync/verify) # Dev: trunk serve # # Requires: rustup target add wasm32-unknown-unknown diff --git a/scripts/build-web.sh b/scripts/build-web.sh new file mode 100644 index 000000000..f675b346d --- /dev/null +++ b/scripts/build-web.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Canonical web bundle build: Trunk plus a fonts sanity sync. +# +# Trunk's copy-dir pipeline always copies web/fonts in full, but its final +# "apply staging to dist" step wipes the old dist and then moves dist/app/.stage +# into place. If that wipe fails on Windows (typically a transient lock from a +# local file server or antivirus with a file open under dist), the build exits +# non-zero and leaves dist stale with .stage fully populated — easy to miss, +# and a stale bundle would 404 the fonts the app fetches from fonts/*.ttf. +# This wrapper therefore re-syncs web/fonts into dist/app/fonts after a +# successful Trunk build and verifies every font byte-for-byte, so a bundle +# produced through it can never ship without the font set. +# +# Usage: sh scripts/build-web.sh [extra trunk build args] +# local: sh scripts/build-web.sh +# CI: sh scripts/build-web.sh --locked --html-output index.html web-app.html +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +# Git Bash rewrites leading-slash arguments such as --public-url /app/ into +# Windows paths; keep them verbatim. Harmless on other platforms. +MSYS_NO_PATHCONV=1 +export MSYS_NO_PATHCONV + +trunk build --release --dist dist/app --public-url /app/ "$@" + +# Mirror the font sources over the bundle and verify the copy-dir output. +mkdir -p dist/app/fonts +cp -f web/fonts/* dist/app/fonts/ + +count=0 +for font in web/fonts/*.ttf; do + name=$(basename "$font") + cmp -s "$font" "dist/app/fonts/$name" || { + echo "error: dist/app/fonts/$name differs from $font" >&2 + exit 1 + } + count=$((count + 1)) +done +echo "web bundle built; verified $count fonts in dist/app/fonts" diff --git a/src/io/mod.rs b/src/io/mod.rs index c5d0ce33f..c0dda28f4 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -1900,8 +1900,13 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { let decoded = crate::scene::model::image_model::resolve_image(&reference).ok_or_else(|| { // An over-budget decode refusal explains itself (dims + limit); // anything else stays the generic unsupported/corrupt message. - crate::scene::model::image_model::take_decode_refusals() - .pop() + // Only a note naming THIS pick explains its failure; the queue may + // also hold refusals queued by concurrent re-resolution of other + // references, which must survive for their own drains. + crate::scene::model::image_model::decode_refusals() + .iter() + .find(|note| note.contains(short.as_str())) + .cloned() .unwrap_or_else(|| format!("Unsupported or corrupt image \"{short}\"")) })?; // Entity sizing truth: the file's ORIGINAL pixel dimensions, not the diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 54f8eb752..b45bcdd6f 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -667,16 +667,34 @@ fn decode_with_budgets( tier_for(width, height, max_decode_bytes, max_display_pixels) }); + // Trust the probe, not the decoder limits: `Limits::reserve` checks the + // decoder's NATIVE bytes-per-pixel, so a grayscale/RGB JPEG/PNG/BMP over + // the RGBA budget decodes fine and then `into_rgba8()` balloons to up to + // 4x it — exactly the tab death this exists to prevent. The probed + // dimensions already prove it cannot fit; refuse without decoding. + if tier == DecodeTier::OverBudget { + return DecodeAttempt::Refused { + width: probed.map_or(0, |(width, _)| width), + height: probed.map_or(0, |(_, height)| height), + }; + } + let mut reader = match open().and_then(|reader| reader.with_guessed_format()) { Ok(reader) => reader, Err(_) => return DecodeAttempt::Failed, }; - // The decoder enforces the decode budget itself (`Limits::reserve` - // checks the output allocation up front), so a too-large image errors - // here instead of allocating first. This also RAISES the image crate's - // 512 MiB default on native, where the budget is 4 GB. + // For formats that FIT the budget, the limits still guard runaway + // allocations mid-decode. TIFF additionally stacks its own intermediate + // scratch buffers on top of the reserve (empirically ~3x the frame size + // in image 0.25), so refusing an in-budget TIFF at face value would + // produce a note claiming e.g. "400 MB, over the 512 MB limit". + let tiff = matches!(reader.format(), Some(image::ImageFormat::Tiff)); let mut limits = image::Limits::default(); - limits.max_alloc = Some(max_decode_bytes); + limits.max_alloc = Some(if tiff { + max_decode_bytes.saturating_mul(3) + } else { + max_decode_bytes + }); reader.limits(limits); let img = match reader.decode() { Ok(img) => img, @@ -747,7 +765,7 @@ fn refusal_message(path: &str, width: u32, height: u32) -> String { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string()); - let decoded_mb = width as u64 * height as u64 * 4 / (1024 * 1024); + let decoded_mb = (width as u64).saturating_mul(height as u64).saturating_mul(4) / (1024 * 1024); let limit_mb = MAX_DECODE_BYTES / (1024 * 1024); crate::tf!( "\"{name}\" is {width}×{height} px and would decode to {decoded} MB, over the {limit} MB image limit of this build — it cannot be attached. Images within the limit are attached at a reduced display resolution when needed.", @@ -768,6 +786,17 @@ fn refusal_notes() -> &'static Mutex> { } /// Take and clear every recorded decode refusal (most recent last). +/// Peek without draining — callers that only want THEIR note (the pick +/// path) must not consume refusals queued for other references. +pub fn decode_refusals() -> Vec { + refusal_notes() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() +} + +/// Take and clear — for the drains that own every queued note (the poll and +/// the open-settle paths). pub fn take_decode_refusals() -> Vec { let mut notes = refusal_notes().lock().unwrap_or_else(|error| error.into_inner()); std::mem::take(&mut *notes) diff --git a/src/scene/text/web_font.rs b/src/scene/text/web_font.rs index 203dfb20f..710894d5e 100644 --- a/src/scene/text/web_font.rs +++ b/src/scene/text/web_font.rs @@ -244,7 +244,7 @@ pub fn script_of(ch: char) -> Option