diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml index cfeda34..d3d434f 100644 --- a/.github/workflows/release-builds.yml +++ b/.github/workflows/release-builds.yml @@ -66,6 +66,10 @@ jobs: fi tar -czf "dist/${{ matrix.archive }}" -C dist stringcast + - name: Prepare macOS DMG + if: runner.os == 'macOS' + run: packaging/macos/build_dmg.sh dist/macos/Stringcast.app dist/stringcast-macos.dmg + - name: Prepare Windows artifact if: runner.os == 'Windows' shell: pwsh @@ -82,3 +86,11 @@ jobs: name: ${{ matrix.archive }} path: dist/${{ matrix.archive }} if-no-files-found: error + + - name: Upload macOS DMG artifact + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: stringcast-macos.dmg + path: dist/stringcast-macos.dmg + if-no-files-found: error diff --git a/docs/MACOS_APP.md b/docs/MACOS_APP.md index 355ac57..aa2f4f9 100644 --- a/docs/MACOS_APP.md +++ b/docs/MACOS_APP.md @@ -31,6 +31,18 @@ The app is written to: dist/macos/Stringcast.app ``` +Build a drag-to-Applications DMG: + +```bash +packaging/macos/build_dmg.sh +``` + +The DMG is written to: + +```text +dist/stringcast-macos.dmg +``` + Open it: ```bash @@ -78,13 +90,10 @@ The app does not block startup on this permission check. If permissions are miss ## Current Limitations - The app is unsigned. -- There is no custom app icon yet. - Logs open in Finder rather than an in-app viewer. -- Packaging does not create a DMG or installer yet. +- The DMG is unsigned and not notarized. ## Next Packaging Steps -- Add an icon. - Add log/status display. - Add code signing and notarization. -- Produce a DMG for end users. diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 5c2055a..adf1cdd 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -1,20 +1,21 @@ # Release Artifacts -Stringcast can be distributed as downloadable binaries from GitHub Actions. This lets users run the Rust app without installing Cargo. +Stringcast can be distributed as downloadable artifacts from GitHub Actions. This lets users run the Rust app without installing Cargo. -The current artifacts are raw CLI binaries, not full desktop installers. A macOS app wrapper and richer desktop packaging are planned separately. +The macOS build includes both a tarball and a drag-to-Applications DMG. Linux and Windows currently ship archive artifacts. ## Build Artifacts The `Release Builds` workflow creates: ```text +stringcast-macos.dmg stringcast-macos.tar.gz stringcast-linux-x86_64.tar.gz stringcast-windows-x86_64.zip ``` -Each archive includes: +Each platform archive includes: - `stringcast` or `stringcast.exe` - `Stringcast.app` in the macOS archive @@ -54,6 +55,14 @@ gh run download -D ./artifacts ## macOS Smoke Test +For the DMG: + +```bash +open stringcast-macos.dmg +``` + +Then drag `Stringcast.app` to Applications, or launch it directly from the mounted volume for a quick test. + Unpack: ```bash diff --git a/packaging/macos/build_app.sh b/packaging/macos/build_app.sh index c8f12ca..18c676e 100755 --- a/packaging/macos/build_app.sh +++ b/packaging/macos/build_app.sh @@ -48,8 +48,8 @@ if ! command -v sips >/dev/null 2>&1; then exit 1 fi -if ! command -v iconutil >/dev/null 2>&1; then - echo "missing iconutil; cannot generate app icon" >&2 +if ! command -v python3 >/dev/null 2>&1; then + echo "missing python3; cannot generate app icon" >&2 exit 1 fi @@ -65,21 +65,40 @@ xcrun --sdk macosx swiftc \ -o "$MACOS_DIR/StringcastMenu" if [ -f "$ICON_SOURCE" ]; then - ICONSET_DIR="$OUT_DIR/StringcastIcon.iconset" - rm -rf "$ICONSET_DIR" - mkdir -p "$ICONSET_DIR" - sips -z 16 16 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null - sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null - sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null - sips -z 64 64 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null - sips -z 128 128 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null - sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null - sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null - sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null - sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null - sips -z 1024 1024 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null - iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/StringcastIcon.icns" - rm -rf "$ICONSET_DIR" + ICON_WORK_DIR="$OUT_DIR/StringcastIcon.work" + rm -rf "$ICON_WORK_DIR" + mkdir -p "$ICON_WORK_DIR" + sips -Z 1024 "$ICON_SOURCE" --out "$ICON_WORK_DIR/icon-scaled.png" >/dev/null + sips --padToHeightWidth 1024 1024 --padColor FFFFFF "$ICON_WORK_DIR/icon-scaled.png" --out "$ICON_WORK_DIR/icon-1024.png" >/dev/null 2>&1 + for size in 16 32 64 128 256 512 1024; do + sips -z "$size" "$size" "$ICON_WORK_DIR/icon-1024.png" --out "$ICON_WORK_DIR/icon_${size}.png" >/dev/null + done + python3 - "$RESOURCES_DIR/StringcastIcon.icns" "$ICON_WORK_DIR" <<'PY' +import struct +import sys +from pathlib import Path + +output = Path(sys.argv[1]) +source_dir = Path(sys.argv[2]) +entries = [ + (b"icp4", "icon_16.png"), + (b"icp5", "icon_32.png"), + (b"icp6", "icon_64.png"), + (b"ic07", "icon_128.png"), + (b"ic08", "icon_256.png"), + (b"ic09", "icon_512.png"), + (b"ic10", "icon_1024.png"), +] + +chunks = [] +for icon_type, filename in entries: + data = (source_dir / filename).read_bytes() + chunks.append(icon_type + struct.pack(">I", len(data) + 8) + data) + +payload = b"".join(chunks) +output.write_bytes(b"icns" + struct.pack(">I", len(payload) + 8) + payload) +PY + rm -rf "$ICON_WORK_DIR" else echo "warning: icon source not found: $ICON_SOURCE" >&2 echo " save the app icon PNG there, or set STRINGCAST_ICON=/path/to/icon.png" >&2 diff --git a/packaging/macos/build_dmg.sh b/packaging/macos/build_dmg.sh new file mode 100755 index 0000000..9f11f8a --- /dev/null +++ b/packaging/macos/build_dmg.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -eu + +if [ "${1:-}" = "--help" ]; then + cat <<'USAGE' +usage: packaging/macos/build_dmg.sh [app-path] [output-dmg-path] [volume-name] + +Builds an unsigned macOS DMG with Stringcast.app and an Applications shortcut. + +Defaults: + app-path: dist/macos/Stringcast.app + output-dmg-path: dist/stringcast-macos.dmg + volume-name: Stringcast +USAGE + exit 0 +fi + +APP_PATH=${1:-dist/macos/Stringcast.app} +DMG_PATH=${2:-dist/stringcast-macos.dmg} +VOLUME_NAME=${3:-Stringcast} + +if [ "$(uname -s)" != "Darwin" ]; then + echo "macOS DMGs can only be built on macOS" >&2 + exit 1 +fi + +if [ ! -d "$APP_PATH" ]; then + echo "missing app bundle: $APP_PATH" >&2 + echo "run: cargo build --release && packaging/macos/build_app.sh" >&2 + exit 1 +fi + +if ! command -v hdiutil >/dev/null 2>&1; then + echo "missing hdiutil; cannot generate DMG" >&2 + exit 1 +fi + +DMG_DIR=$(dirname "$DMG_PATH") +mkdir -p "$DMG_DIR" + +STAGING_DIR=$(mktemp -d "${TMPDIR:-/tmp}/stringcast-dmg.XXXXXX") +trap 'rm -rf "$STAGING_DIR"' EXIT INT TERM + +cp -R "$APP_PATH" "$STAGING_DIR/Stringcast.app" +ln -s /Applications "$STAGING_DIR/Applications" + +hdiutil create \ + -volname "$VOLUME_NAME" \ + -srcfolder "$STAGING_DIR" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +echo "Built $DMG_PATH"