diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..898584b7b4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.agents +.codex +.aws +**/target +dist +corpus +log +plan +secrets +temp +craft-fonts +book/output +**/.DS_Store +**/.env +**/.env.* +*.log diff --git a/.gitignore b/.gitignore index 99cf666151..f310f32921 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ target/ /book/output /craft-fonts *.log +__pycache__/ diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9cdf9aa6e0..22a4c60916 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,6 +26,7 @@ next to them. | `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe | | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 | | `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | +| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code | ### Optional build input: craft-fonts (not files in this repo) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..66734f255a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1 +# Community-maintained self-hosting recipe (see packaging/web/README.md#docker); the official +# web build is the `web` job in .github/workflows/release.yml (packaging/web/package.sh). +# +# Source of truth for the toolchain: release.yml and ci.yml build with the latest *stable* Rust +# (dtolnay/rust-toolchain@stable), not a pinned version. This default is the stable release CI +# used when it was last bumped; raise it when CI's stable moves on. It must stay >= the +# workspace `rust-version` in Cargo.toml. +ARG RUST_VERSION=1.99 +ARG NGINX_VERSION=1.28-alpine + +FROM rust:${RUST_VERSION}-slim-bookworm AS builder +# Must match `TRUNK_VERSION` in the `web` job of .github/workflows/release.yml. +ARG TRUNK_VERSION=0.21.14 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && rustup target add wasm32-unknown-unknown +RUN set -eu; \ + case "$(uname -m)" in \ + x86_64) arch=x86_64 ;; \ + aarch64) arch=aarch64 ;; \ + *) echo 'The web builder supports amd64 and arm64' >&2; exit 1 ;; \ + esac; \ + asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \ + url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \ + cd /tmp; \ + curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \ + curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \ + printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \ + tar -xzf "$asset" -C /usr/local/bin trunk; \ + rm "$asset" "$asset.sha256"; \ + trunk --version + +WORKDIR /src +COPY . . +WORKDIR /src/apps/photocraft-web +# Docker serving has no static-host per-file size cap. Thin LTO uses less build +# memory than the release zip's fat LTO; retain the profile's other optimizations. +ARG CARGO_PROFILE_WASM_RELEASE_LTO=thin +ARG CARGO_BUILD_JOBS=1 +# Optional build provenance shown in About and `--version` (crates/engine/src/build_info.rs). +# .git is not in the build context, so pass it in, e.g. +# --build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F) +# Left empty, the build reports itself as a dev build. +ARG PHOTOCRAFT_BUILD_SHA= +ARG PHOTOCRAFT_BUILD_DATE= +# index.html selects the wasm-release profile, HEIF support and wasm-opt -Oz. +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target \ + --mount=type=cache,target=/root/.cache/trunk \ + trunk build --release --locked \ + && test -s /src/dist/web/index.html \ + && gzip -9 -k /src/dist/web/*.wasm /src/dist/web/*.js /src/dist/web/index.html + +FROM nginx:${NGINX_VERSION} AS runtime +COPY packaging/web/nginx.conf /etc/nginx/nginx.conf +COPY --from=builder /src/dist/web/ /usr/share/nginx/html/ +COPY LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md /usr/share/nginx/html/ +COPY assets/fonts/OFL-*.txt assets/icons/LICENSE-*.txt assets/dict/LICENSE-*.txt \ + crates/ui-egui/src/i18n/LICENSE-translations.txt /usr/share/nginx/html/licenses/ +# Bypass the stock entrypoint: this configuration needs no runtime rewriting. +USER nginx +EXPOSE 8080 +ENTRYPOINT [] +CMD ["nginx", "-g", "daemon off;"] +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 diff --git a/README.md b/README.md index 21642ee617..787433909c 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,8 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/). +To self-host the browser version with Docker, see [Docker web hosting](packaging/web/README.md#docker). + ## Security Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md). diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index 6ac1c3b35e..918eb899a0 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -294,15 +294,23 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt unpremultiply(&mut res, n, alpha); (Rect::new(dst.x0, dst.y0 + b0 as i32, dst.x1, dst.y0 + b1 as i32), res) }; + // Bands go into the output a group at a time, so the `f32` rows held at once are bounded by + // the group, not by the whole destination (#1544). #[cfg(not(target_arch = "wasm32"))] - let parts: Vec<(Rect, Vec)> = { - use rayon::prelude::*; - bands.par_iter().map(run).collect() - }; + let group = rayon::current_num_threads().max(1) * 2; #[cfg(target_arch = "wasm32")] - let parts: Vec<(Rect, Vec)> = bands.iter().map(run).collect(); - for (r, d) in parts { - out.write_region(r, &d); + let group = 1; + for chunk in bands.chunks(group) { + #[cfg(not(target_arch = "wasm32"))] + let parts: Vec<(Rect, Vec)> = { + use rayon::prelude::*; + chunk.par_iter().map(run).collect() + }; + #[cfg(target_arch = "wasm32")] + let parts: Vec<(Rect, Vec)> = chunk.iter().map(run).collect(); + for (r, d) in parts { + out.write_region(r, &d); + } } if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) { let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 }; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 3e2d7d3e37..8d6ae1f803 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -114,8 +114,12 @@ the same. * CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an APP14 marker. * Text is not written because there is no COM-segment support. - * EXIF and XMP must each fit in one 64 KiB APP1 segment, otherwise the encoder returns an error. - Extended XMP is not written. + * EXIF and XMP must each fit in one APP1 segment (65 533 bytes with its header). Metadata that + doesn't fit is dropped and the export still succeeds, with a + `MetadataTooLarge` warning ("EXIF (… bytes) is too large for the format; it will be dropped"). + Before dropping XMP, the encoder first removes the layered-document properties + (`DocumentAncestors`, `TextLayers`) that mean nothing in a flat JPEG. Extended XMP is not + written. * **TIFF decode** * Classic TIFF and BigTIFF (version 43, 8-byte offsets), both byte orders, strips or tiles, planar configuration 1 or 2. @@ -140,7 +144,11 @@ the same. * **EXR** * Reads the first valid layer at full resolution, from its data window. * Channel names are matched by suffix, so `layer.R` counts as `R`. - * Subsampled channels and deep data are unsupported. + * Subsampled channels are unsupported. + * Deep data (scanlines or single-level tiles, compressed NONE, RLE or ZIPS) opens as a flat + image: each pixel's samples are composited into one, with + `DecodeWarning::DeepFlattened` because per-pixel depth is not kept. Deep ZIP (16-line + blocks) and deep mip-map or rip-map tiles return `Unsupported`. * Only lossless compressions are offered for writing: None, RLE, ZIP1, ZIP16 and PIZ. * **Netpbm writing** picks the subtype from the image: float gives PFM, gray gives P5, RGB gives P6, and anything with alpha or CMYK gives P7 (PAM). Writing a `.pbm` file therefore produces diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 6fcfe9ecf0..dd589aef6c 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -706,10 +706,17 @@ fn paint_glow(dst: &mut Buffer, m: &Map, g: &Glow, shape_bounds: Rect, anchor: ( paint(dst, &strength, |i| prepared.sample(1.0 - m.v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)), g.common.blend, g.common.opacity); } +/// A box width as the blurs use it: at least 1, at most [`MAX_REACH`] (an effect never reaches +/// further, see [`margin`]), and 1 for NaN. A size or softness from a command or a file can be +/// anything, and an unbounded width sizes the kernel and its buffers (#1543). +fn box_width(w: f32) -> f32 { + if w.is_nan() { 1.0 } else { w.clamp(1.0, MAX_REACH) } +} + /// Normalised weights of a centred box of (fractional) width `w`: tap `i` gets the overlap of /// `[i - 0.5, i + 0.5]` with `[-w/2, w/2]`. fn box_weights(w: f32) -> Vec { - let w = w.max(1.0); + let w = box_width(w); let half = w / 2.0; let r = (half - 0.5).ceil().max(0.0) as i64; let v: Vec = (-r..=r).map(|i| ((i as f32 + 0.5).min(half) - (i as f32 - 0.5).max(-half)).max(0.0)).collect(); @@ -735,10 +742,11 @@ pub fn tent_kernel(w: f32) -> (i32, Vec) { /// Box geometry for a (fractional) width: (`r`, end-tap weight `f`, 1 / width) — the /// [`box_weights`] taps are `r - 1` full ones each side of the centre plus the two end taps at `f`. fn box_geom(bw: f32) -> (i64, f64, f64) { - let half = bw.max(1.0) / 2.0; + let bw = box_width(bw); + let half = bw / 2.0; let r = (half - 0.5).ceil().max(0.0) as i64; let f = f64::from((half - (r as f32 - 0.5)).clamp(0.0, 1.0)); - (r, f, 1.0 / f64::from(bw.max(1.0))) + (r, f, 1.0 / f64::from(bw)) } /// One box pass over `src` (zero outside it) evaluated at `x0 .. x0 + dst.len()`, as a running @@ -1691,6 +1699,11 @@ mod tests { assert_eq!(r, 4); assert!(k[0] > 0.0 && k[0] < 1.0 / 25.0); assert_eq!(tent_kernel(1.0), (0, vec![1.0])); + // #1543: a huge, infinite or NaN width is capped, not turned into a kernel of 2^62 taps. + for w in [1e30, f32::INFINITY, f32::MAX] { + assert_eq!(tent_kernel(w), tent_kernel(MAX_REACH), "{w}"); + } + assert_eq!(tent_kernel(f32::NAN), (0, vec![1.0])); } fn no_tex() -> TextureCtx<'static> { diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index a19901d7ec..1daec9973d 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -108,6 +108,9 @@ fn parse_resample(s: &str) -> Option { }) } +/// The largest raster Image Size resamples to: the decoders' default allocation budget. +const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + /// Image → Image Size. fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -123,6 +126,20 @@ fn image_size(s: &mut Session, p: &Value) -> Result { (None, None) => (ow as f64, oh as f64), }; let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32); + // Each side may reach 300000 px, but resampling writes real pixels: refuse a canvas whose + // raster alone would pass the budget, before allocating any of it (#1544). + let bytes = u64::from(nw).saturating_mul(u64::from(nh)).saturating_mul(d.doc.pixel_format().bytes_per_pixel() as u64); + if resample.is_some() && (nw, nh) != (ow, oh) && bytes > MAX_RESAMPLE_BYTES { + let mp = |w: u32, h: u32| u64::from(w) * u64::from(h) / 1_000_000; + let max_mp = MAX_RESAMPLE_BYTES / d.doc.pixel_format().bytes_per_pixel().max(1) as u64 / 1_000_000; + return Err(bad( + "image.imageSize", + format!( + "{nw} x {nh} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size, or turn Resample off to change only the resolution", + mp(nw, nh) + ), + )); + } let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); s.edit("Image Size", |doc, _| { if let Some(r) = dpi { @@ -574,6 +591,22 @@ mod tests { /// The canvas border stays opaque after Image Size (it used to fade into transparency, so a /// Background or a 200 % export got a translucent frame), and a full selection stays full. + /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands + /// without a budget. It is refused before any allocation and the document is untouched; a + /// change of resolution only, and an ordinary resize, still work. + #[test] + fn image_size_refuses_a_raster_past_the_budget() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let before = (doc(&s).size, s.active().unwrap().history.past_len()); + let err = s.execute("image.imageSize", json!({"width": 300_000, "height": 300_000})).unwrap_err().to_string(); + assert!(err.contains("megapixels"), "{err}"); + assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), before); + s.execute("image.imageSize", json!({"width": 300_000, "height": 300_000, "resample": "none", "resolution": 300})).unwrap(); + s.execute("image.imageSize", json!({"width": 128, "height": 96})).unwrap(); + assert_eq!(doc(&s).size, Size::new(128, 96)); + } + #[test] fn image_size_keeps_canvas_edges_opaque() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 07e944c0be..15d4a88d63 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -753,6 +753,27 @@ mod tests { } } + /// #1543: a Satin or Bevel & Emboss size or softness far past Photoshop's range rendered a + /// blur kernel of 2^62 taps ("capacity overflow"), or ran for minutes at 1e6. The blur width + /// is capped at the effect reach, so the document still renders, quickly. + #[test] + fn oversized_satin_and_bevel_sizes_render() { + for (cmd, p) in [ + ("satin", json!({"size": 1e308})), + ("satin", json!({"size": 1_000_000})), + ("bevelEmboss", json!({"size": 1e308})), + ("bevelEmboss", json!({"soften": 1e308})), + ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000})), + ] { + let mut s = session(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap(); + let start = std::time::Instant::now(); + s.execute("document.pixel", json!({"x": 0, "y": 0})).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + assert!(start.elapsed().as_secs() < 20, "{cmd} {p}: {:?}", start.elapsed()); + } + } + #[test] fn params_are_applied_and_replace_or_add() { let mut s = session(); diff --git a/docs/architecture.md b/docs/architecture.md index 63a3bc599b..81bd240652 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -434,9 +434,11 @@ psd/src/ ## 12. Automation and extensibility -- **`automation`:** an MCP server built on `rmcp`. It exposes: - - `session.list`, `doc.open`, `doc.save`, `doc.export`, `doc.inspect` (layer tree as JSON), `doc.render_preview` (PNG). - - `command.list` and `command.run(id, params)`, both generated from the registry. +- **`automation`:** an MCP server built on `rmcp`. Its tool names use underscores (the dotted + names such as `doc.open` belong to the JSON control protocol, `docs/control-protocol.md`). It exposes: + - `session_list`, `doc_open`, `doc_new`, `doc_save`, `doc_export`, `doc_inspect` (layer tree as JSON), `doc_render_preview` (PNG), `doc_select`, `doc_close`. + - `command_list`, `command_run` and `command_batch`, generated from the registry; `jobs_list` and `jobs_cancel` for background jobs. + - Bridge only (a running app): `ui_inspect`, `ui_screenshot`, `ui_pointer`, `ui_menu_invoke`, `ui_set`, `control_call`. The full list is in `docs/development.md` › MCP. - Stdio for agent CLIs, and optionally loopback TCP with a token so it can attach to a running GUI. - `AuthorizedWorkspace`, which holds independent read and write directory capabilities. Remote paths are validated relative names; engine commands that still require ambient filesystem diff --git a/docs/development.md b/docs/development.md index 3c7303310d..5b3f6f2390 100644 --- a/docs/development.md +++ b/docs/development.md @@ -143,6 +143,7 @@ Tools: - `session_list` - `doc_open`, `doc_new`, `doc_save`, `doc_export`, `doc_inspect`, `doc_render_preview` (returns a PNG image), `doc_select`, `doc_close` - `command_list`, `command_run`, `command_batch` (several commands per call) +- `jobs_list` (running background jobs with progress, then recently finished ones with their result or error), `jobs_cancel` (one job by id, or every running job) - bridge only: `ui_inspect`, `ui_screenshot`, `ui_pointer`, `ui_menu_invoke`, `ui_set`, `control_call` Claude Code (`.mcp.json` in the repo root, or `claude mcp add`): @@ -303,6 +304,16 @@ Any static file server works for `dist/web`, for example `python3 -m http.server URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. +To build and serve the web app entirely in Docker, run from the repository root: + +```sh +docker build --load -t photocraft-web:local . +docker run --rm -p 8080:8080 photocraft-web:local +``` + +Open http://localhost:8080/. See [Docker hosting](../packaging/web/README.md#docker) for +HTTPS/reverse-proxy deployment and browser limitations. + How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop: - **Open** uses `rfd::AsyncFileDialog`. The bytes arrive asynchronously in `Services::inbox`, which the app drains every frame. diff --git a/docs/releasing.md b/docs/releasing.md index 1122b5251c..3e67c7b549 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -85,6 +85,9 @@ in `ci.yml`. Rules: `../craftrules/standards/fonts.md`; build option: `docs/deve ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link to drag onto). The DMG is signed, notarized and stapled too. The script checks the results with `codesign --verify --strict`, `stapler validate` and `spctl -a -vvv`. + Its Finder window (background, icon size and positions) comes from + [`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `PhotoCraft` + without the version, which the window's background needs; the DMG file name keeps the version. - **CLI:** the universal `photocraft-cli` is signed with the same Developer ID, the hardened runtime and a secure timestamp (identifier `ai.storyteller.photocraft-cli`), zipped, and the zip is sent to `notarytool`. Only `.app`, `.dmg` and `.pkg` can hold a stapled ticket, not a bare diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md new file mode 100644 index 0000000000..0cdb20a915 --- /dev/null +++ b/packaging/macos/dmg/README.md @@ -0,0 +1,44 @@ +# macOS DMG window + +What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the +`Applications` link side by side. `package.sh` copies these files into the image; nothing here is +generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no +Finder scripting on CI, nothing extra installed in the signing job). + +| File | What | +|---|---| +| `background.svg` | Source of the background: the app icon (`assets/app-icon/photocraft-small.svg`, linked, not copied) cropped as a cover on the PhotoCraft colour field (`#2f7bf5`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. | +| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB; about 90 KB). Goes to `.background/background.tiff`. | +| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, PhotoCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. | +| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. | + +## The volume name has no version + +The mounted volume is called `PhotoCraft`, not `PhotoCraft `. The layout points at the background +through an alias that includes the volume name, and Finder resolves it by that name: with a +versioned name the window keeps its size and icon positions but shows no background (tested). +The DMG file name (`photocraft--macos-.dmg`) still carries the version. + +## Rules + +- **Finder draws the icon labels in black in light and dark mode** when a window has a background, + so the area under both icons stays light (Paper). +- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of + the label strip under it. + +## Regenerate + +Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS: + +```sh +pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 +python3 packaging/macos/dmg/generate.py # needs resvg on PATH +``` + +Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the +SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background +alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG, +outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt +title bar; the content area is 660 × 400. + +This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493). diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg new file mode 100644 index 0000000000..d93ae37c5e --- /dev/null +++ b/packaging/macos/dmg/background.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff new file mode 100644 index 0000000000..d9d675c6b2 Binary files /dev/null and b/packaging/macos/dmg/background.tiff differ diff --git a/packaging/macos/dmg/dmg-layout.DS_Store b/packaging/macos/dmg/dmg-layout.DS_Store new file mode 100644 index 0000000000..b939350433 Binary files /dev/null and b/packaging/macos/dmg/dmg-layout.DS_Store differ diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py new file mode 100644 index 0000000000..be6c7ed59c --- /dev/null +++ b/packaging/macos/dmg/generate.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here). + +Needs resvg on PATH and: + pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 + +Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing +from the machine that made them: no local paths, user or disk names, dates or volume UUIDs. + + python3 packaging/macos/dmg/generate.py +""" + +import datetime +import os +import shutil +import subprocess +import sys +import tempfile + +from ds_store import DSStore +from mac_alias import Alias, TargetInfo, VolumeInfo +from PIL import Image, ImageCms + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Keep in sync with package.sh (volume name) and README.md (layout). +VOLUME = "PhotoCraft" +WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size +TITLE_BAR = 32 +ICON_SIZE = 128 +PALETTE = 16 # colours in background.tiff +ICONS = {"PhotoCraft.app": (326, 205), "Applications": (574, 205)} +# A fixed date for the alias and the colour profile. Finder never matches it against the image +# (each build is a new volume): it finds the background by volume name and path. +FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc) + + +def render(svg, width, out): + # The SVG's text is already outlined and no fonts are loaded, so every machine draws the + # same pixels. + subprocess.run( + ["resvg", "--skip-system-fonts", "-w", str(width), svg, out], + check=True, + ) + with Image.open(out) as im: + return im.convert("RGB") + + +def quantize(im): + # No dithering: dither noise compresses badly and the artwork has flat fills. + return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE) + + +def srgb_profile(): + # LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed. + icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()) + d = FIXED_DATE + icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0)) + if icc[84:100] != bytes(16): + sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date") + return bytes(icc) + + +def write_tiff(svg, out): + """1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them.""" + with tempfile.TemporaryDirectory() as tmp: + one, two = ( + quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2) + ) + two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page + # Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette + # keeps the file under ~90 KB (RGB was ~200 KB; repository assets must stay small). + one.save( + out, + "TIFF", + save_all=True, + append_images=[two], + compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate) + dpi=(72, 72), + icc_profile=srgb_profile(), + ) + with Image.open(out) as im: + im.seek(1) + if im.info.get("dpi") != (144, 144): + sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)") + strips = [] + for page in (0, 1): + im.seek(page) + strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts + # TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff + # leaves the padding byte after it uninitialised, so the file changed from run to run with + # identical pixels. Zero it: nothing refers to that byte. + starts = {o for o, _ in strips} + with open(out, "r+b") as f: + for o, n in strips: + if (o + n) % 2 and o + n not in starts: + f.seek(o + n) + f.write(bytes(1)) + + +def background_alias(): + # What Finder stores for /.background/background.tiff on the volume, minus anything about the + # machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable), + # the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids + # are only hints, as the volume's creation date. + volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0") + volume.posix_path = f"/Volumes/{VOLUME}" + target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0") + target.folder_name = ".background" + target.cnid_path = [25] + target.carbon_path = f"{VOLUME}:.background:\0background.tiff" + target.posix_path = "/.background/background.tiff" + return Alias(volume=volume, target=target).to_bytes() + + +def write_ds_store(out): + if os.path.exists(out): + os.remove(out) + with DSStore.open(out, "w+") as d: + d["."]["bwsp"] = { + "ShowStatusBar": False, + "ShowToolbar": False, + "ShowTabView": False, + "ContainerShowSidebar": False, + "ShowSidebar": False, + "WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}", + } + d["."]["icvp"] = { + "viewOptionsVersion": 1, + "backgroundType": 2, + "backgroundImageAlias": background_alias(), + "backgroundColorRed": 1.0, + "backgroundColorGreen": 1.0, + "backgroundColorBlue": 1.0, + "gridOffsetX": 0.0, + "gridOffsetY": 0.0, + "gridSpacing": 100.0, + "arrangeBy": "none", + "showIconPreview": True, + "showItemInfo": False, + "labelOnBottom": True, + "textSize": 16.0, + "iconSize": float(ICON_SIZE), + } + d["."]["vSrn"] = ("long", 1) + for name, pos in ICONS.items(): + d[name]["Iloc"] = pos + + +def main(): + if not shutil.which("resvg"): + sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)") + write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff")) + write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store")) + + +if __name__ == "__main__": + main() diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index b94ca50687..194b379293 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -134,10 +134,15 @@ STAGE="$WORK/dmg" mkdir -p "$STAGE" ditto "$APP" "$STAGE/PhotoCraft.app" ln -s /Applications "$STAGE/Applications" +# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md). +mkdir -p "$STAGE/.background" +cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" +cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" rm -f "$DMG" "$WORK/raw.dmg" # makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, # which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -hdiutil makehybrid -hfs -hfs-volume-name "PhotoCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" +# The volume name has no version: .DS_Store finds the background through an alias that includes it. +hdiutil makehybrid -hfs -hfs-volume-name "PhotoCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" diff --git a/packaging/web/README.md b/packaging/web/README.md index 96225cd82f..acbb8afd33 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -12,6 +12,65 @@ static site in `photocraft-web-/`: There is no server-side code. Upload the folder's contents anywhere that serves static files. +## Docker + +> The Docker recipe (`Dockerfile`, `.dockerignore`, `packaging/web/nginx.conf`) is +> **community-maintained**: it is not built in CI or used for releases, so it can lag behind the +> official web build above. Fixes are welcome. + +From the repository root (Docker is the only build prerequisite): + +```sh +docker build --load -t photocraft-web:local . +docker run -d --name photocraft-web --restart unless-stopped \ + -p 8080:8080 photocraft-web:local +``` + +Open **http://localhost:8080/**. The multi-stage `Dockerfile` builds the existing Rust/Wasm +app with Trunk, including HEIF support, then copies only the static site into NGINX. The +runtime runs as the `nginx` user on port **8080**, with a `/healthz` endpoint and Docker +health check. It serves precompressed gzip assets, the Wasm MIME type, immutable hashed +assets, and an HTML page that revalidates after deployments. No Rust installation, Node.js, +database, GPU passthrough, or document volume is needed on the host. + +The first build downloads Rust dependencies and Trunk's Wasm tools and can take several +minutes. Subsequent builds reuse Cargo caches. The builder supports Linux amd64 and arm64; +the default Rust version is the stable release CI used when the `Dockerfile` was last updated +(CI and releases always use the latest stable), and Trunk is pinned to the release workflow's +`TRUNK_VERSION`. `RUST_VERSION`, `TRUNK_VERSION`, and `NGINX_VERSION` can be overridden with +`--build-arg`. To stamp the About dialog with the commit and date instead of "dev build", pass +`--build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F)`. +The web build does not embed the optional `craft-fonts` checkout. + +The Docker build uses thin LTO and one Cargo build job to reduce peak memory, while keeping +the web profile's size optimizations and `wasm-opt -Oz`. Its Wasm size may differ from the +release zip figures below; NGINX has no 25 MiB file limit. To use the release zip's fat LTO on +a builder with more memory, pass `--build-arg CARGO_PROFILE_WASM_RELEASE_LTO=fat`. +`--build-arg CARGO_BUILD_JOBS=4` enables more concurrent compilation on larger builders. A compiler killed with +`SIGKILL`/`cannot allocate memory` means the Docker/Podman VM needs more memory or swap. + +For a public deployment, put an HTTPS reverse proxy or your hosting platform's TLS endpoint +in front of container port 8080. With a reverse proxy on the same host, bind only loopback +(`-p 127.0.0.1:8080:8080`); with a proxy container, connect both through a Docker network. +HTTPS enables WebGPU and browser clipboard APIs. If hosting under `/photocraft/`, redirect +`/photocraft` to `/photocraft/` and strip that prefix when proxying to the container. Relative +asset URLs then work without rebuilding. For example, in an existing HTTPS NGINX server: + +```nginx +location = /photocraft { return 301 /photocraft/; } +location /photocraft/ { + proxy_pass http://127.0.0.1:8080/; +} +``` + +The editor and image processing run on the visitor's device. Open uses the browser file +picker; Save and Export download files. Preferences use browser localStorage. There is no +server-side document storage, desktop TCP control server, or web autosave/crash recovery; +save/download work before closing or reloading the page. A browser with WebGPU or WebGL2 is +required; `?webgl` forces the fallback when troubleshooting. + +To verify the browser deployment, open the page, open an image and save/export it. + ## Sizes Measured on the 0.2.x build (`packaging/web/package.sh`; gzip `-9`, Brotli quality 11): diff --git a/packaging/web/nginx.conf b/packaging/web/nginx.conf new file mode 100644 index 0000000000..3e49455ef0 --- /dev/null +++ b/packaging/web/nginx.conf @@ -0,0 +1,58 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + sendfile on; + server_tokens off; + + # All runtime writes go to /tmp, allowing a read-only container filesystem. + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + # Precompressed at build time; uncompressed clients receive the original. + gzip_static on; + gzip_vary on; + + server { + listen 8080; + listen [::]:8080; + root /usr/share/nginx/html; + index index.html; + add_header X-Content-Type-Options nosniff always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location = /index.html { + add_header Cache-Control no-cache always; + add_header X-Content-Type-Options nosniff always; + } + + # Trunk gives these files content hashes. Missing assets must be 404s, + # never an HTML fallback or an error cached for a year. + location ~ \.(wasm|js)$ { + types { application/wasm wasm; text/javascript js; } + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header X-Content-Type-Options nosniff always; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ =404; + } + } +}