progress, release workflows
No files matched your search
@@ -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'"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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"
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 671 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
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 |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 36 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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.*`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
|
||||
source-path=SCRIPTDIR
|
||||
external-sources=true
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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=""%1"" />
|
||||
</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=""[#PhotocraftExe]" "%1"" Type="string" />
|
||||
<ProgId Id="Photocraft.Image" Description="Image" Icon="PhotocraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Photocraft.Image\shell\open\command" Value=""[#PhotocraftExe]" "%1"" 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=""[#PhotocraftExe]" "%1"" 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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||