diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml new file mode 100644 index 0000000..7b66f33 --- /dev/null +++ b/.github/workflows/freebsd.yml @@ -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 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml new file mode 100644 index 0000000..d7362dc --- /dev/null +++ b/.github/workflows/packaging-lint.yml @@ -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 '"$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'" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b660831 --- /dev/null +++ b/.github/workflows/release.yml @@ -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" (tag v). 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" diff --git a/.gitignore b/.gitignore index 8b89730..07d2ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ plan/ /.claude/ /craft-fonts/ packaging/env.sh +/packaging/linux/flatpak/cargo-sources.json +/build-flatpak/ +/.flatpak-builder/ diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5c90950..f5f91aa 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 4417fbc..d405d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/ROADMAP.md b/ROADMAP.md index 553ac35..dcbd509 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 · diff --git a/apps/slidecraft-web/Cargo.toml b/apps/slidecraft-web/Cargo.toml new file mode 100644 index 0000000..16b8546 --- /dev/null +++ b/apps/slidecraft-web/Cargo.toml @@ -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"] } diff --git a/apps/slidecraft-web/Trunk.toml b/apps/slidecraft-web/Trunk.toml new file mode 100644 index 0000000..c6e5c00 --- /dev/null +++ b/apps/slidecraft-web/Trunk.toml @@ -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 diff --git a/apps/slidecraft-web/index.html b/apps/slidecraft-web/index.html new file mode 100644 index 0000000..4f2d54d --- /dev/null +++ b/apps/slidecraft-web/index.html @@ -0,0 +1,20 @@ + + + + + + SlideCraft + + + + + + +
Loading SlideCraft…
+ + diff --git a/apps/slidecraft-web/src/main.rs b/apps/slidecraft-web/src/main.rs new file mode 100644 index 0000000..82f564c --- /dev/null +++ b/apps/slidecraft-web/src/main.rs @@ -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"); +} diff --git a/apps/slidecraft-web/src/web.rs b/apps/slidecraft-web/src/web.rs new file mode 100644 index 0000000..0bb14b7 --- /dev/null +++ b/apps/slidecraft-web/src/web.rs @@ -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::().ok()) else { + log::error!("missing "); + 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!("

SlideCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")), + } + } + }); +} + +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", + } +} diff --git a/apps/slidecraft/Cargo.toml b/apps/slidecraft/Cargo.toml index bdb937c..cf518b1 100644 --- a/apps/slidecraft/Cargo.toml +++ b/apps/slidecraft/Cargo.toml @@ -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"] } diff --git a/apps/slidecraft/build.rs b/apps/slidecraft/build.rs new file mode 100644 index 0000000..40c647a --- /dev/null +++ b/apps/slidecraft/build.rs @@ -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}"); + } +} diff --git a/assets/app-icon/slidecraft.icns b/assets/app-icon/slidecraft.icns new file mode 100644 index 0000000..1ab212c Binary files /dev/null and b/assets/app-icon/slidecraft.icns differ diff --git a/assets/app-icon/slidecraft.ico b/assets/app-icon/slidecraft.ico new file mode 100644 index 0000000..769cd6c Binary files /dev/null and b/assets/app-icon/slidecraft.ico differ diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 8684384..854f945 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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"] } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b13b7ab..6f20dfc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f65fcd6..5446ffd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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() } diff --git a/packaging/.shellcheckrc b/packaging/.shellcheckrc new file mode 100644 index 0000000..36c19d0 --- /dev/null +++ b/packaging/.shellcheckrc @@ -0,0 +1,3 @@ +# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml). +source-path=SCRIPTDIR +external-sources=true diff --git a/packaging/common.sh b/packaging/common.sh new file mode 100644 index 0000000..6a0c3d8 --- /dev/null +++ b/packaging/common.sh @@ -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-.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 +} diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh new file mode 100755 index 0000000..fc6beaf --- /dev/null +++ b/packaging/freebsd/package.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Build and package SlideCraft for FreeBSD: +# +# $DIST/slidecraft--freebsd-.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" diff --git a/packaging/icons.sh b/packaging/icons.sh new file mode 100755 index 0000000..c49610f --- /dev/null +++ b/packaging/icons.sh @@ -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 diff --git a/packaging/linux/ai.storyteller.slidecraft.desktop b/packaging/linux/ai.storyteller.slidecraft.desktop new file mode 100644 index 0000000..099d43b --- /dev/null +++ b/packaging/linux/ai.storyteller.slidecraft.desktop @@ -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; diff --git a/packaging/linux/ai.storyteller.slidecraft.metainfo.xml.in b/packaging/linux/ai.storyteller.slidecraft.metainfo.xml.in new file mode 100644 index 0000000..843e7db --- /dev/null +++ b/packaging/linux/ai.storyteller.slidecraft.metainfo.xml.in @@ -0,0 +1,59 @@ + + + + ai.storyteller.slidecraft + CC0-1.0 + MIT OR Apache-2.0 + SlideCraft + Create and present slide decks + + Learning Machines LLC + + +

+ 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. +

+

+ Every action is also a command that the command line and AI agents (through MCP) can drive. +

+
+ ai.storyteller.slidecraft.desktop + ai.storyteller.slidecraft + + https://getartcraft.com/apps/slidecraft + https://github.com/storytold/slidecraft/issues + https://github.com/storytold/slidecraft + https://discord.gg/artcraft + + Office + Presentation + + + presentation + slides + slide show + pptx + + + #ffc9a8 + #b8471f + + + 760 + + + keyboard + pointing + + + slidecraft + application/x-slidecraft + + + + + +
diff --git a/packaging/linux/ai.storyteller.slidecraft.mime.xml b/packaging/linux/ai.storyteller.slidecraft.mime.xml new file mode 100644 index 0000000..49530e4 --- /dev/null +++ b/packaging/linux/ai.storyteller.slidecraft.mime.xml @@ -0,0 +1,9 @@ + + + + SlideCraft presentation + + + + + diff --git a/packaging/linux/flatpak/ai.storyteller.slidecraft.yml b/packaging/linux/flatpak/ai.storyteller.slidecraft.yml new file mode 100644 index 0000000..14dd78d --- /dev/null +++ b/packaging/linux/flatpak/ai.storyteller.slidecraft.yml @@ -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 diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml new file mode 100644 index 0000000..5e04c86 --- /dev/null +++ b/packaging/linux/nfpm.yaml @@ -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 diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh new file mode 100755 index 0000000..d0560d1 --- /dev/null +++ b/packaging/linux/package.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Build and package SlideCraft for Linux ( is x86_64 or aarch64): +# +# $DIST/slidecraft--linux-.AppImage any distro with glibc >= the build host's +# $DIST/slidecraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... +# $DIST/slidecraft--linux-.rpm Fedora, openSUSE, RHEL, ... +# $DIST/slidecraft--linux-.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 }" +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" diff --git a/packaging/linux/postinst.sh b/packaging/linux/postinst.sh new file mode 100755 index 0000000..775e4df --- /dev/null +++ b/packaging/linux/postinst.sh @@ -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 diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in new file mode 100644 index 0000000..347fe72 --- /dev/null +++ b/packaging/macos/Info.plist.in @@ -0,0 +1,95 @@ + + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + SlideCraft + CFBundleName + SlideCraft + CFBundleExecutable + SlideCraft + CFBundleIdentifier + ai.storyteller.slidecraft + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleShortVersionString + @SHORT_VERSION@ + CFBundleVersion + @SHORT_VERSION@ + SlideCraftVersion + @VERSION@ + SlideCraftBuildCommit + @BUILD_SHA@ + CFBundleIconFile + SlideCraft + LSApplicationCategoryType + public.app-category.productivity + + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + NSRequiresAquaSystemAppearance + + NSHumanReadableCopyright + Copyright © the SlideCraft authors. MIT OR Apache-2.0. + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + ai.storyteller.slidecraft.presentation + UTTypeDescription + SlideCraft Presentation + UTTypeConformsTo + + public.data + public.presentation + + UTTypeIconFile + SlideCraft + UTTypeTagSpecification + + public.filename-extension + slidecraft + public.mime-type + application/x-slidecraft + + + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + org.openxmlformats.presentationml.presentation + UTTypeDescription + Open XML Presentation + UTTypeConformsTo + + public.data + public.presentation + public.zip-archive + + UTTypeTagSpecification + + public.filename-extension + pptx + + + + + + diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist new file mode 100644 index 0000000..e3c22ff --- /dev/null +++ b/packaging/macos/entitlements.plist @@ -0,0 +1,9 @@ + + + + + + + diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh new file mode 100755 index 0000000..5be3ebf --- /dev/null +++ b/packaging/macos/import-cert.sh @@ -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 diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh new file mode 100755 index 0000000..b7da66a --- /dev/null +++ b/packaging/macos/package.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Build, sign and (optionally) notarize the macOS release artifacts: +# +# $DIST/slidecraft--macos-.dmg SlideCraft.app on a drag-to-Applications DMG +# $DIST/slidecraft-cli--macos-.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" diff --git a/packaging/web/.htaccess b/packaging/web/.htaccess new file mode 100644 index 0000000..7b7e6e1 --- /dev/null +++ b/packaging/web/.htaccess @@ -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 + + + AddOutputFilterByType DEFLATE application/wasm text/javascript text/html + + + + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "no-cache" + + diff --git a/packaging/web/README.md b/packaging/web/README.md new file mode 100644 index 0000000..4b272bf --- /dev/null +++ b/packaging/web/README.md @@ -0,0 +1,88 @@ +# Hosting SlideCraft for the web + +`slidecraft-web-.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a +static site in `slidecraft-web-/`: + +| File | What it is | +|---|---| +| `index.html` | The page. It loads everything through relative URLs. | +| `slidecraft-web-.js` | wasm-bindgen glue (generated, ES module) | +| `slidecraft-web-_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 + +``` + +- 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: `