Web build and release CI: slidecraft-web, packaging, release workflow

- apps/slidecraft-web: SlideApp in the browser via eframe's web runner (trunk; WebGPU with
  WebGL2 fallback). Opens the sample deck (?blank for empty, ?show to present, ?webgl to force
  WebGL2). Open/Insert use the browser file picker through Services::open_async + inbox;
  Save/Export download; dropped files are read asynchronously.
- ui-egui: native-only synchronous drop handling (wasm uses the inbox), real clock on wasm
  (js_sys::Date::now), About shows build commit/date when SLIDECRAFT_BUILD_SHA/DATE are set.
- .github/workflows/release.yml (push to `release` / workflow_dispatch): macOS universal
  signed+notarized dmg, Windows x64/x86 signed msi + portable zip, Linux AppImage/deb/rpm/tar.gz
  (x86_64, aarch64), Flatpak bundle, FreeBSD tar.gz (vmactions), web zip; draft GitHub Release
  "SlideCraft v<version>" from [workspace.package] version. Same secret names as DesignCraft.
- freebsd.yml and packaging-lint.yml CI, ported from DesignCraft.
- packaging/: macOS (Info.plist, entitlements, cert import), Windows (WiX with new UpgradeCode,
  .slidecraft ProgId, Open-with for pptx), Linux (.desktop, AppStream, MIME, nfpm, Flatpak),
  FreeBSD and web scripts; shared setup in packaging/common.sh.
- apps/slidecraft/build.rs embeds icon + VERSIONINFO on Windows; slidecraft.icns/.ico generated
  from the committed PNGs by packaging/icons.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 00:45:34 -04:00
parent e273d60f22
commit 0b394ad433
41 changed files with 2185 additions and 7 deletions

56
.github/workflows/freebsd.yml vendored Normal file
View File

@ -0,0 +1,56 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/fonts/**"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked

57
.github/workflows/packaging-lint.yml vendored Normal file
View File

@ -0,0 +1,57 @@
# 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/slidecraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/slidecraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.slidecraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.slidecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.slidecraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.slidecraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.slidecraft.yml')); assert m['id'] == 'ai.storyteller.slidecraft' and m['command'] == 'slidecraft'"

414
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,414 @@
# Release pipeline: every push to `release` builds installers for every platform and creates or
# updates the draft GitHub Release "SlideCraft v<version>" (tag v<version>). Recipe:
# craftrules release/playbook.md.
#
# The version has one source: `[workspace.package] version` in Cargo.toml (`cargo xtask version`,
# `cargo xtask version set X.Y.Z`). The app shows it in About and `--version`; the release name
# and tag use it too. workflow_dispatch can override it for a test build (applied to the build).
#
# 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 }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
SLIDECRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
# Optional font input (storytold/craft-fonts docs/integration.md); releases always embed it.
# This commit adds fonts/latin-manifest.txt (the Latin presentation fonts).
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
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/common.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 "### SlideCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15
environment: release
env:
SLIDECRAFT_VERSION: ${{ needs.version.outputs.version }}
SLIDECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- 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 "$SLIDECRAFT_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:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
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:
SLIDECRAFT_VERSION: ${{ needs.version.outputs.version }}
SLIDECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- 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 "$SLIDECRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
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).
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:
SLIDECRAFT_VERSION: ${{ needs.version.outputs.version }}
SLIDECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- 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 "$SLIDECRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
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/slidecraft$|metainfo|applications'
ldd target/release/slidecraft
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
flatpak:
name: Linux Flatpak
needs: version
runs-on: ubuntu-latest
environment: release
# Best effort: Flathub builds from source itself (playbook §7); the bundle is a convenience.
continue-on-error: true
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
env:
SLIDECRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Apply version override
if: inputs.version != ''
run: |
# No cargo in this container: the same edit `cargo xtask version set` makes.
sed -i "0,/^version = \".*\"/s//version = \"$SLIDECRAFT_VERSION\"/" Cargo.toml
grep -m1 '^version' Cargo.toml
- name: Vendor the crate sources (flatpak builds run offline)
run: |
dnf install -y python3-pip
pip3 install --quiet aiohttp tomlkit
curl -fsSL -o "$RUNNER_TEMP/flatpak-cargo-generator.py" \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
python3 "$RUNNER_TEMP/flatpak-cargo-generator.py" Cargo.lock -o packaging/linux/flatpak/cargo-sources.json
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
manifest-path: packaging/linux/flatpak/ai.storyteller.slidecraft.yml
bundle: slidecraft-${{ needs.version.outputs.version }}-linux-x86_64.flatpak
upload-artifact: false
cache-key: flatpak-builder-${{ github.sha }}
- uses: actions/upload-artifact@v4
with:
name: release-flatpak
path: slidecraft-*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
runs-on: ubuntu-latest
environment: release
timeout-minutes: 240
env:
SLIDECRAFT_VERSION: ${{ needs.version.outputs.version }}
SLIDECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
VERSION_OVERRIDE: ${{ inputs.version }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
envs: SLIDECRAFT_VERSION SLIDECRAFT_BUILD_DATE SLIDECRAFT_BUILD_SHA VERSION_OVERRIDE
# Copies the workspace (with dist/release) back; the target dir lives outside it so the
# copy stays small.
copyback: true
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "*"
export CARGO_TARGET_DIR="$HOME/target" CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$SLIDECRAFT_VERSION"; fi
bash packaging/freebsd/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
env:
SLIDECRAFT_VERSION: ${{ needs.version.outputs.version }}
SLIDECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- 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 "$SLIDECRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
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, flatpak, freebsd, web]
# The installers are required; the Flatpak bundle and FreeBSD tarball are attached when they
# built.
if: >-
!cancelled() && needs.version.result == 'success' && needs.macos.result == 'success' &&
needs.windows.result == 'success' && needs.linux.result == 'success' && needs.web.result == 'success'
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="SlideCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"

3
.gitignore vendored
View File

@ -9,3 +9,6 @@ plan/
/.claude/
/craft-fonts/
packaging/env.sh
/packaging/linux/flatpak/cargo-sources.json
/build-flatpak/
/.flatpak-builder/

View File

@ -18,6 +18,7 @@ deck and UI icons are original work of the SlideCraft contributors (drawn or gen
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
| `assets/app-icon/slidecraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.slidecraft.png` | SlideCraft contributors | rendered by SlideCraft from `assets/app-icon/icon-source.slidecraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
| `assets/app-icon/slidecraft.icns`, `assets/app-icon/slidecraft.ico` | SlideCraft contributors | generated from the PNGs above by `packaging/icons.sh` (sips/iconutil, `cargo xtask ico`) | MIT OR Apache-2.0 | macOS and Windows app icons |
| `assets/app-icon/icon-source.slidecraft` | SlideCraft contributors | made with `slidecraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
## Bundled through dependencies

40
Cargo.lock generated
View File

@ -3129,6 +3129,7 @@ dependencies = [
"slidecraft-engine",
"slidecraft-ui-egui",
"winit",
"winresource",
]
[[package]]
@ -3247,12 +3248,9 @@ dependencies = [
name = "slidecraft-pptx"
version = "0.1.0"
dependencies = [
"image",
"log",
"quick-xml 0.38.4",
"serde_json",
"slidecraft-color",
"slidecraft-engine",
"slidecraft-geom",
"slidecraft-model",
"thiserror 2.0.21",
@ -3293,6 +3291,7 @@ version = "0.1.0"
dependencies = [
"egui",
"egui_kittest",
"js-sys",
"log",
"serde",
"serde_json",
@ -3306,6 +3305,23 @@ dependencies = [
"slidecraft-text",
]
[[package]]
name = "slidecraft-web"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"rfd",
"serde_json",
"slidecraft-engine",
"slidecraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "slotmap"
version = "1.1.1"
@ -3579,10 +3595,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",
]
@ -3616,6 +3634,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"
@ -4577,6 +4601,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"

View File

@ -31,8 +31,13 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
palette, light/dark.
- Automation: ~200 commands, every one reachable from `slidecraft-cli`, the app's JSON control
channel and the MCP server (headless or connected to the running app).
- Web: `apps/slidecraft-web` runs the same UI in the browser (trunk; WebGPU with WebGL2 fallback),
opening the sample deck; Open/Insert use the browser file picker, Save/Export download.
- Release CI: pushes to `release` build a draft GitHub Release (macOS universal dmg, Windows
x64/x86 msi + zip, Linux AppImage/deb/rpm/tar.gz + Flatpak, FreeBSD tar.gz, web zip); signing
secrets live in the `release` environment. Version: `cargo xtask version`.
**In progress:** PPTX import/export · web (WASM) build · release packaging for all platforms.
**In progress:** PPTX import/export · first signed release run.
**Next (in order):** vector PDF artwork · audio/video playback · Format Shape pane depth · connectors with
glue, freeform and edit points · merge shapes · Animation Pane polish · native macOS menu bar ·

View File

@ -0,0 +1,30 @@
[package]
name = "slidecraft-web"
description = "SlideCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "slidecraft-web"
path = "src/main.rs"
[dependencies]
slidecraft-engine = { workspace = true }
slidecraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }

View File

@ -0,0 +1,10 @@
# Build with: trunk build --release (from apps/slidecraft-web)
[build]
target = "index.html"
dist = "../../dist/web"
public_url = "./"
filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8768

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>SlideCraft</title>
<link data-trunk rel="rust" data-bin="slidecraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.slidecraft.png" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#slidecraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#slidecraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
</head>
<body>
<canvas id="slidecraft_canvas"></canvas>
<div id="slidecraft_loading">Loading SlideCraft&hellip;</div>
</body>
</html>

View File

@ -0,0 +1,31 @@
//! SlideCraft in the browser.
//!
//! Runs the same [`slidecraft_ui_egui::SlideApp`] as the desktop app through eframe's web
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
//! from this directory (output in `dist/web`).
//!
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
//! asynchronously through `Services::inbox` (presentations open, media is inserted);
//! - Save and Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - preferences are not persisted.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
//! empty presentation instead of the sample deck; `?show` starts the slide show.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
fn main() {
web::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("slidecraft-web only runs in the browser: build it with `trunk build --release` in apps/slidecraft-web");
}

View File

@ -0,0 +1,176 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use slidecraft_engine::Session;
use slidecraft_ui_egui::{Inbox, Services, SlideApp};
use wasm_bindgen::JsCast as _;
const PRESENTATION_EXTS: &[&str] = &["slidecraft", "pptx", "potx", "ppsx", "txt", "md"];
const PICTURE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"];
const AUDIO_EXTS: &[&str] = &["wav", "mp3", "m4a", "aac", "flac", "ogg", "opus", "aif", "aiff"];
const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"];
const CANVAS_ID: &str = "slidecraft_canvas";
const LOADING_ID: &str = "slidecraft_loading";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
return;
};
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
.start(
canvas,
options,
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("slidecraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
}
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
if !q.contains("blank") {
if let Err(e) = slidecraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample deck: {e}");
}
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
log::error!("new presentation: {e}");
}
if q.contains("show") {
app.start_show(0, false);
}
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!("<p>SlideCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
}
}
});
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
app: SlideApp,
inbox: Inbox,
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
self.app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
}
}
/// Web services: no file paths, so `pick_open`/`pick_save`/`read` stay `None` and the UI falls
/// back to `open_async` (browser picker → inbox) and `download`.
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
Services {
open_async: Some(Box::new(move |purpose: &str| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = match purpose {
"picture" => rfd::AsyncFileDialog::new().add_filter("Pictures", PICTURE_EXTS),
"audio" => rfd::AsyncFileDialog::new().add_filter("Audio", AUDIO_EXTS),
"video" => rfd::AsyncFileDialog::new().add_filter("Video", VIDEO_EXTS),
_ => rfd::AsyncFileDialog::new().add_filter("Presentations", PRESENTATION_EXTS),
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
})),
write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))),
download: Some(Box::new(|name: &str, bytes: &[u8]| {
if let Err(e) = download(name, bytes) {
log::error!("download of {name} failed: {e}");
}
})),
inbox: Some(inbox),
..Default::default()
}
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "slidecraft".into());
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(&name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js)?;
let body = document.body().ok_or("no body")?;
body.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click has been dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok();
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("txt") => "text/plain",
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
Some("slidecraft") => "application/zip",
_ => "application/octet-stream",
}
}

View File

@ -18,6 +18,10 @@ egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for slidecraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }

30
apps/slidecraft/build.rs Normal file
View File

@ -0,0 +1,30 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `slidecraft.exe`, so it
//! shows in Explorer, the taskbar, the Start menu and Alt-Tab.
//!
//! 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 `SLIDECRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/slidecraft.ico");
println!("cargo:rerun-if-env-changed=SLIDECRAFT_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/slidecraft.ico")
.set("ProductName", "SlideCraft")
.set("FileDescription", "SlideCraft presentations")
.set("CompanyName", "Learning Machines LLC")
.set("LegalCopyright", "Copyright (c) the SlideCraft authors. MIT OR Apache-2.0.")
.set("OriginalFilename", "slidecraft.exe")
.set("InternalName", "slidecraft");
if let Err(e) = res.compile() {
if std::env::var_os("SLIDECRAFT_REQUIRE_WINRES").is_some() {
eprintln!("embedding Windows resources failed: {e}");
std::process::exit(1);
}
println!("cargo:warning=slidecraft.exe built without icon/version resources: {e}");
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -22,5 +22,8 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }

View File

@ -738,7 +738,7 @@ pub fn about(_app: &mut SlideApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover());
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("SlideCraft").font(theme::bold(22.0)));
ui.label(egui::RichText::new(format!("Version {}", env!("CARGO_PKG_VERSION"))).color(t.text_dim));
ui.label(egui::RichText::new(version_line()).color(t.text_dim));
ui.add_space(6.0);
ui.label("Presentations and slide shows, rebuilt from scratch in pure Rust.");
ui.label("An open-source, clean-room project. Not affiliated with Microsoft.");
@ -751,6 +751,21 @@ pub fn about(_app: &mut SlideApp, ui: &mut Ui) {
});
}
/// "Version X.Y.Z" plus, for release builds, the short commit and build date. The version comes
/// from `[workspace.package] version` (the single source of truth, `cargo xtask version`); the
/// release workflow sets `SLIDECRAFT_BUILD_SHA` / `SLIDECRAFT_BUILD_DATE` at build time.
pub fn version_line() -> String {
let mut s = format!("Version {}", env!("CARGO_PKG_VERSION"));
let sha = option_env!("SLIDECRAFT_BUILD_SHA").map(|s| s.get(..9).unwrap_or(s)).filter(|s| !s.is_empty());
let date = option_env!("SLIDECRAFT_BUILD_DATE").filter(|s| !s.is_empty());
match (sha, date) {
(Some(sha), Some(date)) => s.push_str(&format!(" ({sha}, {date})")),
(Some(x), None) | (None, Some(x)) => s.push_str(&format!(" ({x})")),
(None, None) => {}
}
s
}
/// SlideCraft's mark, drawn in code: a slide card with a play triangle in our orange.
pub fn paint_logo(p: &egui::Painter, r: Rect) {
let orange = Color32::from_rgb(0xF2, 0x6B, 0x1D);

View File

@ -498,6 +498,9 @@ impl SlideApp {
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
// On the web, dropped files can only be read asynchronously: the host reads them and
// delivers them through `Services::inbox`.
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into());
match f.bytes() {
@ -749,6 +752,5 @@ pub fn now_ms() -> f64 {
#[cfg(target_arch = "wasm32")]
fn js_now() -> f64 {
// egui's input time is good enough for UI timing on the web.
0.0
js_sys::Date::now()
}

3
packaging/.shellcheckrc Normal file
View File

@ -0,0 +1,3 @@
# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
source-path=SCRIPTDIR
external-sources=true

70
packaging/common.sh Normal file
View File

@ -0,0 +1,70 @@
# shellcheck shell=bash
# Shared setup for the packaging scripts. Source it: `. "$(dirname "$0")/../common.sh"`.
# (Not env.sh: .gitignore reserves packaging/env.sh for local, uncommitted settings.)
#
# Exports:
# ROOT workspace root
# VERSION [workspace.package] version from Cargo.toml (override: SLIDECRAFT_VERSION)
# DIST output directory for release artifacts (default: $ROOT/dist/release)
# SLIDECRAFT_BUILD_SHA git commit shown in the About dialog (crates/ui-egui/src/dialogs.rs version_line)
# SLIDECRAFT_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="${SLIDECRAFT_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 "${SLIDECRAFT_BUILD_SHA:-}" ]; then
SLIDECRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
fi
export SLIDECRAFT_BUILD_SHA
export SLIDECRAFT_BUILD_DATE="${SLIDECRAFT_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
copy_font_licences "$dest"
}
# Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so
# the package carries their licences as OFL-<family-dir>.txt. Nothing without CRAFT_FONTS_DIR.
copy_font_licences() {
local dest="$1" f
[ -n "${CRAFT_FONTS_DIR:-}" ] || return 0
for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
if [ -f "$f" ]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; 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
}

44
packaging/freebsd/package.sh Executable file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Build and package SlideCraft for FreeBSD:
#
# $DIST/slidecraft-<version>-freebsd-<arch>.tar.gz bin/, share/ (desktop entry, icons, AppStream)
#
# Usage: packaging/freebsd/package.sh [--skip-build]
#
# Run on FreeBSD (CI: a FreeBSD 14 VM via vmactions/freebsd-vm). Runtime needs: libxkbcommon,
# wayland or libX11, and mesa (Vulkan or EGL): `pkg install libxkbcommon libX11 mesa-libs vulkan-loader`.
set -euo pipefail
# shellcheck source=../common.sh
. "$(dirname "${BASH_SOURCE[0]}")/../common.sh"
LINUX="$ROOT/packaging/linux"
APP_ID=ai.storyteller.slidecraft
if [ "${1:-}" != "--skip-build" ]; then
(cd "$ROOT" && cargo build --release --locked -p slidecraft -p slidecraft-cli)
fi
ARCH="$(uname -m)"
case "$ARCH" in
amd64) ARCH=x86_64 ;;
arm64) ARCH=aarch64 ;;
esac
BIN="$CARGO_TARGET_DIR/release"
NAME="slidecraft-$VERSION-freebsd-$ARCH"
WORK="$CARGO_TARGET_DIR/freebsd-package"
STAGE="$WORK/$NAME"
rm -rf "$WORK"
mkdir -p "$STAGE/bin" "$STAGE/share/applications" "$STAGE/share/metainfo" "$STAGE/share/mime/packages" "$STAGE/share/icons" "$STAGE/share/doc/slidecraft"
install -m 755 "$BIN/slidecraft" "$BIN/slidecraft-cli" "$STAGE/bin/"
strip "$STAGE/bin/slidecraft" "$STAGE/bin/slidecraft-cli" 2>/dev/null || true
install -m 644 "$LINUX/$APP_ID.desktop" "$STAGE/share/applications/"
install -m 644 "$LINUX/$APP_ID.mime.xml" "$STAGE/share/mime/packages/$APP_ID.xml"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$SLIDECRAFT_BUILD_DATE/g" \
"$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml"
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/"
copy_docs "$STAGE/share/doc/slidecraft"
tar -C "$WORK" -czf "$DIST/$NAME.tar.gz" "$NAME"
"$STAGE/bin/slidecraft-cli" --version
echo "wrote $DIST/$NAME.tar.gz"
ls -lh "$DIST/$NAME.tar.gz"

48
packaging/icons.sh Executable file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Regenerate the platform app icons from the committed PNGs in assets/app-icon/:
#
# assets/app-icon/slidecraft.ico Windows (16–256 px, from hicolor/*)
# assets/app-icon/slidecraft.icns macOS (16–1024 px, from slidecraft-1024.png; needs sips + iconutil)
#
# The PNGs themselves are rendered by SlideCraft from assets/app-icon/icon-source.slidecraft.
# The outputs are committed, so builds and packaging never need these tools; package scripts
# call this only when an output is missing.
#
# packaging/icons.sh [ico] [icns] (default: both)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIR="$ROOT/assets/app-icon"
ID="ai.storyteller.slidecraft"
SRC="$DIR/slidecraft-1024.png"
WANT="${*:-ico icns}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
case " $WANT " in
*" ico "*)
ICO_PNGS=()
for s in 16 24 32 48 64 128 256; do
ICO_PNGS+=("$DIR/hicolor/${s}x${s}/apps/$ID.png")
done
(cd "$ROOT" && cargo run -q -p xtask -- ico "$DIR/slidecraft.ico" "${ICO_PNGS[@]}")
echo "wrote $DIR/slidecraft.ico"
;;
esac
case " $WANT " in
*" icns "*)
if command -v iconutil >/dev/null && command -v sips >/dev/null; then
SET="$TMP/slidecraft.iconset"
mkdir -p "$SET"
for s in 16 32 128 256 512; do
sips -z "$s" "$s" "$SRC" --out "$SET/icon_${s}x${s}.png" >/dev/null
d=$((s * 2))
sips -z "$d" "$d" "$SRC" --out "$SET/icon_${s}x${s}@2x.png" >/dev/null
done
iconutil -c icns -o "$DIR/slidecraft.icns" "$SET"
echo "wrote $DIR/slidecraft.icns"
else
echo "warning: sips/iconutil not found (macOS only); slidecraft.icns not regenerated" >&2
fi
;;
esac

View File

@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Name=SlideCraft
GenericName=Presentation
Comment=Create and present slide decks
Exec=slidecraft %F
TryExec=slidecraft
Icon=ai.storyteller.slidecraft
Terminal=false
StartupNotify=true
StartupWMClass=ai.storyteller.slidecraft
Categories=Office;Presentation;
MimeType=application/x-slidecraft;application/vnd.openxmlformats-officedocument.presentationml.presentation;application/vnd.openxmlformats-officedocument.presentationml.template;application/vnd.openxmlformats-officedocument.presentationml.slideshow;
Keywords=presentation;slides;slide show;deck;pptx;keynote;powerpoint;

View File

@ -0,0 +1,59 @@
<?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.slidecraft</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT OR Apache-2.0</project_license>
<name>SlideCraft</name>
<summary>Create and present slide decks</summary>
<developer id="ai.storyteller">
<name>Learning Machines LLC</name>
</developer>
<description>
<p>
SlideCraft builds presentations and slide shows: themes, layouts, shapes, pictures,
tables, charts, transitions and animations. It opens and saves PowerPoint (.pptx) files
and presents them full screen with a presenter view.
</p>
<p>
Every action is also a command that the command line and AI agents (through MCP) can drive.
</p>
</description>
<launchable type="desktop-id">ai.storyteller.slidecraft.desktop</launchable>
<icon type="stock">ai.storyteller.slidecraft</icon>
<!-- Only url types known to appstream 0.15 (Ubuntu 22.04, where CI validates): no vcs-browser. -->
<url type="homepage">https://getartcraft.com/apps/slidecraft</url>
<url type="bugtracker">https://github.com/storytold/slidecraft/issues</url>
<url type="help">https://github.com/storytold/slidecraft</url>
<url type="contact">https://discord.gg/artcraft</url>
<categories>
<category>Office</category>
<category>Presentation</category>
</categories>
<keywords>
<keyword>presentation</keyword>
<keyword>slides</keyword>
<keyword>slide show</keyword>
<keyword>pptx</keyword>
</keywords>
<branding>
<color type="primary" scheme_preference="light">#ffc9a8</color>
<color type="primary" scheme_preference="dark">#b8471f</color>
</branding>
<requires>
<display_length compare="ge">760</display_length>
</requires>
<supports>
<control>keyboard</control>
<control>pointing</control>
</supports>
<provides>
<binary>slidecraft</binary>
<mediatype>application/x-slidecraft</mediatype>
</provides>
<content_rating type="oars-1.1"/>
<releases>
<release version="@VERSION@" date="@DATE@"/>
</releases>
</component>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-slidecraft">
<comment>SlideCraft presentation</comment>
<sub-class-of type="application/zip"/>
<glob pattern="*.slidecraft"/>
<icon name="ai.storyteller.slidecraft"/>
</mime-type>
</mime-info>

View File

@ -0,0 +1,56 @@
# Flatpak manifest for SlideCraft, 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.slidecraft.yml
# flatpak run ai.storyteller.slidecraft
#
# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
id: ai.storyteller.slidecraft
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: slidecraft
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. Documents is granted for opening decks by
# path from the command line.
- --filesystem=xdg-documents
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules:
- name: slidecraft
buildsystem: simple
build-options:
env:
CARGO_HOME: /run/build/slidecraft/cargo
CARGO_NET_OFFLINE: 'true'
build-commands:
- cargo --offline build --release --locked -p slidecraft
- install -Dm755 target/release/slidecraft /app/bin/slidecraft
- install -Dm644 packaging/linux/ai.storyteller.slidecraft.desktop /app/share/applications/ai.storyteller.slidecraft.desktop
- install -Dm644 packaging/linux/ai.storyteller.slidecraft.mime.xml /app/share/mime/packages/ai.storyteller.slidecraft.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.slidecraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.slidecraft.metainfo.xml
sources:
- type: dir
path: ../../..
skip:
- target
- dist
- corpus
- build-flatpak
- .flatpak-builder
- cargo-sources.json

71
packaging/linux/nfpm.yaml Normal file
View File

@ -0,0 +1,71 @@
# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages
# the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and
# exports VERSION and NFPM_ARCH.
#
# nfpm package -f packaging/linux/nfpm.yaml -p deb -t dist/release/
name: slidecraft
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: editors
priority: optional
maintainer: ${SLIDECRAFT_MAINTAINER}
vendor: Learning Machines LLC
homepage: https://getartcraft.com/apps/slidecraft
license: MIT OR Apache-2.0
description: |-
Open-source, native presentation app that works the way PowerPoint users
expect. Themes, layouts, shapes, pictures, tables, charts, transitions and
animations; opens and saves PowerPoint (.pptx) files; full-screen slide show
with presenter view; GPU rendering (wgpu).
Includes slidecraft-cli, a headless renderer 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/slidecraft
dst: /usr/bin/slidecraft
file_info: { mode: 0755 }
- src: ${STAGE}/usr/bin/slidecraft-cli
dst: /usr/bin/slidecraft-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

120
packaging/linux/package.sh Executable file
View File

@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Build and package SlideCraft for Linux (<arch> is x86_64 or aarch64):
#
# $DIST/slidecraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/slidecraft-<version>-linux-<arch>.deb Debian, Ubuntu, Mint, Pop!_OS, ...
# $DIST/slidecraft-<version>-linux-<arch>.rpm Fedora, openSUSE, RHEL, ...
# $DIST/slidecraft-<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=../common.sh
. "$(dirname "${BASH_SOURCE[0]}")/../common.sh"
HERE="$ROOT/packaging/linux"
APP_ID=ai.storyteller.slidecraft
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 SLIDECRAFT_MAINTAINER="${SLIDECRAFT_MAINTAINER:-SlideCraft maintainers <slidecraft@storyteller.ai>}"
BASENAME="slidecraft-$VERSION-linux-$ARCH"
echo "==> SlideCraft $VERSION for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p slidecraft -p slidecraft-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/slidecraft" "$STAGE/usr/bin/slidecraft"
install -Dm755 "$BIN/slidecraft-cli" "$STAGE/usr/bin/slidecraft-cli"
strip "$STAGE/usr/bin/slidecraft" "$STAGE/usr/bin/slidecraft-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@/$SLIDECRAFT_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/slidecraft"
copy_docs "$STAGE/usr/share/doc/slidecraft"
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
mkdir -p "$WORK/tar"
cp -R "$STAGE/usr" "$WORK/tar/$BASENAME"
tar -C "$WORK/tar" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $DIST/$BASENAME.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 VERSION
export NFPM_ARCH="$DEB_ARCH"
# nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`.
sed "s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml"
for fmt in deb rpm; do
if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi
done
fi
# ---- AppImage -----------------------------------------------------------------------------------
if has appimage; then
APPDIR="$WORK/SlideCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
ln -s usr/bin/slidecraft "$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/$BASENAME.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/slidecraft-cli" --version
echo "==> done"
ls -lh "$DIST"

13
packaging/linux/postinst.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
# deb/rpm post-install and post-remove: refresh the desktop, MIME and icon caches.
# Every tool is optional; missing ones are skipped.
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v update-mime-database >/dev/null 2>&1; then
update-mime-database /usr/share/mime || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
exit 0

View File

@ -0,0 +1,95 @@
<?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>SlideCraft</string>
<key>CFBundleName</key>
<string>SlideCraft</string>
<key>CFBundleExecutable</key>
<string>SlideCraft</string>
<key>CFBundleIdentifier</key>
<string>ai.storyteller.slidecraft</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>SlideCraftVersion</key>
<string>@VERSION@</string>
<key>SlideCraftBuildCommit</key>
<string>@BUILD_SHA@</string>
<key>CFBundleIconFile</key>
<string>SlideCraft</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</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 SlideCraft authors. MIT OR Apache-2.0.</string>
<!-- SlideCraft's own format: a zip of a JSON manifest plus media. -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.slidecraft.presentation</string>
<key>UTTypeDescription</key>
<string>SlideCraft Presentation</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.presentation</string>
</array>
<key>UTTypeIconFile</key>
<string>SlideCraft</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>slidecraft</string></array>
<key>public.mime-type</key>
<array><string>application/x-slidecraft</string></array>
</dict>
</dict>
</array>
<!-- Open XML presentations; the system's own declaration wins when it has one. -->
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.openxmlformats.presentationml.presentation</string>
<key>UTTypeDescription</key>
<string>Open XML Presentation</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.presentation</string>
<string>public.zip-archive</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>pptx</string></array>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Hardened-runtime entitlements for SlideCraft. 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>

50
packaging/macos/import-cert.sh Executable file
View File

@ -0,0 +1,50 @@
#!/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
umask 077
# shellcheck source=../common.sh
. "$(dirname "${BASH_SOURCE[0]}")/../common.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/slidecraft-signing.keychain-db"
CERT="$TMP/slidecraft-signing.p12"
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
trap 'rm -f "$CERT"' EXIT
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:-}" -T /usr/bin/codesign -T /usr/bin/security -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

164
packaging/macos/package.sh Executable file
View File

@ -0,0 +1,164 @@
#!/usr/bin/env bash
# Build, sign and (optionally) notarize the macOS release artifacts:
#
# $DIST/slidecraft-<version>-macos-<arch>.dmg SlideCraft.app on a drag-to-Applications DMG
# $DIST/slidecraft-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=../common.sh
. "$(dirname "${BASH_SOURCE[0]}")/../common.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/SlideCraft.app"
DMG="$DIST/slidecraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/slidecraft-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 "==> SlideCraft $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 slidecraft -p slidecraft-cli "${args[@]}")
fi
rm -rf "$WORK"
mkdir -p "$WORK/bin"
for bin in slidecraft slidecraft-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
}
# ---- SlideCraft.app ----------------------------------------------------------------------------
echo "==> assembling $APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile).
cp "$WORK/bin/slidecraft" "$APP/Contents/MacOS/SlideCraft"
cp "$ROOT/assets/app-icon/slidecraft.icns" "$APP/Contents/Resources/SlideCraft.icns"
# Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR).
copy_font_licences "$APP/Contents/Resources"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
-e "s/@BUILD_SHA@/${SLIDECRAFT_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/SlideCraft"
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/SlideCraft-notarize.zip"
notarize "$WORK/SlideCraft-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/SlideCraft.app"
ln -s /Applications "$STAGE/Applications"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "SlideCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"
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/slidecraft-cli-$VERSION-macos-$ARCH"
mkdir -p "$CLI_DIR"
cp "$WORK/bin/slidecraft-cli" "$CLI_DIR/"
copy_docs "$CLI_DIR"
sign --options runtime "$CLI_DIR/slidecraft-cli"
codesign --verify --strict --verbose=2 "$CLI_DIR/slidecraft-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/slidecraft-cli" --version
echo "==> done"
ls -lh "$DMG" "$CLI_ZIP"

16
packaging/web/.htaccess Normal file
View File

@ -0,0 +1,16 @@
# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional.
AddType application/wasm .wasm
AddType text/javascript .js
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(wasm|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "^index\.html$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>

88
packaging/web/README.md Normal file
View File

@ -0,0 +1,88 @@
# Hosting SlideCraft for the web
`slidecraft-web-<version>.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a
static site in `slidecraft-web-<version>/`:
| File | What it is |
|---|---|
| `index.html` | The page. It loads everything through relative URLs. |
| `slidecraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
| `slidecraft-web-<hash>_bg.wasm` | The app, size is printed by `packaging/web/package.sh` |
| `_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/slidecraft-web/Trunk.toml`),
so the site works at a domain root (`https://example.com/`), under a prefix
(`https://example.com/tools/slidecraft/`) 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 shrinks the
download by roughly 60%. 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:** SlideCraft 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 /slidecraft/ {
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/slidecraft/"
title="SlideCraft presentations"
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. The web build keeps no preferences between visits; it starts
with the sample deck (or `?blank`) every time.
- **Sandboxed iframes** need at least
`sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"`. 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
SlideCraft 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) |
| `?blank` | Start with an empty presentation instead of the sample deck |
| `?show` | Start the slide show immediately (kiosk/embedded decks) |
For example: `<iframe src="https://example.com/slidecraft/?webgl" ...>`.
A browser with neither WebGPU nor WebGL2 gets a message in place of the app.

13
packaging/web/_headers Normal file
View File

@ -0,0 +1,13 @@
# Netlify / Cloudflare Pages header rules (see HOSTING.md). Other hosts: set the same headers.
/*
X-Content-Type-Options: nosniff
/*.wasm
Content-Type: application/wasm
Cache-Control: public, max-age=31536000, immutable
/*.js
Cache-Control: public, max-age=31536000, immutable
/index.html
Cache-Control: no-cache

39
packaging/web/package.sh Executable file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Build the browser version and zip it: $DIST/slidecraft-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 slidecraft-web-<version>/ that works
# from any URL path and inside an <iframe>. Hosting notes: packaging/web/README.md.
set -euo pipefail
# shellcheck source=../common.sh
. "$(dirname "${BASH_SOURCE[0]}")/../common.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/slidecraft-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="slidecraft-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"

View File

@ -0,0 +1,131 @@
<#
.SYNOPSIS
Build, sign and package SlideCraft for Windows.
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
slidecraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
slidecraft-<version>-windows-<arch>-portable.zip slidecraft.exe + slidecraft-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
pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools
#>
param(
[ValidateSet('x64', 'x86', 'arm64')] [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:SLIDECRAFT_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 = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-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:SLIDECRAFT_BUILD_SHA) { $env:SLIDECRAFT_BUILD_SHA = (git -C $Root rev-parse HEAD 2>$null) }
if (-not $env:SLIDECRAFT_BUILD_DATE) { $env:SLIDECRAFT_BUILD_DATE = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') }
Write-Output "SlideCraft $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:SLIDECRAFT_REQUIRE_WINRES = '1'
Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p slidecraft -p slidecraft-cli --target $Target }
}
$Bin = Join-Path $TargetDir "$Target\release"
# Check both binaries' PE headers before packaging. Machine (COFF header) must match -Arch, so an
# x64 build can never ship labelled arm64. Subsystem (optional header): 2 = Windows GUI, 3 = console.
# The app must be GUI (no console window opens with it); the CLI must stay console so its output
# reaches the terminal.
function Get-PeHeader([string] $Path) {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
}
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
foreach ($check in @(@('slidecraft.exe', 2), @('slidecraft-cli.exe', 3))) {
$h = Get-PeHeader (Join-Path $Bin $check[0])
if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
}
$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 'slidecraft.exe'), (Join-Path $Bin 'slidecraft-cli.exe') $Stage
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'slidecraft.exe') (Join-Path $Stage 'slidecraft-cli.exe')
# ---- MSI ---------------------------------------------------------------------------------------
$Msi = Join-Path $Dist "slidecraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'slidecraft.wxs') -arch $Arch `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\slidecraft.ico')" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
# ---- portable zip ------------------------------------------------------------------------------
$Portable = Join-Path $TargetDir "windows-package\slidecraft-$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 }
}
# Builds made with craft-fonts (CRAFT_FONTS_DIR) embed its fonts: ship their licences.
if ($env:CRAFT_FONTS_DIR) {
foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -ErrorAction SilentlyContinue) {
Copy-Item $ofl.FullName (Join-Path $Portable "OFL-$($ofl.Directory.Name).txt")
}
}
$Zip = Join-Path $Dist "slidecraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
# here.
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
Invoke-Native 'slidecraft-cli --version' { & (Join-Path $Stage 'slidecraft-cli.exe') --version }
} else {
Write-Output "skipping slidecraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
}
Get-Item $Msi, $Zip | Format-Table Name, Length

View File

@ -0,0 +1,95 @@
<#
.SYNOPSIS
Authenticode-sign files with signtool, using whichever signing material is configured.
.DESCRIPTION
Tries, in order:
1. A code-signing certificate file: WINDOWS_CERTIFICATE (base64 .pfx), WINDOWS_CERTIFICATE_PASSWORD
2. Azure Trusted Signing: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET,
AZURE_SIGNING_ENDPOINT (e.g. https://eus.codesigning.azure.net),
AZURE_SIGNING_ACCOUNT, AZURE_CERT_PROFILE
With neither, it prints a warning and leaves the files unsigned (exit code 0), so test builds
still produce installers.
This is the one place to adapt when the signing setup changes (an HSM/cloud key, a different
timestamp server, ...). Optional overrides: WINDOWS_TIMESTAMP_URL, SIGNTOOL (path to signtool.exe).
.EXAMPLE
pwsh packaging/windows/sign.ps1 dist/slidecraft.exe dist/Slidecraft.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', 'SlideCraft', '/du', 'https://github.com/storytold/slidecraft')
$tmp = Join-Path ([IO.Path]::GetTempPath()) "slidecraft-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
}

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SlideCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
wix build slidecraft.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, `.slidecraft` owned by
SlideCraft, and SlideCraft offered under "Open with" for .pptx/.potx/.ppsx (without taking over
the user's default app for them).
UpgradeCode is fixed for SlideCraft forever (playbook §6); never change it.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="SlideCraft"
Manufacturer="Learning Machines LLC"
Version="$(var.Version)"
UpgradeCode="6F9C69E7-7825-40D2-BF26-C274DA676425"
Scope="perMachine"
Compressed="yes"
Language="1033">
<SummaryInformation Description="SlideCraft presentations and slide shows" 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 SlideCraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="SlidecraftIcon.ico" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="SlidecraftIcon.ico" />
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps/slidecraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/slidecraft" />
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="SlideCraft" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="SlideCraft" Level="1">
<ComponentGroupRef Id="SlidecraftFiles" />
</Feature>
</Package>
<Fragment>
<ComponentGroup Id="SlidecraftFiles" Directory="INSTALLFOLDER">
<Component Id="SlidecraftApp" Guid="D87EA444-41A4-4C19-922E-66D1A29461FF">
<File Id="SlidecraftExe" Source="$(var.BinDir)\slidecraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="SlideCraft"
Description="SlideCraft presentations and slide shows" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="SlidecraftIcon.ico" IconIndex="0" />
</File>
<!-- .slidecraft: SlideCraft's own format. -->
<ProgId Id="SlideCraft.Presentation" Description="SlideCraft Presentation" Icon="SlidecraftExe" IconIndex="0">
<Extension Id="slidecraft" ContentType="application/x-slidecraft">
<Verb Id="open" Command="Open" TargetFile="SlidecraftExe" Argument="&quot;%1&quot;" />
</Extension>
</ProgId>
<!-- "Open with" for PowerPoint formats, without becoming the default. -->
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\slidecraft.exe" Name="FriendlyAppName" Value="SlideCraft" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\slidecraft.exe\shell\open\command" Value="&quot;[#SlidecraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\slidecraft.exe\SupportedTypes" Name=".slidecraft" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\slidecraft.exe\SupportedTypes" Name=".pptx" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\slidecraft.exe\SupportedTypes" Name=".potx" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\slidecraft.exe\SupportedTypes" Name=".ppsx" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.pptx\OpenWithProgids" Name="SlideCraft.Presentation" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.potx\OpenWithProgids" Name="SlideCraft.Presentation" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.ppsx\OpenWithProgids" Name="SlideCraft.Presentation" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\slidecraft.exe" Value="[#SlidecraftExe]" Type="string" />
</Component>
<Component Id="SlidecraftCli">
<File Id="SlidecraftCliExe" Source="$(var.BinDir)\slidecraft-cli.exe" KeyPath="yes" />
</Component>
</ComponentGroup>
</Fragment>
</Wix>