progress, release workflows

This commit is contained in:
Brandon Thomas committed 2026-10-01 10:49:51 -04:00
1 parent aad724f28e
commit d98bee4c94
56 files changed
+2603 -17

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
push:
branches: [main, release]
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/Photocraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.Photocraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.Photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.Photocraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.Photocraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.Photocraft.yml')); assert m['id'] == 'ai.storyteller.Photocraft' and m['command'] == 'photocraft'"
+284
View File
@@ -0,0 +1,284 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "Photocraft v<version>". See docs/releasing.md.
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
name: Release
on:
push:
branches: [release]
workflow_dispatch:
inputs:
version:
description: "Version override for a test run, e.g. 0.2.0-rc.1 (empty: Cargo.toml)"
type: string
default: ""
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### Photocraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15
environment: release
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/photocraft$|metainfo|applications'
ldd target/release/photocraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.Photocraft.yml')); assert m['id'] == 'ai.storyteller.Photocraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="Photocraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
+3 -1
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
Photocraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). The product name is always written **PhotoCraft** (PascalCase, like its siblings DrawCraft, FilmCraft, LightCraft, PrintCraft, DesignCraft, EffectsCraft, ArtCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
@@ -82,6 +82,8 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
- `docs/parity.md`: generated Photoshop menu coverage.
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
- `docs/release-playbook.md`: how every storytold app builds signed release binaries (shared across photocraft, drawcraft, filmcraft, lightcraft, printcraft, designcraft, effectscraft); `docs/releasing.md` is Photocraft's specifics.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
- `log/` (local, gitignored): the dev log.
Generated
+19
View File
@@ -3253,6 +3253,7 @@ dependencies = [
"photocraft-ui-egui",
"rfd",
"serde_json",
"winresource",
]
[[package]]
@@ -4835,10 +4836,12 @@ version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
@@ -4872,6 +4875,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -5822,6 +5831,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml",
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+2
View File
@@ -235,6 +235,8 @@ cargo test --workspace # the test suite
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> **Status:** Photocraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues.
## Crafting Apps
+1 -1
View File
@@ -111,7 +111,7 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
return 0;
}
"--version" | "version" => {
let _ = writeln!(out, "photocraft-cli {}", env!("CARGO_PKG_VERSION"));
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
return 0;
}
other => {
+6
View File
@@ -4,6 +4,8 @@ version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "photocraft"
description = "Photocraft: an open-source, native image editor"
build = "build.rs"
[lints]
workspace = true
@@ -27,3 +29,7 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = "3"
[build-dependencies]
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+28
View File
@@ -0,0 +1,28 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `photocraft.exe`.
//!
//! On every other target this does nothing. A missing resource compiler is a warning, so a
//! cross-compile from macOS or Linux still links, unless `PHOTOCRAFT_REQUIRE_WINRES=1` (set by the
//! release workflow) turns it into an error.
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/photocraft.ico");
println!("cargo:rerun-if-env-changed=PHOTOCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/photocraft.ico")
.set("ProductName", "Photocraft")
.set("FileDescription", "Photocraft image editor")
.set("CompanyName", "Learning Machines LLC")
.set("LegalCopyright", "Copyright (c) the Photocraft authors. MIT OR Apache-2.0.")
.set("OriginalFilename", "photocraft.exe")
.set("InternalName", "photocraft");
if let Err(e) = res.compile() {
if std::env::var_os("PHOTOCRAFT_REQUIRE_WINRES").is_some() {
panic!("embedding Windows resources failed: {e}");
}
println!("cargo:warning=photocraft.exe built without icon/version resources: {e}");
}
}
+8 -2
View File
@@ -6,6 +6,10 @@
//! Each line `{"id":1,"method":"ui.inspect","params":{}}` gets a reply line
//! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods.
// Release builds on Windows are GUI-subsystem apps, so launching from the Start Menu or Explorer
// doesn't open a console window. (`--version` output then only shows when redirected.)
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
mod control_server;
mod services;
@@ -20,15 +24,17 @@ fn main() -> eframe::Result {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--version" => {
println!("photocraft {}", env!("CARGO_PKG_VERSION"));
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
}
// Old macOS passes a process serial number when launched from Finder.
_ if a.starts_with("-psn_") => {}
_ => files.push(a),
}
}
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_title("Photocraft").with_inner_size([1440.0, 900.0]).with_min_inner_size([760.0, 480.0]).with_drag_and_drop(true).with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
viewport: egui::ViewportBuilder::default().with_title("PhotoCraft").with_inner_size([1440.0, 900.0]).with_min_inner_size([760.0, 480.0]).with_drag_and_drop(true).with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
..Default::default()
};
eframe::run_native(
+12
View File
@@ -0,0 +1,12 @@
Photocraft app icon (photocraft.svg and every file derived from it in this directory:
photocraft-1024.png, photocraft.icns, photocraft.ico, hicolor/).
Original artwork by the Photocraft authors, made for this project (no third-party material).
Licensed like the rest of Photocraft, under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (https://opensource.org/licenses/MIT)
at your option.
Regenerate the derived files with packaging/icons.sh.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Photocraft app icon. Original design by the Photocraft authors. MIT OR Apache-2.0. -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3A3A3D"/>
<stop offset="1" stop-color="#1C1C1E"/>
</linearGradient>
<radialGradient id="lens" cx="0.42" cy="0.38" r="0.7">
<stop offset="0" stop-color="#9FD0FF"/>
<stop offset="1" stop-color="#2A78D6"/>
</radialGradient>
<clipPath id="disc"><circle cx="512" cy="512" r="300"/></clipPath>
</defs>
<!-- Body: Apple's 824/1024 grid, so the icon sits correctly in the macOS Dock. -->
<rect x="100" y="100" width="824" height="824" rx="185" fill="url(#bg)"/>
<rect x="100.5" y="100.5" width="823" height="823" rx="184.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.08"/>
<!-- Aperture: six blades around a hexagonal opening. -->
<circle cx="512" cy="512" r="318" fill="#141416"/>
<g clip-path="url(#disc)" stroke="#141416" stroke-width="12" stroke-linejoin="round">
<path d="M409.8,453.0 L216.6,564.5 A300,300 0 0 0 409.8,794.1 L409.8,571.0 Z" fill="#5AA6FF"/>
<path d="M409.8,571.0 L409.8,794.1 A300,300 0 0 0 705.2,741.5 L512.0,630.0 Z" fill="#378EF0"/>
<path d="M512.0,630.0 L705.2,741.5 A300,300 0 0 0 807.4,459.5 L614.2,571.0 Z" fill="#2A78D6"/>
<path d="M614.2,571.0 L807.4,459.5 A300,300 0 0 0 614.2,229.9 L614.2,453.0 Z" fill="#1F64BA"/>
<path d="M614.2,453.0 L614.2,229.9 A300,300 0 0 0 318.8,282.5 L512.0,394.0 Z" fill="#2A78D6"/>
<path d="M512.0,394.0 L318.8,282.5 A300,300 0 0 0 216.6,564.5 L409.8,453.0 Z" fill="#378EF0"/>
<path d="M512.0,394.0 L614.2,453.0 L614.2,571.0 L512.0,630.0 L409.8,571.0 L409.8,453.0 Z" fill="url(#lens)" stroke="none"/>
</g>
<!-- A highlight glint in the opening. -->
<ellipse cx="478" cy="468" rx="34" ry="20" transform="rotate(-30 478 468)" fill="#FFFFFF" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Photocraft app icon. Original design by the Photocraft authors. MIT OR Apache-2.0. -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3A3A3D"/>
<stop offset="1" stop-color="#1C1C1E"/>
</linearGradient>
<radialGradient id="lens" cx="0.42" cy="0.38" r="0.7">
<stop offset="0" stop-color="#9FD0FF"/>
<stop offset="1" stop-color="#2A78D6"/>
</radialGradient>
<clipPath id="disc"><circle cx="512" cy="512" r="300"/></clipPath>
</defs>
<!-- Body: Apple's 824/1024 grid, so the icon sits correctly in the macOS Dock. -->
<rect x="100" y="100" width="824" height="824" rx="185" fill="url(#bg)"/>
<rect x="100.5" y="100.5" width="823" height="823" rx="184.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.08"/>
<!-- Aperture: six blades around a hexagonal opening. -->
<circle cx="512" cy="512" r="318" fill="#141416"/>
<g clip-path="url(#disc)" stroke="#141416" stroke-width="12" stroke-linejoin="round">
<path d="M409.8,453.0 L216.6,564.5 A300,300 0 0 0 409.8,794.1 L409.8,571.0 Z" fill="#5AA6FF"/>
<path d="M409.8,571.0 L409.8,794.1 A300,300 0 0 0 705.2,741.5 L512.0,630.0 Z" fill="#378EF0"/>
<path d="M512.0,630.0 L705.2,741.5 A300,300 0 0 0 807.4,459.5 L614.2,571.0 Z" fill="#2A78D6"/>
<path d="M614.2,571.0 L807.4,459.5 A300,300 0 0 0 614.2,229.9 L614.2,453.0 Z" fill="#1F64BA"/>
<path d="M614.2,453.0 L614.2,229.9 A300,300 0 0 0 318.8,282.5 L512.0,394.0 Z" fill="#2A78D6"/>
<path d="M512.0,394.0 L318.8,282.5 A300,300 0 0 0 216.6,564.5 L409.8,453.0 Z" fill="#378EF0"/>
<path d="M512.0,394.0 L614.2,453.0 L614.2,571.0 L512.0,630.0 L409.8,571.0 L409.8,453.0 Z" fill="url(#lens)" stroke="none"/>
</g>
<!-- A highlight glint in the opening. -->
<ellipse cx="478" cy="468" rx="34" ry="20" transform="rotate(-30 478 468)" fill="#FFFFFF" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+65
View File
@@ -0,0 +1,65 @@
//! Version and build provenance shared by every frontend (About dialog, `--version`).
//!
//! The version is the workspace version (`[workspace.package] version` in the root
//! `Cargo.toml`, managed by `cargo xtask version`). The commit and build date are optional and
//! come from environment variables at compile time, set by CI and the packaging scripts:
//!
//! - `PHOTOCRAFT_BUILD_SHA`: the git commit (any length; shown shortened to 9 characters)
//! - `PHOTOCRAFT_BUILD_DATE`: the build date, conventionally `YYYY-MM-DD`
//!
//! Without them (a plain `cargo build`) the build reports itself as a dev build. Nothing here
//! shells out to git, so builds work from a source tarball and for wasm.
/// The workspace version, such as `0.1.0` or `0.2.0-rc.1`.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// The full git commit this build was made from, when the build environment recorded it.
pub fn git_sha() -> Option<&'static str> {
option_env!("PHOTOCRAFT_BUILD_SHA").map(str::trim).filter(|s| !s.is_empty())
}
/// The build date (`YYYY-MM-DD`), when the build environment recorded it.
pub fn build_date() -> Option<&'static str> {
option_env!("PHOTOCRAFT_BUILD_DATE").map(str::trim).filter(|s| !s.is_empty())
}
/// The commit shortened for display.
pub fn short_sha() -> Option<&'static str> {
git_sha().map(|s| &s[..s.len().min(9)])
}
/// `0.1.0 (3f2a9c1d0, 2026-10-01)`, or `0.1.0 (dev build)` without provenance.
pub fn long_version() -> String {
format_long(VERSION, short_sha(), build_date())
}
fn format_long(version: &str, sha: Option<&str>, date: Option<&str>) -> String {
match (sha, date) {
(Some(s), Some(d)) => format!("{version} ({s}, {d})"),
(Some(s), None) => format!("{version} ({s})"),
(None, Some(d)) => format!("{version} ({d})"),
(None, None) => format!("{version} (dev build)"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn long_version_formats() {
assert_eq!(format_long("1.2.3", Some("abc"), Some("2026-10-01")), "1.2.3 (abc, 2026-10-01)");
assert_eq!(format_long("1.2.3", Some("abc"), None), "1.2.3 (abc)");
assert_eq!(format_long("1.2.3", None, Some("2026-10-01")), "1.2.3 (2026-10-01)");
assert_eq!(format_long("1.2.3-rc.1", None, None), "1.2.3-rc.1 (dev build)");
}
#[test]
fn version_is_the_workspace_version() {
assert!(!VERSION.is_empty());
assert!(long_version().starts_with(VERSION));
if let Some(s) = short_sha() {
assert!(s.len() <= 9);
}
}
}
+1
View File
@@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
pub mod adjust_cmds;
pub mod build_info;
pub mod layer_menu_cmds;
pub mod mode_cmds;
pub mod channel_cmds;
+4 -3
View File
@@ -70,9 +70,10 @@ pub(crate) struct MapProgram {
}
/// How far a field value depends on the shape, for a field exact up to `reach`.
pub(crate) fn field_radius(kind: FieldKind, reach: i32) -> i32 {
// The nearest seed lies within the reach; local coverage reads one more pixel.
reach + 2 + i32::from(kind == FieldKind::StrokeOutsideVector)
pub(crate) fn field_radius(_kind: FieldKind, reach: i32) -> i32 {
// The nearest seed lies within the reach (seeds may start up to 1 px in, at their sub-pixel
// edge); stroke fields classify seeds by their 3×3 local coverage, one more pixel.
reach + 3
}
impl MapProgram {
+6 -5
View File
@@ -10,20 +10,21 @@
//! - **Planner** ([`plan`]). The layer tree becomes a linear list of passes over abstract
//! chunk-sized buffers (blend with every Photoshop mode, opacity × fill, masks, clipping groups,
//! pass-through vs isolated groups, adjustments, solid / gradient fills, layer effects).
//! - **Layer effects** ([`fx`]). Each effect layer's maps (shadow, glow, satin, bevel, stroke
//! - **Layer effects** (`fx.rs`). Each effect layer's maps (shadow, glow, satin, bevel, stroke
//! bands) are built by fragment passes over its effect region and cached on the GPU per layer
//! state; a brush stroke rebuilds only the damaged tiles grown by the effect reach.
//! - **Execution.** The canvas is processed in chunks (1024² RGBA32F accumulators, reused), and
//! each finished chunk is handed to a caller-supplied sink, e.g. to encode it straight into a
//! display texture — no readback.
//!
//! Anything the planner can't express (vector masks, layers clipped to pass-through groups,
//! uncached pattern fills, artboards, documents or effect regions larger than the device's
//! texture limit) returns [`Unsupported`]; callers fall back to the CPU compositor.
//! Anything the planner can't express (vector masks, layers clipped to pass-through groups, stroked
//! shapes with clipped layers, uncached pattern fills, artboards, documents or effect regions
//! larger than the device's texture limit) returns [`Unsupported`]; callers fall back to the CPU
//! compositor.
#![forbid(unsafe_code)]
pub mod bounds;
pub mod fx;
mod fx;
pub mod plan;
use std::collections::HashMap;
+3 -3
View File
@@ -27,8 +27,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
match d.kind {
DialogKind::NewDocument => new_document(ui, &mut fields),
DialogKind::About => {
ui.label("Photocraft — an open-source, native image editor written in Rust.");
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
ui.label("PhotoCraft — an open-source, native image editor written in Rust.");
ui.label(format!("Version {}", photocraft_engine::build_info::long_version()));
ui.weak("egui · wgpu · photocraft-engine");
}
DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields),
@@ -88,7 +88,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
pub fn title(d: &Dialog) -> String {
match d.kind {
DialogKind::NewDocument => "New Document".into(),
DialogKind::About => "About Photocraft".into(),
DialogKind::About => "About PhotoCraft".into(),
DialogKind::LayerStyle => "Layer Style".into(),
DialogKind::Command => d.fields.get("__label").and_then(Value::as_str).unwrap_or("Command").trim_end_matches('…').to_string(),
DialogKind::Error => "Error".into(),
+1 -1
View File
@@ -49,7 +49,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
("edit.search", "Search…", &["Edit"], Some("Cmd+K")),
("help.about", "About Photocraft", &["Help"], None),
("help.about", "About PhotoCraft", &["Help"], None),
];
/// Photoshop's Window › <panel> ids for the panels the shell already has, as `window.toggle.*`.
+1 -1
View File
@@ -169,7 +169,7 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity.md`](par
- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): vector masks, layers clipped to pass-through groups, stroked shapes with clipped layers, pattern fills without Photoshop's cached pixels, artboards, and documents or effect regions over the texture limit. `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path).
- **Layer effects on the GPU** (`gpu/src/fx.rs`, kernels in `gpu/src/compose.wgsl`). Every enabled effect becomes a *map program* over the layer's effect region (shift, dilate, Gaussian blur, glow ramp, bevel height and shading, contour, stroke band), mirroring `compose::effects` step by step; the chunked composite then paints through the maps, clipped to that region, and copies the result back into the backdrop in place, so a small text layer costs only its own pixels. The layer's shape (`compose::layer_shape`) and its distance fields (`compose::effects::distance_field`: a sequential transform whose tie-breaking a parallel GPU pass can't reproduce bit for bit) come from compose on the CPU, computed in parallel bands. Everything is cached per layer state: an unrelated edit, or an effect's colour or opacity, rebuilds nothing; a brush dab recomputes the touched 256² tiles grown by the effect reach; changing one effect's geometry rebuilds that effect only. Cache budget `gpu::FX_BUDGET` (1.5 GB, least recently drawn layers evicted first). `PHOTOCRAFT_FX_TRACE=1` prints the CPU time of each rebuild.
- The canvas grows a stroke's damage rect by the effect reach of the layers around it (`canvas::effect_reach`), so effects beyond the dab refresh too (on both paths).
- **Numbers** (7360 × 4912, 8 text layers + one painted layer with drop shadow + stroke + bevel, Hue/Saturation on top; M4 Pro; `cargo run --release -p photocraft-ui-egui --example fx_bench`): FX_NUMBERS. `fx_bench --compare corpus/psd/…/*.psd` reports the GPU vs CPU difference on real files.
- **Numbers** (7360 × 4912, 8 text layers + one painted layer with drop shadow + stroke + bevel, Hue/Saturation on top; M4 Pro; `cargo run --release -p photocraft-ui-egui --example fx_bench`), CPU fallback → GPU: full refresh with warm effect maps 5.7 s → 47 ms; Hue/Saturation tweak above the effects 4.7 s → 31 ms; brush dab on a plain layer 32 → 1.7 ms; brush dab on the effect layer (its maps rebuilt around the dab) 659 → 3.7 ms; first refresh (all maps built) 6.4 s → 0.39 s. With the CPU ~14× oversubscribed by parallel builds (min of 7 runs): 8.1 s → 0.33 s, 9.9 s → 0.28 s, 28 → 2.2 ms, 1.7 s → 89 ms; moving a text layer 7 px 336 → 13 ms. `fx_bench --compare corpus/psd/…/*.psd` reports the GPU vs CPU difference on real files (30 of the 31 corpus files with effects render on the GPU, worst 0.12/255).
- On the CPU path `compose::effect_maps` caches shadow, glow, bevel and satin maps per layer state (LRU, 768 MB budget).
- Live adjustment previews on large documents use a downsampled proxy (`ui-egui/src/proxy.rs`).
- `ui.inspect` returns `perf` timings (UI ms per frame, composite ms, upload ms).
+260
View File
@@ -0,0 +1,260 @@
# Release playbook for storytold apps
How every storytold desktop/web app (PhotoCraft, DrawCraft, FilmCraft, LightCraft, PrintCraft,
DesignCraft, EffectsCraft, and later ArtCraft) builds signed release binaries in CI. Photocraft is
the reference implementation: copy its `.github/workflows/release.yml`, `packaging/` and
`xtask version` code, then rename. Photocraft-specific details live in its `docs/releasing.md`.
Agents: follow this document step by step, and keep each app's setup identical to it except for
the names in §9. If you improve the recipe in one app, update this playbook in photocraft too.
**Naming.** Display names are PascalCase with a capital "C": **PhotoCraft, DrawCraft, FilmCraft,
LightCraft, PrintCraft, DesignCraft, EffectsCraft, ArtCraft**. Use them for:
- release names (`PhotoCraft v0.2.0`);
- the macOS bundle (`PhotoCraft.app`, `CFBundleName`/`CFBundleDisplayName`), the DMG volume name;
- the Windows product name, Start Menu shortcut and installer title;
- `.desktop` `Name=`, AppStream `<name>`;
- window titles and About dialogs.
Machine names stay lowercase: cargo packages and binaries (`photocraft`), file names
(`photocraft-0.2.0-macos-universal.dmg`), repo names, and ids (`ai.storyteller.photocraft`).
Below, `<App>` is the display name and `<app>` the lowercase name.
## 1. What a release produces
Every push to the `release` branch builds everything below and attaches it to a **draft** GitHub
Release named `<App> v<version>`, tagged `v<version>`, plus `SHA256SUMS.txt`. A human reviews and
publishes the draft.
| Platform | Artifacts | Signing |
|---|---|---|
| macOS | Universal (arm64 + x86_64) `<App>.app` in a `.dmg`; universal CLI `.zip` if the app has one | Developer ID codesign (hardened runtime) + Apple notarization + stapling |
| Windows | x64 and x86 `.msi` installers + portable `.zip`s | Authenticode (signtool, timestamped), or Azure Trusted Signing |
| Linux | x86_64 (+ aarch64 when cheap): `.AppImage`, `.deb`, `.rpm`, `.tar.gz`; a Flatpak manifest | None needed (checksums in `SHA256SUMS.txt`) |
| Web | `<app>-web-<version>.zip` (wasm + generated glue + `index.html`) | None |
Why these Linux formats:
- **AppImage** runs on almost any distro without installing.
- **.deb** covers Debian, Ubuntu, Mint and Pop!_OS, the largest desktop share.
- **.rpm** covers Fedora, openSUSE and RHEL.
- **.tar.gz** is for everyone else and for scripts.
- **Flatpak** (Flathub) is the best long-term channel for sandboxed, auto-updating installs. We keep a manifest ready and submit to Flathub separately, because Flathub builds from source itself.
Build Linux on **ubuntu-22.04**. Its glibc 2.35 is old enough for current Debian, Ubuntu LTS and
Fedora.
## 2. Triggers
```yaml
on:
push:
branches: [release]
workflow_dispatch:
inputs:
version:
description: "Override version for a test build (e.g. 0.2.0-rc.1)"
required: false
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
```
Every job that needs secrets declares `environment: release` (see §3). The job that creates the
release also declares `permissions: contents: write`.
## 3. Secrets and branch protection (security model)
**Never store signing secrets as org-level or repo-level Actions secrets.** GitHub can't restrict
those to a branch: anyone with write access could push a workflow to any branch and read them.
Instead:
1. **A `release` environment in every repo**, with deployment branches limited to exactly
`release`. The signing secrets live there as *environment secrets*, so only workflow runs on
`refs/heads/release` can read them.
2. **A ruleset on `release` in every repo**:
- target `refs/heads/release`;
- rules: restrict creations, restrict updates, restrict deletions, block force pushes;
- bypass list: the `release-managers` team (echelon, echelon-robot) plus the
*Organization admin* role. Rulesets can't list individual users, hence the team.
The result is that only those people can start a run that sees the secrets.
3. **Canonical copy:** `~/secrets/storytold/` on the release manager's machine (dir 700, files
600). Binary items (`.p12`, `.pfx`) are stored decoded next to their base64 form. A sync script
pushes them into each repo's `release` environment with `gh secret set --env release -R storytold/<repo>`.
Adding a repo = create the environment + ruleset, then run the sync.
4. **Public repos have world-readable logs.** Never `echo`, `cat` or `set -x` around secrets.
Write them to files with `umask 077` and delete them in an `always()` step. Fork PRs never
receive secrets, and must not be given them via `pull_request_target`.
5. Every secret is **optional** in the workflow. When one is missing, the job builds unsigned and
emits `::warning::`, so the pipeline also runs in forks and before secrets exist.
Secret names (identical in every repo):
| Secret | Use |
|---|---|
| `APPLE_CERTIFICATE` | base64 `.p12`: "Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" |
| `APPLE_CERTIFICATE_PASSWORD` | password of that `.p12` |
| `KEYCHAIN_PASSWORD` | throwaway password for the temporary CI keychain |
| `APPLE_ID` | Apple ID used for notarization |
| `APPLE_PASSWORD` | app-specific password for `notarytool` |
| `APPLE_TEAM_ID` | `DJ6XS33FX8` |
| `WINDOWS_CERTIFICATE` / `WINDOWS_CERTIFICATE_PASSWORD` | base64 `.pfx` for signtool (if we use a PFX) |
| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE` | Azure Trusted Signing (if we use it instead of a PFX) |
Status (2026-10-01): the Apple secrets exist as storytold org secrets used by artcraft. They are
being exported into `~/secrets/storytold/` and moved into per-repo `release` environments. Windows
signing material is still being identified, because artcraft never signed its Windows builds.
## 4. Versioning
- **Source of truth:** `[workspace.package] version` in the root `Cargo.toml`. Packaging metadata
must not hardcode it.
- **Bumping:** `cargo xtask version` prints the version, and `cargo xtask version set X.Y.Z[-pre]`
rewrites it and refreshes `Cargo.lock`. Cut a release by bumping on `main`, then merging `main`
into `release`.
- **In the app:** read the version from `CARGO_PKG_VERSION`. A `build.rs` (or `option_env!`) adds
the short commit and build date from CI env vars (e.g. `<APP>_BUILD_SHA`), with fallbacks so
local and wasm builds still compile without git. Show it in the About dialog, `--version`, and
the window title if appropriate.
- **Overrides:** the release workflow reads the version from `Cargo.toml`. The `workflow_dispatch`
`version` input overrides it for test builds; apply it to the build too, not just the release name.
- **Naming:** the tag is `v<version>`; the release name is `<App> v<version>`; files are named
`<app>-<version>-<platform>-<arch>.<ext>`.
## 5. macOS recipe (universal, signed, notarized)
1. Build both architectures and merge them:
```sh
rustup target add aarch64-apple-darwin x86_64-apple-darwin
cargo build --release -p <app> --target aarch64-apple-darwin
cargo build --release -p <app> --target x86_64-apple-darwin
lipo -create -output <App> target/{aarch64,x86_64}-apple-darwin/release/<app>
```
2. Assemble `<App>.app/Contents/{MacOS/<App>, Resources/<App>.icns, Info.plist}`. Info.plist keys:
- `CFBundleIdentifier` = `ai.storyteller.<app>`
- `CFBundleShortVersionString` / `CFBundleVersion`
- `CFBundleIconFile`
- `LSMinimumSystemVersion`: what wgpu/Metal needs
- `NSHighResolutionCapable`
- `CFBundleDocumentTypes` for the app's file types
3. Import the certificate into a temporary keychain. ArtCraft's flow works:
- `security create-keychain` → `default-keychain -s` → `unlock-keychain` → `set-keychain-settings -t 3600 -u`
- `security import cert.p12 -P … -T /usr/bin/codesign`
- `security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD"`
- add the keychain to the search list.
Delete the keychain and `.p12` in an `always()` step.
4. Sign inside-out (nested code first, then the bundle) with `--options runtime --timestamp` and
minimal entitlements. Avoid `--deep` on the final signature.
5. Make the DMG (`hdiutil create -format UDZO`, ideally with an `/Applications` symlink), then sign it.
6. Notarize: `xcrun notarytool submit <dmg> --apple-id … --password … --team-id … --wait`, then
`xcrun stapler staple` the DMG (and the `.app` before packaging if it ships elsewhere).
7. Verify: `codesign --verify --strict --verbose=2`, `spctl -a -vvv -t install <dmg>`,
`spctl -a -vvv <App>.app`.
8. Locally, run the same script with `codesign -s -` (ad-hoc) and no notarization to test bundling.
## 6. Windows recipe (x64 + x86, signed MSI)
1. Targets: `x86_64-pc-windows-msvc` and `i686-pc-windows-msvc` on `windows-latest`.
2. Set `RUSTFLAGS=-C target-feature=+crt-static` so users don't need the VC++ redistributable.
3. Embed the icon and version info in the exe through a `build.rs` using `winresource` or
`embed-resource`, gated to Windows targets so macOS, Linux and wasm builds are unaffected.
4. Build the MSI with WiX (v4/v5 dotnet tool, or cargo-wix): per-machine install, Start Menu
shortcut, file associations, upgrade code fixed per app (generate once, never change), product
code per version. Also zip the exe as a portable build.
5. Sign the exe before packaging and the MSI after, using one small script that picks the method:
- PFX: `signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f cert.pfx /p …`
- or Azure Trusted Signing (the `azure/trusted-signing-action`, or `signtool` with the dlib).
Verify with `signtool verify /pa`.
## 7. Linux recipe
1. Run on `ubuntu-22.04` (and `ubuntu-24.04-arm` for aarch64). Install the egui/winit/wgpu build deps:
`libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev`.
2. Shared assets in `packaging/linux/`:
- `<app>.desktop`, with `MimeType=` for the app's formats;
- `ai.storyteller.<app>.metainfo.xml` (AppStream);
- hicolor PNG icons at 16–512 px plus a scalable SVG.
3. `.deb`: `cargo-deb` (or nfpm), declaring runtime deps such as `libxkbcommon0`, `libwayland-client0`, `libgtk-3-0` and `libvulkan1 | mesa-vulkan-drivers`.
4. `.rpm`: `cargo-generate-rpm` (or nfpm) with the equivalent deps.
5. `.AppImage`: build an AppDir (binary + desktop + icon + AppRun), then run `appimagetool`, or `linuxdeploy` to bundle libraries.
6. Flatpak: keep a manifest at `packaging/linux/flatpak/ai.storyteller.<App>.yml` (freedesktop runtime, `--socket=wayland --socket=fallback-x11 --device=dri --filesystem=home` or portals). Build it in CI only if it's quick; otherwise validate it and submit to Flathub separately.
## 8. Web/WASM recipe
1. `rustup target add wasm32-unknown-unknown`, install `trunk`, then run `trunk build --release` in the
web app dir. `Trunk.toml` must set `public_url = "./"` so the bundle works from any path.
2. Zip the dist dir. The README in `packaging/web/` covers:
- serving `.wasm` as `application/wasm` with gzip/brotli;
- the iframe embed snippet;
- URL flags for the WebGPU/WebGL2/CPU fallbacks;
- the rule that no hand-written JS is involved.
3. A later job can deploy the zip to the website. The release only attaches it.
## 9. Adopting this in another app (checklist)
1. Copy from photocraft:
- `.github/workflows/release.yml`
- `packaging/{macos,windows,linux,web}/`
- the `xtask version` command
- the version/build-info `build.rs` and About/`--version` wiring
2. Rename everywhere: app name, binary/package names, `ai.storyteller.<app>` bundle id,
`.desktop`/metainfo ids, file associations, the WiX upgrade code (new GUID), icons, release name.
3. Make sure the app's workspace version is the single source (§4).
4. In GitHub (an org admin does this, by API or the web UI):
- create the `release` environment with a deployment-branch rule of `release` only;
- add the ruleset from §3.2 (`release-managers` team + Organization admin bypass);
- run the secret sync from `~/secrets/storytold/` for this repo.
5. Test with `workflow_dispatch` and `version: X.Y.Z-rc.1` from the `release` branch. Check every
artifact installs and launches on each OS: Gatekeeper accepts the DMG with no warning, and
SmartScreen shows the publisher.
6. Document anything app-specific in that app's `docs/releasing.md`. Improvements to the recipe
itself go back into this playbook.
## 10. Shared reusable workflow (planned)
So apps don't each carry a copy of the pipeline, the photocraft workflow will be lifted into a
reusable workflow in a shared repo (`storytold/release-kit`, `on: workflow_call`). Each app's
`release.yml` then shrinks to a caller:
```yaml
jobs:
release:
uses: storytold/release-kit/.github/workflows/rust-app-release.yml@v1
with:
app-name: DrawCraft # display name (PascalCase), release name "DrawCraft v<version>"
package: drawcraft # cargo package of the desktop app
bundle-id: ai.storyteller.drawcraft
web-dir: apps/drawcraft-web # omit if there is no web build
wix-upgrade-code: "<GUID generated once for this app>"
secrets: inherit
```
The called jobs declare `environment: release`, so they read the *caller's* `release`
environment secrets, and the branch rule from §3 still applies. Until release-kit exists, copy the
files from photocraft as described in §9.
## 11. Prompt for an app's agent
Paste this into the agent working in another app's repo (replace `<App>` / `<app>`):
> Set up signed release builds for `<App>` (display name in PascalCase, e.g. DrawCraft; lowercase
> `<app>` for packages, binaries, files and ids) following `docs/release-playbook.md` in the photocraft
> repo (`storytold/photocraft`). Read it fully first, then mirror photocraft's implementation:
> copy `.github/workflows/release.yml`, `packaging/{macos,windows,linux,web}/`, the `cargo xtask version`
> command, and the version/build-info `build.rs` + About/`--version` wiring. Rename everything
> per §9: app name, package, `ai.storyteller.<app>` bundle id, `.desktop`/metainfo ids, file
> associations, and a NEW WiX upgrade GUID. Keep the secret names, triggers, artifact naming
> (`<app>-<version>-<platform>-<arch>.<ext>`) and the `environment: release` gating exactly as
> written. Make the workspace version the single source of truth. Verify locally what you can:
> build the app; run the macOS packaging script with an ad-hoc signature; lint the workflow with
> actionlint. Don't create GitHub environments, rulesets or secrets yourself: an org admin does
> that (§9.4). Don't commit or push unless asked. Report what you copied, what you renamed, and
> anything app-specific that needed a change, and propose playbook edits if the recipe was wrong.
## 12. Non-Rust or non-eframe apps
The security model (§3), versioning rules (§4), naming, triggers and artifact matrix (§1) still
apply. Swap only the build commands: for example, Tauri apps can keep `tauri-action` for bundling
but must still read secrets only from the `release` environment.
+190
View File
@@ -0,0 +1,190 @@
# Releasing Photocraft
Every push to the `release` branch runs `.github/workflows/release.yml`. The workflow builds
signed installers for macOS, Windows, Linux and the web, then creates or updates a **draft**
GitHub Release named `Photocraft v<version>`. Nobody sees a draft until a maintainer publishes it.
## Cutting a release
1. **Bump the version** on `main`. The only place it lives is `[workspace.package] version`
in the root `Cargo.toml`:
```sh
cargo xtask version # prints the current version, e.g. 0.1.0
cargo xtask version set 0.2.0 # or 0.2.0-rc.1; updates Cargo.toml and Cargo.lock
```
Commit the change (`Cargo.toml` + `Cargo.lock`) through the normal review flow.
2. **Merge `main` into `release`** (or fast-forward it) and push. The workflow starts by itself.
3. **Wait for the draft.** After about 30 to 45 minutes (notarization is the slow part), the
Releases page has a draft `Photocraft v0.2.0`, tagged `v0.2.0` on the pushed commit, with
every artifact and `SHA256SUMS.txt`. The notes are generated from the merged PRs.
4. **Check it.** Download an installer or two and look at the job summaries. Any
`::warning::` there means a signing secret was missing and that artifact is unsigned.
5. **Publish** the draft in the GitHub UI. Publishing creates the `v0.2.0` tag. Versions with a
pre-release suffix (`-rc.1`) are marked as pre-releases.
Pushing to `release` again before you publish rebuilds the same draft and replaces its assets.
After the draft is published, the workflow refuses to touch that version again, so bump it first.
**Test runs:** *Actions → Release → Run workflow* runs the whole pipeline by hand. The optional
`version` input (such as `0.2.0-rc.1`) overrides `Cargo.toml` for that run only. The jobs apply
it with `cargo xtask version set` before building, so the binaries report it too. The run still
needs the `release` environment, which only the `release` branch can use, so pick that branch
in the dialog.
## What gets built
| Platform | Artifacts | Built on |
|---|---|---|
| macOS 11+ (universal: Apple silicon + Intel) | `Photocraft-<v>-macos-universal.dmg`, `photocraft-cli-<v>-macos-universal.zip` | `macos-15` |
| Windows 10+ x64 | `Photocraft-<v>-windows-x64.msi`, `…-windows-x64-portable.zip` | `windows-latest` |
| Windows 10+ x86 (32-bit) | `Photocraft-<v>-windows-x86.msi`, `…-windows-x86-portable.zip` | `windows-latest` |
| Linux x86_64 | `Photocraft-<v>-x86_64.AppImage`, `photocraft_<v>-1_amd64.deb`, `photocraft-<v>-1.x86_64.rpm`, `photocraft-<v>-linux-x86_64.tar.gz` | `ubuntu-22.04` |
| Linux aarch64 | the same four, for `aarch64` / `arm64` | `ubuntu-22.04-arm` |
| Web | `photocraft-web-<v>.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` |
Every binary reports its version, the commit and the build date: `photocraft --version`,
`photocraft-cli --version`, and *Help › About Photocraft*. CI sets `PHOTOCRAFT_BUILD_SHA` and
`PHOTOCRAFT_BUILD_DATE`, and `crates/engine/src/build_info.rs` reads them at compile time. A plain
`cargo build` doesn't set them and reports `0.2.0 (dev build)`.
### macOS
`packaging/macos/package.sh` builds `aarch64-apple-darwin` and `x86_64-apple-darwin` with
`MACOSX_DEPLOYMENT_TARGET=11.0`, joins them with `lipo`, and assembles `Photocraft.app`:
- `Info.plist` is generated from `Info.plist.in`. The bundle id is `ai.storyteller.photocraft`.
The plist sets `LSMinimumSystemVersion` 11.0, `NSHighResolutionCapable`, and document types:
`.pcraft` (Owner), plus PSD/PSB and the image formats Photocraft reads (Alternate, so it never
takes over Preview's defaults). The icon is `assets/app-icon/photocraft.icns`.
- **Signing** goes inside-out with the hardened runtime and a secure timestamp. The executable
is signed first, then the bundle. There's no `--deep` on the final signature. The
entitlements (`entitlements.plist`) are deliberately empty.
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then the
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`.
- **CLI:** the universal `photocraft-cli` is signed with the hardened runtime, zipped and
notarized. A bare binary can't hold a stapled ticket, so Gatekeeper looks it up online.
Locally, without certificates, the script signs ad-hoc (`codesign -s -`) and skips notarization.
That's enough to check the bundle and the DMG on your own Mac:
```sh
packaging/macos/package.sh # universal; needs both rustup targets
packaging/macos/package.sh --arch aarch64 # quicker, host-only
open dist/release/Photocraft-*-macos-*.dmg
```
### Windows
`packaging/windows/package.ps1 -Arch x64|x86` builds with `-C target-feature=+crt-static`.
The static C runtime means neither the MSI nor the portable zip needs the Visual C++
redistributable, which matters for a standalone installer and costs only about 100 KB. The flag
goes in `CARGO_TARGET_<TRIPLE>_RUSTFLAGS`, so host build scripts aren't affected.
- `apps/photocraft/build.rs` embeds the icon (`assets/app-icon/photocraft.ico`) and
VERSIONINFO with the `winresource` crate. It only does this when targeting Windows. Elsewhere
it's a no-op, and the web build doesn't touch that crate.
- Release builds use the GUI subsystem, so Start Menu launches don't open a console window.
- `Photocraft.wxs` (WiX v5) is a per-machine install into Program Files with an advertised
Start Menu shortcut. Photocraft becomes the default app for `.pcraft` and is listed under
"Open with" for PSD/PSB and image files. It also registers App Paths (Win+R `photocraft`).
The MSI version is the numeric `X.Y.Z`, because MSI has no pre-release field. Same-version
upgrades are allowed so that release candidates replace each other.
- **Signing:** `packaging/windows/sign.ps1` signs both `.exe` files and then the `.msi` with
`signtool`, using SHA-256 and an RFC 3161 timestamp. It uses whichever material is present:
1. a `.pfx` file (`WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD`), timestamped by
DigiCert, or
2. Azure Trusted Signing (`AZURE_*`): the script downloads the
`Microsoft.Trusted.Signing.Client` dlib and timestamps with Microsoft's server.
The script is the single place to change when the Windows signing setup changes.
Locally on Windows: `dotnet tool install -g wix --version 5.0.2`, then
`pwsh packaging/windows/package.ps1 -Arch x64`.
### Linux
`packaging/linux/package.sh` stages one FHS tree and makes every format from it. The tree
holds both binaries, `ai.storyteller.Photocraft.desktop`, hicolor icons from 16 px to 512 px
plus a scalable SVG, AppStream metainfo, and a shared-mime-info file for `.pcraft`, `.psb` and
`.qoi`.
Why these formats:
- **AppImage** runs on any distribution without installing anything. It's the
download-and-go option and the fallback for distros the packages don't cover. It's built
with the maintained `AppImage/appimagetool`, whose static runtime doesn't need libfuse2.
- **.deb** covers Debian, Ubuntu, Mint, Pop!_OS and elementary. **.rpm** covers Fedora, RHEL
and its clones, and openSUSE. Both integrate with the menu, MIME and icon caches (the
`postinst.sh` hook) and uninstall cleanly. Both are built by
[nfpm](https://nfpm.goreleaser.com/): one maintained, dependency-free tool and one config
(`nfpm.yaml`). It doesn't need cargo-deb and cargo-generate-rpm metadata in our crates, and
it can build both formats on Ubuntu.
- **.tar.gz** is for people who manage their own `/opt` or `~/.local`.
- **Flatpak** is the route into Flathub, GNOME Software and KDE Discover.
`packaging/linux/flatpak/ai.storyteller.Photocraft.yml` is ready for a Flathub submission.
CI only validates it. A real build needs vendored crate sources (`cargo-sources.json` from
`flatpak-cargo-generator.py`) and adds 20+ minutes per architecture. Build it by hand with
the commands in the manifest's header.
All Linux binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**: Ubuntu 22.04+,
Debian 12+, Fedora 36+, RHEL 10, openSUSE Tumbleweed. They link only glibc and libgcc_s. X11,
Wayland, xkbcommon, Vulkan and EGL are loaded at runtime from the system, which is also where
the GPU driver has to come from. That's why the packages declare them as dependencies and the
AppImage doesn't bundle them.
Locally (on Linux): install [nfpm](https://nfpm.goreleaser.com/install/), then
`packaging/linux/package.sh` (or `--formats "deb tar"`).
### Web
`packaging/web/package.sh` runs `trunk build --release` (see `apps/photocraft-web/Trunk.toml`)
and zips `dist/web` together with sample `_headers` and `.htaccess` files and the hosting guide.
The site only uses relative URLs, so it works under any path and in an iframe.
[`packaging/web/README.md`](../packaging/web/README.md) covers MIME types, compression,
caching, the iframe snippet and the `?webgl` / `?cpu` flags.
## Secrets
All secrets live in the repository's **`release` environment** (*Settings → Environments →
release*). Restrict its deployment branches to `release`. Every job in `release.yml` declares
`environment: release`, so only pushes to that branch, or manual runs on it, can read the
secrets. Each secret is optional. If one is missing, that platform's artifacts are unsigned and
the run shows a `::warning::`.
| Secret | Used for |
|---|---|
| `APPLE_CERTIFICATE` | base64 `.p12` with "Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" |
| `APPLE_CERTIFICATE_PASSWORD` | password for that `.p12` |
| `KEYCHAIN_PASSWORD` | password for the temporary CI keychain (random if unset) |
| `APPLE_ID` | Apple ID used by `notarytool` |
| `APPLE_PASSWORD` | app-specific password for that Apple ID |
| `APPLE_TEAM_ID` | `DJ6XS33FX8` |
| `WINDOWS_CERTIFICATE` | base64 `.pfx` code-signing certificate |
| `WINDOWS_CERTIFICATE_PASSWORD` | password for that `.pfx` |
| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | service principal for Azure Trusted Signing (an alternative to the `.pfx`) |
| `AZURE_SIGNING_ENDPOINT` | e.g. `https://eus.codesigning.azure.net` |
| `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE` | Trusted Signing account and certificate profile names |
`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`.
On macOS, `packaging/macos/import-cert.sh` decodes the `.p12` into a temporary keychain
(`security create-keychain`, then `security import`, then `set-key-partition-list` so codesign
can use the key without a prompt). It exports the identity's SHA-1 as `MACOS_SIGN_IDENTITY`.
The keychain is deleted at the end of the job.
## Icons
`assets/app-icon/photocraft.svg` is our own artwork (MIT OR Apache-2.0, see `LICENSE.txt`
there). `packaging/icons.sh` regenerates the 1024 px PNG, the `.icns`, the `.ico` (packed by
`cargo xtask ico`) and the hicolor PNGs from it. It needs `resvg`, plus `iconutil` on macOS.
The outputs are committed, so packaging never needs those tools.
## Checks
`.github/workflows/packaging-lint.yml` runs in seconds on any change to `packaging/`, the
workflows or the icons. It runs actionlint, shellcheck, a PowerShell parse, xmllint,
`desktop-file-validate`, `appstreamcli validate`, and a YAML check of the Flatpak manifest.
+3
View File
@@ -0,0 +1,3 @@
# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
source-path=SCRIPTDIR
external-sources=true
+58
View File
@@ -0,0 +1,58 @@
# shellcheck shell=bash
# Shared setup for the packaging scripts. Source it: `. "$(dirname "$0")/../env.sh"`.
#
# Exports:
# ROOT workspace root
# VERSION [workspace.package] version from Cargo.toml (override: PHOTOCRAFT_VERSION)
# DIST output directory for release artifacts (default: $ROOT/dist/release)
# PHOTOCRAFT_BUILD_SHA git commit baked into the binaries (see crates/engine/src/build_info.rs)
# PHOTOCRAFT_BUILD_DATE UTC build date, YYYY-MM-DD
# CARGO_TARGET_DIR cargo's target dir (default: $ROOT/target)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export ROOT
# The version lives in exactly one place: `[workspace.package] version` in the root Cargo.toml.
# (`cargo xtask version` prints the same thing; awk avoids compiling xtask here.)
workspace_version() {
awk '
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
' "$ROOT/Cargo.toml"
}
VERSION="${PHOTOCRAFT_VERSION:-$(workspace_version)}"
if [ -z "$VERSION" ]; then
echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2
exit 1
fi
export VERSION
DIST="${DIST:-$ROOT/dist/release}"
mkdir -p "$DIST"
export DIST
if [ -z "${PHOTOCRAFT_BUILD_SHA:-}" ]; then
PHOTOCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
fi
export PHOTOCRAFT_BUILD_SHA
export PHOTOCRAFT_BUILD_DATE="${PHOTOCRAFT_BUILD_DATE:-$(date -u +%Y-%m-%d)}"
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions).
warn() {
if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
}
# Copy licence and readme files that exist into a package directory.
copy_docs() {
local dest="$1" f
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
done
}
# Portable SHA-256 of a file (prints just the hash).
sha256() {
if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Regenerate every app icon from assets/app-icon/photocraft.svg.
#
# Needs: resvg (brew install resvg / cargo install resvg). On macOS, iconutil also writes the
# .icns. The outputs are committed, so packaging never needs these tools.
#
# packaging/icons.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIR="$ROOT/assets/app-icon"
SVG="$DIR/photocraft.svg"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
command -v resvg >/dev/null || { echo "error: resvg not found (brew install resvg)" >&2; exit 1; }
# macOS icons keep Apple's 824/1024 body grid (transparent margin). Windows and Linux icons are
# cropped tighter so the shape reads at 16-48 px.
TIGHT="$TMP/tight.svg"
sed 's/viewBox="0 0 1024 1024"/viewBox="88 88 848 848"/' "$SVG" >"$TIGHT"
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
render "$SVG" 1024 "$DIR/photocraft-1024.png"
# Linux hicolor theme.
for s in 16 24 32 48 64 128 256 512; do
mkdir -p "$DIR/hicolor/${s}x${s}/apps"
render "$TIGHT" "$s" "$DIR/hicolor/${s}x${s}/apps/ai.storyteller.Photocraft.png"
done
mkdir -p "$DIR/hicolor/scalable/apps"
cp "$SVG" "$DIR/hicolor/scalable/apps/ai.storyteller.Photocraft.svg"
# Windows .ico.
ICO_PNGS=()
for s in 16 20 24 32 40 48 64 128 256; do
render "$TIGHT" "$s" "$TMP/ico-$s.png"
ICO_PNGS+=("$TMP/ico-$s.png")
done
(cd "$ROOT" && cargo run -q -p xtask -- ico "$DIR/photocraft.ico" "${ICO_PNGS[@]}")
# macOS .icns.
if command -v iconutil >/dev/null; then
SET="$TMP/photocraft.iconset"
mkdir -p "$SET"
for s in 16 32 128 256 512; do
render "$SVG" "$s" "$SET/icon_${s}x${s}.png"
render "$SVG" $((s * 2)) "$SET/icon_${s}x${s}@2x.png"
done
iconutil -c icns -o "$DIR/photocraft.icns" "$SET"
else
echo "warning: iconutil not found (macOS only); photocraft.icns not regenerated" >&2
fi
echo "icons written to $DIR"
@@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Name=Photocraft
GenericName=Image Editor
Comment=Edit photos and layered PSD documents
Exec=photocraft %F
TryExec=photocraft
Icon=ai.storyteller.Photocraft
Terminal=false
StartupNotify=true
StartupWMClass=photocraft
Categories=Graphics;2DGraphics;RasterGraphics;Photography;
Keywords=photo;image;editor;psd;photoshop;layers;retouch;paint;
MimeType=application/x-photocraft;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi;
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- AppStream metadata (software centres, Flathub). Template: package.sh and the Flatpak
manifest substitute @VERSION@ and @DATE@ from Cargo.toml and the build date. -->
<component type="desktop-application">
<id>ai.storyteller.Photocraft</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT OR Apache-2.0</project_license>
<name>Photocraft</name>
<summary>Edit photos and layered Photoshop documents</summary>
<developer id="ai.storyteller">
<name>Learning Machines LLC</name>
</developer>
<description>
<p>
Photocraft is an open-source, native image editor that works the way Photoshop users
expect: familiar menus, shortcuts, panels and tools, with layers, masks, adjustment
layers, layer styles, type and brushes.
</p>
<ul>
<li>Open, edit and save layered PSD and PSB files</li>
<li>8-bit, 16-bit and 32-bit float documents in RGB, grayscale, CMYK and Lab</li>
<li>A GPU compositor with multithreaded filters</li>
<li>Every action is a command, scriptable from a CLI, a JSON control channel and an MCP server</li>
</ul>
</description>
<launchable type="desktop-id">ai.storyteller.Photocraft.desktop</launchable>
<icon type="stock">ai.storyteller.Photocraft</icon>
<url type="homepage">https://github.com/storytold/photocraft</url>
<url type="bugtracker">https://github.com/storytold/photocraft/issues</url>
<url type="vcs-browser">https://github.com/storytold/photocraft</url>
<categories>
<category>Graphics</category>
<category>RasterGraphics</category>
<category>Photography</category>
</categories>
<keywords>
<keyword>photo</keyword>
<keyword>image editor</keyword>
<keyword>psd</keyword>
<keyword>layers</keyword>
</keywords>
<screenshots>
<screenshot type="default">
<caption>Editing The Great Wave off Kanagawa with type layers and adjustment layers</caption>
<image>https://raw.githubusercontent.com/storytold/photocraft/main/docs/images/photocraft-demo.jpg</image>
</screenshot>
</screenshots>
<branding>
<color type="primary" scheme_preference="light">#9fd0ff</color>
<color type="primary" scheme_preference="dark">#1f64ba</color>
</branding>
<requires>
<display_length compare="ge">760</display_length>
</requires>
<supports>
<control>keyboard</control>
<control>pointing</control>
<control>tablet</control>
</supports>
<provides>
<binary>photocraft</binary>
<mediatype>application/x-photocraft</mediatype>
<mediatype>image/vnd.adobe.photoshop</mediatype>
<mediatype>image/x-psb</mediatype>
</provides>
<content_rating type="oars-1.1"/>
<releases>
<release version="@VERSION@" date="@DATE@"/>
</releases>
</component>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- shared-mime-info definitions for types the system database may not have.
Installed to /usr/share/mime/packages/ (update-mime-database runs on install). -->
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-photocraft">
<comment>Photocraft document</comment>
<icon name="ai.storyteller.Photocraft"/>
<glob pattern="*.pcraft"/>
</mime-type>
<mime-type type="image/x-psb">
<comment>Photoshop large document</comment>
<sub-class-of type="image/vnd.adobe.photoshop"/>
<generic-icon name="image-x-generic"/>
<magic priority="60">
<match type="string" value="8BPS\x00\x02" offset="0"/>
</magic>
<glob pattern="*.psb"/>
</mime-type>
<mime-type type="image/qoi">
<comment>QOI image</comment>
<generic-icon name="image-x-generic"/>
<magic priority="50">
<match type="string" value="qoif" offset="0"/>
</magic>
<glob pattern="*.qoi"/>
</mime-type>
</mime-info>
@@ -0,0 +1,56 @@
# Flatpak manifest for Photocraft, ready for a Flathub submission.
#
# Flatpak builds run offline, so the crate sources must be vendored first. From the repo root:
#
# curl -fsSLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/linux/flatpak/cargo-sources.json
# flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.Photocraft.yml
# flatpak run ai.storyteller.Photocraft
#
# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
id: ai.storyteller.Photocraft
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: photocraft
finish-args:
- --share=ipc
- --socket=wayland
- --socket=fallback-x11
- --device=dri
# Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
# portal, so broad filesystem access isn't needed. Pictures is granted for opening files by
# path from the command line.
- --filesystem=xdg-pictures
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules:
- name: photocraft
buildsystem: simple
build-options:
env:
CARGO_HOME: /run/build/photocraft/cargo
CARGO_NET_OFFLINE: 'true'
build-commands:
- cargo --offline build --release --locked -p photocraft
- install -Dm755 target/release/photocraft /app/bin/photocraft
- install -Dm644 packaging/linux/ai.storyteller.Photocraft.desktop /app/share/applications/ai.storyteller.Photocraft.desktop
- install -Dm644 packaging/linux/ai.storyteller.Photocraft.mime.xml /app/share/mime/packages/ai.storyteller.Photocraft.xml
- mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/
- >-
version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml);
install -d /app/share/metainfo;
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/"
packaging/linux/ai.storyteller.Photocraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.Photocraft.metainfo.xml
sources:
- type: dir
path: ../../..
skip:
- target
- dist
- corpus
- build-flatpak
- .flatpak-builder
- cargo-sources.json
+69
View File
@@ -0,0 +1,69 @@
# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages
# the file tree in $STAGE and exports VERSION, NFPM_ARCH and STAGE.
#
# nfpm package -f packaging/linux/nfpm.yaml -p deb -t dist/release/
name: photocraft
arch: ${NFPM_ARCH}
platform: linux
version: ${VERSION}
# semver: 0.2.0-rc.1 becomes 0.2.0~rc.1 in deb/rpm, so pre-releases sort before the release.
version_schema: semver
release: 1
section: graphics
priority: optional
maintainer: ${PHOTOCRAFT_MAINTAINER}
vendor: Learning Machines LLC
homepage: https://github.com/storytold/photocraft
license: MIT OR Apache-2.0
description: |-
Open-source, native image editor that works the way Photoshop users expect.
Layers, masks, adjustment layers, layer styles, type and brushes; opens and
saves layered PSD/PSB files; 8/16/32-bit documents; GPU compositing (wgpu).
Includes photocraft-cli, a headless converter and command runner.
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
# GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here
# rather than ELF NEEDED entries.
overrides:
deb:
depends:
- libc6 (>= 2.35)
- libgcc-s1
- libxkbcommon0
- libvulkan1 | libegl1
recommends:
- libwayland-client0
- libx11-6
- libx11-xcb1
- libxcursor1
- libxi6
- libxrandr2
- mesa-vulkan-drivers
- xdg-desktop-portal
rpm:
depends:
- libxkbcommon
- (vulkan-loader or libglvnd-egl)
recommends:
- libwayland-client
- libX11
- libXcursor
- libXi
- libXrandr
- mesa-vulkan-drivers
- xdg-desktop-portal
contents:
- src: ${STAGE}/usr/bin/photocraft
dst: /usr/bin/photocraft
file_info: { mode: 0755 }
- src: ${STAGE}/usr/bin/photocraft-cli
dst: /usr/bin/photocraft-cli
file_info: { mode: 0755 }
- src: ${STAGE}/usr/share/
dst: /usr/share/
type: tree
scripts:
postinstall: packaging/linux/postinst.sh
postremove: packaging/linux/postinst.sh
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Build and package Photocraft for Linux:
#
# $DIST/Photocraft-<version>-<arch>.AppImage runs on any distro with glibc >= the build host's
# $DIST/photocraft_<version>-1_<debarch>.deb Debian, Ubuntu, Mint, Pop!_OS, ...
# $DIST/photocraft-<version>-1.<arch>.rpm Fedora, openSUSE, RHEL, ...
# $DIST/photocraft-<version>-linux-<arch>.tar.gz plain FHS-style tree (bin/, share/)
#
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"]
#
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
APP_ID=ai.storyteller.Photocraft
SKIP_BUILD=0
FORMATS="appimage deb rpm tar"
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) DEB_ARCH=amd64 ;;
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
export PHOTOCRAFT_MAINTAINER="${PHOTOCRAFT_MAINTAINER:-Photocraft maintainers <photocraft@storyteller.ai>}"
echo "==> Photocraft $VERSION for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli)
fi
BIN="$CARGO_TARGET_DIR/release"
WORK="$CARGO_TARGET_DIR/linux-package"
STAGE="$WORK/root"
rm -rf "$WORK"
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
install -Dm755 "$BIN/photocraft" "$STAGE/usr/bin/photocraft"
install -Dm755 "$BIN/photocraft-cli" "$STAGE/usr/bin/photocraft-cli"
strip "$STAGE/usr/bin/photocraft" "$STAGE/usr/bin/photocraft-cli" 2>/dev/null || true
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
mkdir -p "$STAGE/usr/share/metainfo"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$PHOTOCRAFT_BUILD_DATE/g" \
"$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
mkdir -p "$STAGE/usr/share/icons"
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/"
mkdir -p "$STAGE/usr/share/doc/photocraft"
copy_docs "$STAGE/usr/share/doc/photocraft"
if command -v desktop-file-validate >/dev/null; then
desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop"
fi
if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then
NAME="photocraft-$VERSION-linux-$ARCH"
mkdir -p "$WORK/tar"
cp -R "$STAGE/usr" "$WORK/tar/$NAME"
tar -C "$WORK/tar" -czf "$DIST/$NAME.tar.gz" "$NAME"
echo "wrote $DIST/$NAME.tar.gz"
fi
# ---- .deb / .rpm --------------------------------------------------------------------------------
if has deb || has rpm; then
command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; }
export STAGE VERSION
export NFPM_ARCH="$DEB_ARCH"
for fmt in deb rpm; do
if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$HERE/nfpm.yaml" -p "$fmt" -t "$DIST/"); fi
done
fi
# ---- AppImage -----------------------------------------------------------------------------------
if has appimage; then
APPDIR="$WORK/Photocraft.AppDir"
cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
ln -s usr/bin/photocraft "$APPDIR/AppRun"
cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [ -z "$TOOL" ]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
fi
OUT="$DIST/Photocraft-$VERSION-$ARCH.AppImage"
# Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime,
# so users don't need libfuse2 either.
ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT"
echo "wrote $OUT"
fi
"$STAGE/usr/bin/photocraft-cli" --version
echo "==> done"
ls -lh "$DIST"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# deb/rpm post-install and post-remove: refresh the desktop, MIME and icon caches.
# Every tool is optional; missing ones are skipped.
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v update-mime-database >/dev/null 2>&1; then
update-mime-database /usr/share/mime || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
exit 0
+162
View File
@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Template: packaging/macos/package.sh substitutes @VERSION@ (full, e.g. 0.2.0-rc.1),
@SHORT_VERSION@ (numeric X.Y.Z) and @BUILD_SHA@. -->
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Photocraft</string>
<key>CFBundleName</key>
<string>Photocraft</string>
<key>CFBundleExecutable</key>
<string>photocraft</string>
<key>CFBundleIdentifier</key>
<string>ai.storyteller.photocraft</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleShortVersionString</key>
<string>@SHORT_VERSION@</string>
<key>CFBundleVersion</key>
<string>@SHORT_VERSION@</string>
<key>PhotocraftVersion</key>
<string>@VERSION@</string>
<key>PhotocraftBuildCommit</key>
<string>@BUILD_SHA@</string>
<key>CFBundleIconFile</key>
<string>photocraft</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.graphics-design</string>
<!-- Must match MACOSX_DEPLOYMENT_TARGET in package.sh. Big Sur is the first release on
Apple silicon and has the Metal feature set wgpu targets. -->
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>NSRequiresAquaSystemAppearance</key>
<false/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © the Photocraft authors. MIT OR Apache-2.0.</string>
<!-- Photocraft's own format. -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.photocraft.document</string>
<key>UTTypeDescription</key>
<string>Photocraft Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeIconFile</key>
<string>photocraft</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>pcraft</string></array>
<key>public.mime-type</key>
<array><string>application/x-photocraft</string></array>
</dict>
</dict>
</array>
<!-- Types macOS may not know; the system's own declaration wins when it has one. -->
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.adobe.photoshop-large-image</string>
<key>UTTypeDescription</key>
<string>Photoshop Large Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>psb</string></array>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.photocraft.qoi</string>
<key>UTTypeDescription</key>
<string>QOI Image</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>qoi</string></array>
</dict>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Photocraft Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>CFBundleTypeIconFile</key>
<string>photocraft</string>
<key>LSItemContentTypes</key>
<array><string>ai.storyteller.photocraft.document</string></array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Photoshop Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.photoshop-image</string>
<string>com.adobe.photoshop-large-image</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Image</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.png</string>
<string>public.jpeg</string>
<string>public.tiff</string>
<string>org.webmproject.webp</string>
<string>com.compuserve.gif</string>
<string>com.microsoft.bmp</string>
<string>com.truevision.tga-image</string>
<string>com.microsoft.ico</string>
<string>public.pbm</string>
<string>com.ilm.openexr-image</string>
<string>public.radiance</string>
<string>public.avif</string>
<string>ai.storyteller.photocraft.qoi</string>
</array>
</dict>
</array>
</dict>
</plist>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Hardened-runtime entitlements for Photocraft. Deliberately empty: the app is pure Rust,
loads no unsigned code, needs no JIT, and is not sandboxed (it opens files by path, from the
command line and drag-and-drop). Add exceptions here only with a reason in a comment. -->
<plist version="1.0">
<dict>
</dict>
</plist>
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# CI only: import the Developer ID certificate into a temporary keychain so codesign can use it.
#
# Inputs (env): APPLE_CERTIFICATE (base64 .p12), APPLE_CERTIFICATE_PASSWORD, KEYCHAIN_PASSWORD
# (optional; random if unset), RUNNER_TEMP (or TMPDIR).
# Outputs: MACOS_SIGN_IDENTITY (SHA-1 of the identity) and MACOS_KEYCHAIN, appended to
# $GITHUB_ENV when running in Actions, otherwise printed.
#
# Missing certificate: prints a warning and exits 0, so package.sh falls back to ad-hoc signing.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
if [ -z "${APPLE_CERTIFICATE:-}" ]; then
warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized"
exit 0
fi
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
KEYCHAIN="$TMP/photocraft-signing.keychain-db"
CERT="$TMP/photocraft-signing.p12"
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KC_PASS" "$KEYCHAIN"
security import "$CERT" -P "${APPLE_CERTIFICATE_PASSWORD:-}" -A -t cert -f pkcs12 -k "$KEYCHAIN"
rm -f "$CERT"
# Let codesign use the key without a UI prompt.
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KEYCHAIN" >/dev/null
# Put it on the search list (keeping the login keychain) so codesign and notarytool find it.
# shellcheck disable=SC2046
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')"
if [ -z "$IDENTITY" ]; then
security find-identity -v -p codesigning "$KEYCHAIN" >&2
echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2
exit 1
fi
echo "Imported signing identity $IDENTITY"
if [ -n "${GITHUB_ENV:-}" ]; then
{ echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV"
else
echo "MACOS_SIGN_IDENTITY=$IDENTITY"
echo "MACOS_KEYCHAIN=$KEYCHAIN"
fi
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# Build, sign and (optionally) notarize the macOS release artifacts:
#
# $DIST/Photocraft-<version>-macos-<arch>.dmg Photocraft.app on a drag-to-Applications DMG
# $DIST/photocraft-cli-<version>-macos-<arch>.zip the headless CLI
#
# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
#
# Signing (env):
# MACOS_SIGN_IDENTITY codesign identity (name or SHA-1). Default "-" = ad-hoc (local testing;
# Gatekeeper will reject the result on other Macs). In CI, import-cert.sh sets it.
# MACOS_KEYCHAIN keychain holding the identity (optional)
# Notarization (env; all three needed, and a real identity):
# APPLE_ID, APPLE_PASSWORD (app-specific password), APPLE_TEAM_ID
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/macos"
ARCH=universal
SKIP_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
case "$ARCH" in
universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) ;;
aarch64) TARGETS=(aarch64-apple-darwin) ;;
x86_64) TARGETS=(x86_64-apple-darwin) ;;
*) echo "unknown --arch $ARCH" >&2; exit 2 ;;
esac
# Keep in sync with LSMinimumSystemVersion in Info.plist.in.
export MACOSX_DEPLOYMENT_TARGET=11.0
IDENTITY="${MACOS_SIGN_IDENTITY:--}"
SHORT_VERSION="${VERSION%%-*}"
WORK="$CARGO_TARGET_DIR/macos-package"
APP="$WORK/Photocraft.app"
DMG="$DIST/Photocraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/photocraft-cli-$VERSION-macos-$ARCH.zip"
NOTARIZE=0
if [ "$IDENTITY" = "-" ]; then
warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized"
elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
NOTARIZE=1
else
warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized"
fi
echo "==> Photocraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE"
# ---- build -------------------------------------------------------------------------------------
if [ "$SKIP_BUILD" = 0 ]; then
args=()
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli "${args[@]}")
fi
rm -rf "$WORK"
mkdir -p "$WORK/bin"
for bin in photocraft photocraft-cli; do
inputs=()
for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done
lipo -create -output "$WORK/bin/$bin" "${inputs[@]}"
lipo -info "$WORK/bin/$bin"
done
# ---- signing helpers ---------------------------------------------------------------------------
sign() {
# Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped.
local ts=(--timestamp)
[ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
local kc=()
[ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN")
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
}
notarize() {
local file="$1" out id status
echo "==> notarizing $(basename "$file") (this can take a few minutes)"
out="$(xcrun notarytool submit "$file" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait --timeout 1h --output-format json)" || true
echo "$out"
id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)"
status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)"
if [ "$status" != "Accepted" ]; then
if [ -n "$id" ]; then
xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true
fi
echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2
exit 1
fi
}
# ---- Photocraft.app ----------------------------------------------------------------------------
echo "==> assembling $APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$WORK/bin/photocraft" "$APP/Contents/MacOS/photocraft"
cp "$ROOT/assets/app-icon/photocraft.icns" "$APP/Contents/Resources/photocraft.icns"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
-e "s/@BUILD_SHA@/${PHOTOCRAFT_BUILD_SHA:-unknown}/g" \
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
plutil -lint "$APP/Contents/Info.plist"
printf 'APPL????' >"$APP/Contents/PkgInfo"
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/photocraft"
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
codesign --verify --strict --deep --verbose=2 "$APP"
if [ "$NOTARIZE" = 1 ]; then
ditto -c -k --keepParent "$APP" "$WORK/Photocraft-notarize.zip"
notarize "$WORK/Photocraft-notarize.zip"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl --assess --type execute -vvv "$APP"
fi
# ---- DMG ---------------------------------------------------------------------------------------
echo "==> building $DMG"
STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/Photocraft.app"
ln -s /Applications "$STAGE/Applications"
rm -f "$DMG"
hdiutil create -volname "Photocraft $VERSION" -srcfolder "$STAGE" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
if [ "$NOTARIZE" = 1 ]; then
notarize "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl --assess --type open --context context:primary-signature -vvv "$DMG"
fi
# ---- CLI ---------------------------------------------------------------------------------------
echo "==> building $CLI_ZIP"
CLI_DIR="$WORK/photocraft-cli-$VERSION-macos-$ARCH"
mkdir -p "$CLI_DIR"
cp "$WORK/bin/photocraft-cli" "$CLI_DIR/"
copy_docs "$CLI_DIR"
sign --options runtime "$CLI_DIR/photocraft-cli"
codesign --verify --strict --verbose=2 "$CLI_DIR/photocraft-cli"
rm -f "$CLI_ZIP"
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online.
if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi
"$WORK/bin/photocraft-cli" --version
echo "==> done"
ls -lh "$DMG" "$CLI_ZIP"
+16
View File
@@ -0,0 +1,16 @@
# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional.
AddType application/wasm .wasm
AddType text/javascript .js
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(wasm|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "^index\.html$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>
+89
View File
@@ -0,0 +1,89 @@
# Hosting Photocraft for the web
`photocraft-web-<version>.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a
static site in `photocraft-web-<version>/`:
| File | What it is |
|---|---|
| `index.html` | The page. It loads everything through relative URLs. |
| `photocraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
| `photocraft-web-<hash>_bg.wasm` | The app, about 13 MB, or 5 MB with compression |
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
There is no server-side code. Upload the folder's contents anywhere that serves static files.
## Any path works
All URLs in `index.html` are relative (`public_url = "./"` in `apps/photocraft-web/Trunk.toml`),
so the site works at a domain root (`https://example.com/`), under a prefix
(`https://example.com/tools/photocraft/`) and from a CDN bucket. The asset names carry a content
hash, so they can be cached forever. Only `index.html` needs revalidation.
## Required server settings
- **MIME type:** serve `.wasm` as `application/wasm`. Browsers refuse to stream-compile it under
any other type, and the app then loads slowly or not at all. Serve `.js` as `text/javascript`.
Most hosts already do both. For nginx, check that `mime.types` has `application/wasm wasm;`.
- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. That takes the
download from about 13 MB to about 5 MB. You can also precompress (`brotli -k *.wasm`) and let
the server send `Content-Encoding: br`.
- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the hashed `.wasm` and
`.js` files, and `no-cache` on `index.html`.
- **HTTPS:** WebGPU (and the clipboard) only work in a secure context, which means `https://`
or `http://localhost`. Over plain HTTP elsewhere, the app falls back to WebGL2.
- **No special isolation headers:** Photocraft doesn't use `SharedArrayBuffer`, so it doesn't
need `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy`. If your site already sends
COEP `require-corp`, also send `Cross-Origin-Resource-Policy: same-origin` (or `cross-origin`
when the files live on a CDN) on the app's files.
nginx example:
```nginx
location /photocraft/ {
types { application/wasm wasm; text/javascript js; text/html html; }
gzip on;
gzip_types application/wasm text/javascript text/html;
location ~* \.(wasm|js)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location ~* index\.html$ { add_header Cache-Control "no-cache"; }
}
```
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
## Embedding in a page (iframe)
```html
<iframe
src="https://example.com/photocraft/"
title="Photocraft image editor"
style="width: 100%; height: 720px; border: 0;"
allow="fullscreen; clipboard-read; clipboard-write"
allowfullscreen>
</iframe>
```
- The app fills the iframe and follows its size, so size the iframe and not the app.
- Keyboard shortcuts go to the iframe after the user clicks into it, as with any embedded app.
- **Cross-origin embeds** work. Preferences are kept in the iframe's `localStorage`. Browsers
that partition or block third-party storage may forget them between visits, and the app
then starts with defaults.
- **Sandboxed iframes** need at least
`sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"`. Without
`allow-same-origin` there's no storage. Without `allow-downloads`, Save and Export (browser
downloads) are blocked.
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## Renderer selection and fallback flags
Photocraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
**WebGL2** on its own. URL query flags override this, and they work on the iframe `src` too:
| Flag | Effect |
|---|---|
| *(none)* | WebGPU if available, otherwise WebGL2 |
| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) |
| `?cpu` | Force the CPU canvas path (slowest, most compatible) |
For example: `<iframe src="https://example.com/photocraft/?webgl" ...>`.
A browser with neither WebGPU nor WebGL2 gets a message in place of the app.
+13
View File
@@ -0,0 +1,13 @@
# Netlify / Cloudflare Pages header rules (see HOSTING.md). Other hosts: set the same headers.
/*
X-Content-Type-Options: nosniff
/*.wasm
Content-Type: application/wasm
Cache-Control: public, max-age=31536000, immutable
/*.js
Cache-Control: public, max-age=31536000, immutable
/index.html
Cache-Control: no-cache
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Build the browser version and zip it: $DIST/photocraft-web-<version>.zip
#
# Usage: packaging/web/package.sh [--skip-build]
#
# Needs: trunk (brew install trunk / cargo install trunk --locked) and the wasm32-unknown-unknown
# target. The zip holds a self-contained static site in photocraft-web-<version>/ that works
# from any URL path and inside an <iframe>. Hosting notes: packaging/web/README.md.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/web"
if [ "${1:-}" != "--skip-build" ]; then
command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; }
(cd "$ROOT/apps/photocraft-web" && trunk build --release)
fi
SITE="$ROOT/dist/web"
[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
# Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml).
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
exit 1
fi
NAME="photocraft-web-$VERSION"
WORK="$CARGO_TARGET_DIR/web-package"
rm -rf "$WORK"
mkdir -p "$WORK/$NAME"
cp -R "$SITE/." "$WORK/$NAME/"
# Sample server configs (MIME type, caching, compression); harmless where unused.
cp "$HERE/_headers" "$HERE/.htaccess" "$WORK/$NAME/"
cp "$HERE/README.md" "$WORK/$NAME/HOSTING.md"
copy_docs "$WORK/$NAME"
rm -f "$DIST/$NAME.zip"
(cd "$WORK" && zip -qr9 "$DIST/$NAME.zip" "$NAME")
echo "wrote $DIST/$NAME.zip"
ls -lh "$DIST/$NAME.zip"
+107
View File
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Photocraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
wix build Photocraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, `.pcraft` owned by Photocraft,
and Photocraft offered under "Open with" for PSD/PSB and the image formats it reads (without
taking over the user's default app for them).
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="Photocraft"
Manufacturer="Learning Machines LLC"
Version="$(var.Version)"
UpgradeCode="D9660F32-2D0A-4ABE-B73D-17BE25F1542C"
Scope="perMachine"
Compressed="yes"
Language="1033">
<SummaryInformation Description="Photocraft image editor" Manufacturer="Learning Machines LLC" />
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
<MajorUpgrade AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of Photocraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="PhotocraftIcon" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="PhotocraftIcon" />
<Property Id="ARPURLINFOABOUT" Value="https://github.com/storytold/photocraft" />
<Property Id="ARPHELPLINK" Value="https://github.com/storytold/photocraft/issues" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="Photocraft" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="Photocraft" Level="1">
<ComponentGroupRef Id="PhotocraftFiles" />
</Feature>
</Package>
<Fragment>
<ComponentGroup Id="PhotocraftFiles" Directory="INSTALLFOLDER">
<Component Id="PhotocraftApp" Guid="A6DE41B7-A80F-4C59-A17E-BB68065BF67E">
<File Id="PhotocraftExe" Source="$(var.BinDir)\photocraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="Photocraft"
Description="Photocraft image editor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="PhotocraftIcon" IconIndex="0" />
</File>
<!-- .pcraft: Photocraft's own format; Photocraft is its default app. -->
<ProgId Id="Photocraft.Document" Description="Photocraft Document" Icon="PhotocraftExe" IconIndex="0">
<Extension Id="pcraft" ContentType="application/x-photocraft">
<Verb Id="open" Command="Open" TargetFile="PhotocraftExe" Argument="&quot;%1&quot;" />
</Extension>
</ProgId>
<!-- PSD/PSB and images: offered under "Open with", never forced as the default. -->
<ProgId Id="Photocraft.PhotoshopDocument" Description="Photoshop Document" Icon="PhotocraftExe" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\Classes\Photocraft.PhotoshopDocument\shell\open\command" Value="&quot;[#PhotocraftExe]&quot; &quot;%1&quot;" Type="string" />
<ProgId Id="Photocraft.Image" Description="Image" Icon="PhotocraftExe" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\Classes\Photocraft.Image\shell\open\command" Value="&quot;[#PhotocraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.psd\OpenWithProgids" Name="Photocraft.PhotoshopDocument" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.psb\OpenWithProgids" Name="Photocraft.PhotoshopDocument" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.png\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.jpg\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.jpeg\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.tif\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.tiff\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.webp\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.gif\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.bmp\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.tga\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.exr\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.hdr\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.avif\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.qoi\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.ico\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<!-- "Open with" app list and the Run dialog (Win+R "photocraft"). -->
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe" Name="FriendlyAppName" Value="Photocraft" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\shell\open\command" Value="&quot;[#PhotocraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".pcraft" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".psd" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".psb" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".png" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".jpg" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".jpeg" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".tif" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".tiff" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".webp" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".gif" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".bmp" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".tga" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".exr" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".hdr" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".avif" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".qoi" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".ico" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe" Value="[#PhotocraftExe]" Type="string" />
</Component>
<Component Id="PhotocraftCli">
<File Id="PhotocraftCliExe" Source="$(var.BinDir)\photocraft-cli.exe" KeyPath="yes" />
</Component>
</ComponentGroup>
</Fragment>
</Wix>
+98
View File
@@ -0,0 +1,98 @@
<#
.SYNOPSIS
Build, sign and package Photocraft for Windows.
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
Photocraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
Photocraft-<version>-windows-<arch>-portable.zip photocraft.exe + photocraft-cli.exe
The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable
zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a
warning when no signing secrets are set).
Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe),
and WiX v5: dotnet tool install --global wix --version 5.0.2
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild
#>
param(
[ValidateSet('x64', 'x86')] [string] $Arch = 'x64',
[switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Invoke-Native([string] $What, [scriptblock] $Block) {
Write-Output "==> $What"
& $Block
if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" }
}
# The version lives in one place: [workspace.package] version in the root Cargo.toml.
$Version = $env:PHOTOCRAFT_VERSION
if (-not $Version) {
$inPkg = $false
foreach ($line in Get-Content (Join-Path $Root 'Cargo.toml')) {
if ($line -match '^\s*\[') { $inPkg = ($line.Trim() -eq '[workspace.package]'); continue }
if ($inPkg -and $line -match '^\s*version\s*=\s*"([^"]+)"') { $Version = $Matches[1]; break }
}
}
if (-not $Version) { throw 'could not read [workspace.package] version from Cargo.toml' }
# MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there.
$MsiVersion = ($Version -split '-')[0]
$Target = if ($Arch -eq 'x64') { 'x86_64-pc-windows-msvc' } else { 'i686-pc-windows-msvc' }
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
if (-not $env:PHOTOCRAFT_BUILD_SHA) { $env:PHOTOCRAFT_BUILD_SHA = (git -C $Root rev-parse HEAD 2>$null) }
if (-not $env:PHOTOCRAFT_BUILD_DATE) { $env:PHOTOCRAFT_BUILD_DATE = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') }
Write-Output "Photocraft $Version for Windows $Arch ($Target)"
if (-not $SkipBuild) {
# Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and
# proc-macros are unaffected.
$flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS'
[Environment]::SetEnvironmentVariable($flagVar, '-C target-feature=+crt-static')
# Fail the build (rather than warn) if the icon/VERSIONINFO can't be embedded.
$env:PHOTOCRAFT_REQUIRE_WINRES = '1'
Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p photocraft -p photocraft-cli --target $Target }
}
$Bin = Join-Path $TargetDir "$Target\release"
$Stage = Join-Path $TargetDir "windows-package\$Arch"
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
Copy-Item (Join-Path $Bin 'photocraft.exe'), (Join-Path $Bin 'photocraft-cli.exe') $Stage
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'photocraft.exe') (Join-Path $Stage 'photocraft-cli.exe')
# ---- MSI ---------------------------------------------------------------------------------------
$Msi = Join-Path $Dist "Photocraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'Photocraft.wxs') -arch $Arch `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\photocraft.ico')" `
-o $Msi
}
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
# ---- portable zip ------------------------------------------------------------------------------
$Portable = Join-Path $TargetDir "windows-package\Photocraft-$Version-windows-$Arch-portable"
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
Copy-Item (Join-Path $Stage '*.exe') $Portable
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Portable }
}
$Zip = Join-Path $Dist "Photocraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
Invoke-Native 'photocraft-cli --version' { & (Join-Path $Stage 'photocraft-cli.exe') --version }
Get-Item $Msi, $Zip | Format-Table Name, Length
+95
View File
@@ -0,0 +1,95 @@
<#
.SYNOPSIS
Authenticode-sign files with signtool, using whichever signing material is configured.
.DESCRIPTION
Tries, in order:
1. A code-signing certificate file: WINDOWS_CERTIFICATE (base64 .pfx), WINDOWS_CERTIFICATE_PASSWORD
2. Azure Trusted Signing: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET,
AZURE_SIGNING_ENDPOINT (e.g. https://eus.codesigning.azure.net),
AZURE_SIGNING_ACCOUNT, AZURE_CERT_PROFILE
With neither, it prints a warning and leaves the files unsigned (exit code 0), so test builds
still produce installers.
This is the one place to adapt when the signing setup changes (an HSM/cloud key, a different
timestamp server, ...). Optional overrides: WINDOWS_TIMESTAMP_URL, SIGNTOOL (path to signtool.exe).
.EXAMPLE
pwsh packaging/windows/sign.ps1 dist/photocraft.exe dist/Photocraft.msi
#>
param(
[Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)]
[string[]] $Files
)
$ErrorActionPreference = 'Stop'
function Write-Warn([string] $Message) {
if ($env:GITHUB_ACTIONS) { Write-Output "::warning::$Message" } else { Write-Warning $Message }
}
function Find-SignTool {
if ($env:SIGNTOOL) { return $env:SIGNTOOL }
$onPath = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($onPath) { return $onPath.Source }
$kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$found = Get-ChildItem -Path $kits -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $found) { throw 'signtool.exe not found (install the Windows SDK)' }
return $found.FullName
}
function Invoke-SignTool([string[]] $Arguments) {
& $script:SignTool @Arguments
if ($LASTEXITCODE -ne 0) { throw "signtool $($Arguments[0]) failed with exit code $LASTEXITCODE" }
}
$Files = $Files | ForEach-Object { (Resolve-Path $_).Path }
$haveCert = [bool]$env:WINDOWS_CERTIFICATE
$azureVars = 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET', 'AZURE_SIGNING_ENDPOINT', 'AZURE_SIGNING_ACCOUNT', 'AZURE_CERT_PROFILE'
$haveAzure = -not ($azureVars | Where-Object { -not [Environment]::GetEnvironmentVariable($_) })
if (-not $haveCert -and -not $haveAzure) {
Write-Warn "Windows signing secrets not set (WINDOWS_CERTIFICATE or AZURE_*): leaving unsigned: $($Files -join ', ')"
exit 0
}
$script:SignTool = Find-SignTool
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'Photocraft', '/du', 'https://github.com/storytold/photocraft')
$tmp = Join-Path ([IO.Path]::GetTempPath()) "photocraft-sign-$PID"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
try {
if ($haveCert) {
Write-Output "Signing with WINDOWS_CERTIFICATE: $($Files -join ', ')"
$pfx = Join-Path $tmp 'cert.pfx'
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
$ts = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$signArgs = $common + @('/tr', $ts, '/f', $pfx)
if ($env:WINDOWS_CERTIFICATE_PASSWORD) { $signArgs += @('/p', $env:WINDOWS_CERTIFICATE_PASSWORD) }
Invoke-SignTool ($signArgs + $Files)
}
else {
Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')"
# The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID,
# AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment.
$pkg = Join-Path $tmp 'trusted-signing.zip'
Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg
Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force
$dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll |
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1
if (-not $dlib) { throw 'Azure.CodeSigning.Dlib.dll not found in Microsoft.Trusted.Signing.Client' }
$metadata = Join-Path $tmp 'metadata.json'
@{
Endpoint = $env:AZURE_SIGNING_ENDPOINT
CodeSigningAccountName = $env:AZURE_SIGNING_ACCOUNT
CertificateProfileName = $env:AZURE_CERT_PROFILE
} | ConvertTo-Json | Set-Content -Path $metadata -Encoding utf8
$ts = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.acs.microsoft.com' }
Invoke-SignTool ($common + @('/tr', $ts, '/dlib', $dlib.FullName, '/dmdf', $metadata) + $Files)
}
Invoke-SignTool (@('verify', '/pa', '/v') + $Files)
}
finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
+107
View File
@@ -0,0 +1,107 @@
//! `cargo xtask ico <out.ico> <in.png>…`: pack PNG images into a Windows `.ico`.
//!
//! Vista and later read PNG-compressed icon entries, so each PNG is stored as is. The size comes
//! from the PNG's IHDR chunk; 256 px is written as 0, per the ICO format.
use std::path::Path;
/// Width and height from a PNG's IHDR chunk.
pub fn png_size(png: &[u8]) -> Result<(u32, u32), String> {
const SIG: &[u8] = b"\x89PNG\r\n\x1a\n";
if png.len() < 24 || &png[..8] != SIG || &png[12..16] != b"IHDR" {
return Err("not a PNG file".into());
}
let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]);
let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]);
Ok((w, h))
}
/// An ICO file holding `pngs`, which must be square and at most 256 px.
pub fn pack(pngs: &[Vec<u8>]) -> Result<Vec<u8>, String> {
if pngs.is_empty() || pngs.len() > u16::MAX as usize {
return Err("need at least one PNG".into());
}
let mut out = Vec::new();
out.extend_from_slice(&0u16.to_le_bytes()); // reserved
out.extend_from_slice(&1u16.to_le_bytes()); // type: icon
out.extend_from_slice(&(pngs.len() as u16).to_le_bytes());
let mut offset = 6 + 16 * pngs.len();
for png in pngs {
let (w, h) = png_size(png)?;
if w != h || w == 0 || w > 256 {
return Err(format!("icon images must be square and at most 256 px, got {w}x{h}"));
}
let dim = if w == 256 { 0 } else { w as u8 };
out.extend_from_slice(&[dim, dim, 0, 0]); // width, height, palette size, reserved
out.extend_from_slice(&1u16.to_le_bytes()); // colour planes
out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
out.extend_from_slice(&(png.len() as u32).to_le_bytes());
out.extend_from_slice(&(offset as u32).to_le_bytes());
offset += png.len();
}
for png in pngs {
out.extend_from_slice(png);
}
Ok(out)
}
pub fn run(args: &[&str]) -> Result<(), String> {
let [out, inputs @ ..] = args else {
return Err("usage: cargo xtask ico <out.ico> <in.png>…".into());
};
let pngs = inputs
.iter()
.map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}")))
.collect::<Result<Vec<_>, _>>()?;
let ico = pack(&pngs)?;
std::fs::write(Path::new(out), &ico).map_err(|e| format!("write {out}: {e}"))?;
println!("{out}: {} images, {} bytes", pngs.len(), ico.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_png(size: u32) -> Vec<u8> {
let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
v.extend_from_slice(&size.to_be_bytes());
v.extend_from_slice(&size.to_be_bytes());
v.extend_from_slice(&[8, 6, 0, 0, 0, 1, 2, 3, 4]);
v
}
#[test]
fn packs_directory_and_payloads() {
let a = fake_png(16);
let b = fake_png(256);
let ico = pack(&[a.clone(), b.clone()]).unwrap();
assert_eq!(&ico[..6], &[0, 0, 1, 0, 2, 0]);
// Entry 0: 16 px at offset 6 + 32.
assert_eq!(&ico[6..8], &[16, 16]);
assert_eq!(u32::from_le_bytes(ico[14..18].try_into().unwrap()), a.len() as u32);
assert_eq!(u32::from_le_bytes(ico[18..22].try_into().unwrap()), 38);
// Entry 1: 256 px stored as 0, right after the first payload.
assert_eq!(&ico[22..24], &[0, 0]);
assert_eq!(u32::from_le_bytes(ico[34..38].try_into().unwrap()), 38 + a.len() as u32);
assert_eq!(&ico[38..38 + a.len()], &a[..]);
assert_eq!(&ico[38 + a.len()..], &b[..]);
}
#[test]
fn rejects_bad_input() {
assert!(pack(&[]).is_err());
assert!(pack(&[fake_png(512)]).is_err());
assert!(pack(&[b"GIF89a".to_vec()]).is_err());
assert_eq!(png_size(&fake_png(48)).unwrap(), (48, 48));
}
#[test]
fn committed_icon_is_valid() {
let path = crate::root().join("assets/app-icon/photocraft.ico");
let Ok(ico) = std::fs::read(&path) else { return };
assert_eq!(&ico[..4], &[0, 0, 1, 0]);
let n = u16::from_le_bytes([ico[4], ico[5]]) as usize;
assert!(n >= 4, "expected several sizes in {}", path.display());
}
}
+8
View File
@@ -3,8 +3,10 @@
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
//! invoked through `std::process::Command`.
mod ico;
mod layers;
mod stats;
mod version;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -20,6 +22,10 @@ commands:
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity Photoshop menu parity; rewrites docs/parity.md
version [set X.Y.Z[-pre]]
print the workspace version, or set it (Cargo.toml + Cargo.lock)
ico <out.ico> <in.png>...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
";
fn main() -> ExitCode {
@@ -32,6 +38,8 @@ fn main() -> ExitCode {
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("parity") => cmd_parity(),
Some("version") => version::run(&root(), &rest),
Some("ico") => ico::run(&rest),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
+164
View File
@@ -0,0 +1,164 @@
//! `cargo xtask version [set X.Y.Z[-pre]]`: the workspace version, the single source of truth.
//!
//! The version lives in `[workspace.package] version` in the root `Cargo.toml`; every crate
//! inherits it with `version.workspace = true`, and the packaging scripts read it from here.
use std::path::Path;
/// The `version = "…"` value inside `[workspace.package]`.
pub fn read(manifest: &str) -> Result<String, String> {
let (_, line) = find_line(manifest)?;
let v = line
.split_once('=')
.map(|(_, v)| v.trim().trim_matches('"').to_string())
.unwrap_or_default();
if v.is_empty() {
return Err("empty [workspace.package] version".into());
}
Ok(v)
}
/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged.
pub fn replace(manifest: &str, new: &str) -> Result<String, String> {
validate(new)?;
let (idx, _) = find_line(manifest)?;
let mut out = String::with_capacity(manifest.len() + 8);
for (i, line) in manifest.split_inclusive('\n').enumerate() {
if i == idx {
let eol = if line.ends_with("\r\n") {
"\r\n"
} else if line.ends_with('\n') {
"\n"
} else {
""
};
out.push_str(&format!("version = \"{new}\"{eol}"));
} else {
out.push_str(line);
}
}
Ok(out)
}
/// Semver without build metadata: `MAJOR.MINOR.PATCH` plus an optional `-pre.release` tag.
pub fn validate(v: &str) -> Result<(), String> {
let bad = || Err(format!("`{v}` is not a version like 1.2.3 or 1.2.3-rc.1"));
let (core, pre) = match v.split_once('-') {
Some((c, p)) => (c, Some(p)),
None => (v, None),
};
let nums: Vec<&str> = core.split('.').collect();
if nums.len() != 3
|| nums.iter().any(|n| {
n.is_empty()
|| !n.bytes().all(|b| b.is_ascii_digit())
|| (n.len() > 1 && n.starts_with('0'))
})
{
return bad();
}
if let Some(p) = pre
&& (p.is_empty()
|| p.split('.').any(|id| {
id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}))
{
return bad();
}
Ok(())
}
/// Index and text of the `version` line in `[workspace.package]`.
fn find_line(manifest: &str) -> Result<(usize, &str), String> {
let mut in_section = false;
for (i, line) in manifest.lines().enumerate() {
let t = line.trim();
if t.starts_with('[') {
in_section = t == "[workspace.package]";
continue;
}
if in_section
&& let Some(rest) = t.strip_prefix("version")
&& rest.trim_start().starts_with('=')
{
return Ok((i, line));
}
}
Err("no `version = \"…\"` in [workspace.package] of the root Cargo.toml".into())
}
pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
let path = root.join("Cargo.toml");
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
match args {
[] => {
println!("{}", read(&text)?);
Ok(())
}
["set", new] => {
let old = read(&text)?;
let updated = replace(&text, new)?;
// Write atomically: a half-written root manifest breaks every build in the workspace.
let tmp = root.join("target").join("Cargo.toml.xtask-version");
std::fs::create_dir_all(root.join("target")).map_err(|e| format!("create target/: {e}"))?;
std::fs::write(&tmp, updated).map_err(|e| format!("write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("replace {}: {e}", path.display()))?;
// Refresh the workspace members' entries in Cargo.lock (no dependency upgrades).
let mut c = crate::cargo();
c.args(["update", "--workspace", "--offline"]);
if crate::run(c, "cargo update --workspace --offline").is_err() {
let mut c = crate::cargo();
c.args(["update", "--workspace"]);
crate::run(c, "cargo update --workspace")?;
}
println!("version: {old} -> {new}");
Ok(())
}
_ => Err("usage: cargo xtask version [set X.Y.Z[-pre]]".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
const MANIFEST: &str = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[workspace.dependencies]\nfoo = { version = \"1\" }\n";
#[test]
fn reads_the_workspace_version() {
assert_eq!(read(MANIFEST).unwrap(), "0.1.0");
assert!(read("[package]\nversion = \"1.0.0\"\n").is_err());
}
#[test]
fn replaces_only_the_workspace_version() {
let out = replace(MANIFEST, "1.2.3-rc.1").unwrap();
assert_eq!(read(&out).unwrap(), "1.2.3-rc.1");
assert_eq!(out, MANIFEST.replace("version = \"0.1.0\"", "version = \"1.2.3-rc.1\""));
assert!(out.contains("foo = { version = \"1\" }"));
}
#[test]
fn keeps_crlf_line_endings() {
let crlf = MANIFEST.replace('\n', "\r\n");
let out = replace(&crlf, "2.0.0").unwrap();
assert_eq!(out, crlf.replace("\"0.1.0\"", "\"2.0.0\""));
}
#[test]
fn validates_versions() {
for ok in ["0.1.0", "1.20.300", "1.0.0-rc.1", "1.0.0-alpha", "1.0.0-x-y.2"] {
assert!(validate(ok).is_ok(), "{ok}");
}
for bad in ["1.0", "1.0.0.0", "v1.0.0", "01.0.0", "1.0.0-", "1.0.0-a..b", "1.0.0+meta", "1.a.0", ""] {
assert!(validate(bad).is_err(), "{bad}");
}
assert!(replace(MANIFEST, "nope").is_err());
}
#[test]
fn the_real_manifest_has_a_valid_version() {
let text = std::fs::read_to_string(crate::root().join("Cargo.toml")).unwrap();
validate(&read(&text).unwrap()).unwrap();
}
}