diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f81d050..e59d735 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,9 +156,23 @@ jobs: if: matrix.platform == 'macos' env: APPLE_SIGNING_IDENTITY: "-" + TAURI_BUNDLER_DMG_IGNORE_CI: "true" TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" run: make bundles TARGET=${{ matrix.target }} CONFIG=src-tauri/tauri.updater.conf.json + - name: Verify macOS DMG layout + if: matrix.platform == 'macos' + env: + RELEASE_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + shopt -s nullglob + dmg_paths=(target/"$RELEASE_TARGET"/release/bundle/dmg/*.dmg) + if [[ ${#dmg_paths[@]} -ne 1 ]]; then + echo "Expected exactly one macOS DMG for layout verification." >&2 + exit 1 + fi + ./scripts/verify-macos-dmg.sh "${dmg_paths[0]}" - name: Build Linux bundles if: matrix.platform == 'linux' env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c90526..a7bef3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Initial public release for bounded cross-platform and Agent compatibility. - Optional age-encrypted backups for complete plans with a supported restore route. - Atomic operation journal, startup recovery, post-mutation rescans, and all-or-nothing restore preflight and rollback. - Unsigned macOS, Linux, and Windows packaging with SHA-256 checksums and build metadata. +- A guided macOS DMG install layout with CI checks for its Finder metadata and tracked background. - User-initiated Tauri updates with a pinned public key and signed updater artifacts. ### Safety boundary diff --git a/assets/branding/dmg-background.png b/assets/branding/dmg-background.png index 38fb187..fa12fcf 100644 Binary files a/assets/branding/dmg-background.png and b/assets/branding/dmg-background.png differ diff --git a/assets/branding/dmg-background.svg b/assets/branding/dmg-background.svg new file mode 100644 index 0000000..095d90d --- /dev/null +++ b/assets/branding/dmg-background.svg @@ -0,0 +1,37 @@ + + + + + + CLEANERX / MACOS INSTALLER + LOCAL-ONLY UTILITY + + + + Install CleanerX + Drag the app into Applications. Nothing else is installed. + + + + + + 01 / CLEANERX.APP + 02 / APPLICATIONS + + + + + + + DRAG TO INSTALL + + + + + + + + UNSIGNED BUILD / macOS MAY ASK FOR APPROVAL ON FIRST LAUNCH + FINDER → OPEN + + diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md index 2d1d6bc..dd288a2 100644 --- a/docs/releases/v0.1.0.md +++ b/docs/releases/v0.1.0.md @@ -37,6 +37,7 @@ This checklist is the evidence record for the first CleanerX release. Do not pub - [ ] The release workflow accepts the SemVer tag and confirms every version surface matches. - [ ] Product CI reruns successfully from the exact tag. - [ ] macOS arm64, macOS x86_64, Linux x86_64, and Windows x86_64 release builds succeed. +- [ ] Both macOS DMGs retain the tracked guided-install background, configured Finder window, large icon positions, and exact `/Applications` link. - [ ] Every expected manual installer, updater payload, `.sig`, `latest.json`, lockfile archive, build-metadata file, and checksum file appears exactly once in the draft. - [ ] `latest.json` contains version `0.1.0`, the four intended updater targets, HTTPS URLs for the exact tag, and non-empty signatures. - [ ] Every staged asset matches `CleanerX_0.1.0_SHA256SUMS.txt`. diff --git a/docs/update-strategy.md b/docs/update-strategy.md index 4bc8908..4ca2367 100644 --- a/docs/update-strategy.md +++ b/docs/update-strategy.md @@ -44,6 +44,8 @@ The fixed stable endpoint intentionally excludes GitHub prereleases. A future be Normal developer bundle commands remain unsigned and do not require the updater private key. Tagged releases add `src-tauri/tauri.updater.conf.json`, set `TAURI_SIGNING_PRIVATE_KEY` from GitHub Actions secrets, and ask Tauri to create update artifacts and `.sig` files. The release-draft job gathers every architecture, runs `scripts/generate-update-manifest.mjs`, and fails closed on a missing/empty signature before staging `latest.json` and checksums in a draft GitHub Release. A maintainer verifies the complete draft before publishing it. +[Tauri normally skips Finder cosmetic metadata when it detects `CI=true`](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/macos/dmg/mod.rs#L160-L168), which would silently reduce a configured DMG to Finder's default icon view. The macOS release jobs explicitly set Tauri's supported `TAURI_BUNDLER_DMG_IGNORE_CI=true` override, then mount each completed DMG read-only and require the tracked field-manual background, a non-empty `.DS_Store` selecting that background and the configured window size, the application bundle, and the exact `/Applications` link. A missing or stale layout fails the release before assets are staged. + WiX/MSI does not accept alphanumeric Semantic Versioning prerelease identifiers. The Windows release job therefore uses `scripts/generate-windows-release-config.mjs` to derive a numeric MSI-only version that sorts below the eventual stable version (`0.1.0-alpha.1` becomes `0.0.65535.10001`). The application, tag, updater manifest, NSIS installer, and release asset names retain the public Semantic Versioning value. Unsupported prerelease shapes fail during tag validation before platform builds begin. Tauri's signature establishes continuity with the public key embedded in an installed CleanerX build. It does not establish operating-system publisher identity. Artifact names and release warnings therefore continue to say that current binaries are unsigned and not notarized. diff --git a/scripts/verify-macos-dmg-layout.mjs b/scripts/verify-macos-dmg-layout.mjs new file mode 100644 index 0000000..54562cd --- /dev/null +++ b/scripts/verify-macos-dmg-layout.mjs @@ -0,0 +1,102 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, readlink } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const expectedWindowSizeMarker = "760, 440"; + +function digest(contents) { + return createHash("sha256").update(contents).digest("hex"); +} + +async function pathKind(target) { + try { + const stat = await lstat(target); + if (stat.isDirectory()) return "directory"; + if (stat.isSymbolicLink()) return "symbolic-link"; + if (stat.isFile()) return "file"; + return "other"; + } catch (error) { + if (error?.code === "ENOENT") return "missing"; + throw error; + } +} + +export function validateDmgLayout(snapshot) { + const problems = []; + + if (snapshot.appKind !== "directory") { + problems.push("CleanerX.app must be a real directory"); + } + if (snapshot.applicationsKind !== "symbolic-link") { + problems.push("Applications must be a symbolic link"); + } else if (snapshot.applicationsTarget !== "/Applications") { + problems.push("Applications must link exactly to /Applications"); + } + if (snapshot.dsStoreKind !== "file" || snapshot.dsStoreBytes < 512) { + problems.push(".DS_Store must be a non-empty Finder layout file"); + } + if (!snapshot.dsStoreContainsBackground) { + problems.push(".DS_Store must select dmg-background.png"); + } + if (!snapshot.dsStoreContainsWindowSize) { + problems.push(".DS_Store must retain the configured 760 x 440 window size"); + } + if (snapshot.backgroundKind !== "file") { + problems.push("the DMG background must be a real file"); + } else if (snapshot.backgroundDigest !== snapshot.expectedBackgroundDigest) { + problems.push("the staged DMG background must match the tracked background"); + } + + if (problems.length > 0) { + throw new Error(`Invalid macOS DMG layout:\n- ${problems.join("\n- ")}`); + } + + return snapshot; +} + +export async function inspectMountedDmg(mountRoot, expectedBackgroundPath) { + const dsStorePath = path.join(mountRoot, ".DS_Store"); + const backgroundPath = path.join(mountRoot, ".background", "dmg-background.png"); + const applicationsPath = path.join(mountRoot, "Applications"); + const dsStoreKind = await pathKind(dsStorePath); + const backgroundKind = await pathKind(backgroundPath); + const dsStore = dsStoreKind === "file" ? await readFile(dsStorePath) : Buffer.alloc(0); + const background = backgroundKind === "file" ? await readFile(backgroundPath) : Buffer.alloc(0); + const expectedBackground = await readFile(expectedBackgroundPath); + const dsStoreText = dsStore.toString("latin1"); + const applicationsKind = await pathKind(applicationsPath); + + return validateDmgLayout({ + appKind: await pathKind(path.join(mountRoot, "CleanerX.app")), + applicationsKind, + applicationsTarget: + applicationsKind === "symbolic-link" ? await readlink(applicationsPath) : null, + dsStoreKind, + dsStoreBytes: dsStore.byteLength, + dsStoreContainsBackground: dsStoreText.includes("dmg-background.png"), + dsStoreContainsWindowSize: dsStoreText.includes(expectedWindowSizeMarker), + backgroundKind, + backgroundDigest: digest(background), + expectedBackgroundDigest: digest(expectedBackground), + }); +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isMain) { + if (process.argv.length !== 4) { + console.error("Usage: node scripts/verify-macos-dmg-layout.mjs "); + process.exit(2); + } + + try { + const snapshot = await inspectMountedDmg(process.argv[2], process.argv[3]); + console.log( + `Verified Finder layout (${snapshot.dsStoreBytes} byte .DS_Store, background ${snapshot.backgroundDigest}).`, + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/verify-macos-dmg-layout.test.js b/scripts/verify-macos-dmg-layout.test.js new file mode 100644 index 0000000..df095c4 --- /dev/null +++ b/scripts/verify-macos-dmg-layout.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { validateDmgLayout } from "./verify-macos-dmg-layout.mjs"; + +const validSnapshot = { + appKind: "directory", + applicationsKind: "symbolic-link", + applicationsTarget: "/Applications", + dsStoreKind: "file", + dsStoreBytes: 10_240, + dsStoreContainsBackground: true, + dsStoreContainsWindowSize: true, + backgroundKind: "file", + backgroundDigest: "expected-digest", + expectedBackgroundDigest: "expected-digest", +}; + +describe("macOS DMG layout validation", () => { + it("accepts the intended Finder layout snapshot", () => { + expect(validateDmgLayout(validSnapshot)).toEqual(validSnapshot); + }); + + it("rejects the unstyled CI layout", () => { + expect(() => validateDmgLayout({ + ...validSnapshot, + dsStoreKind: "missing", + dsStoreBytes: 0, + dsStoreContainsBackground: false, + dsStoreContainsWindowSize: false, + })).toThrow(/Finder layout file[\s\S]*select dmg-background[\s\S]*760 x 440/); + }); + + it("rejects a changed background or unsafe Applications entry", () => { + expect(() => validateDmgLayout({ + ...validSnapshot, + applicationsKind: "directory", + applicationsTarget: null, + backgroundDigest: "different-digest", + })).toThrow(/symbolic link[\s\S]*match the tracked background/); + }); +}); diff --git a/scripts/verify-macos-dmg.sh b/scripts/verify-macos-dmg.sh new file mode 100755 index 0000000..fd30a34 --- /dev/null +++ b/scripts/verify-macos-dmg.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $(uname -s) != "Darwin" ]]; then + echo "verify-macos-dmg requires macOS." >&2 + exit 2 +fi +if [[ $# -ne 1 || ! -f $1 ]]; then + echo "Usage: scripts/verify-macos-dmg.sh " >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "$script_dir/.." && pwd)" +dmg_path="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +mount_dir="$(mktemp -d -t cleanerx-dmg-verify)" +mounted=false + +cleanup() { + if [[ $mounted == true ]]; then + hdiutil detach "$mount_dir" >/dev/null 2>&1 || true + fi + rmdir "$mount_dir" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +hdiutil verify "$dmg_path" >/dev/null +hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$dmg_path" >/dev/null +mounted=true + +node "$repository_root/scripts/verify-macos-dmg-layout.mjs" \ + "$mount_dir" \ + "$repository_root/assets/branding/dmg-background.png" + +hdiutil detach "$mount_dir" >/dev/null +mounted=false +rmdir "$mount_dir" +trap - EXIT