diff --git a/docs/sdlc/changes/2026-08-26-dmg-installer-polish/intent.md b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/intent.md new file mode 100644 index 0000000..570ad00 --- /dev/null +++ b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/intent.md @@ -0,0 +1,55 @@ +# Intent: Polish and harden the Utter DMG installer + +## Problem + +The release DMG currently contains only `Utter.app` and an Applications +symlink. Finder chooses the window size, icon placement, and background, so the +installation flow looks unfinished and can vary with the packager's Finder +state. The existing path also has no Retina-aware installation artwork or +deterministic layout metadata. + +## Outcome + +Opening a release DMG shows a compact Utter-branded Finder window with the app +and Applications folder aligned around a clear drag arrow. Labels remain +readable in the user's current macOS appearance, and the same result is produced +locally and in release CI without scripting Finder. + +## Scope + +In scope: DMG background rendering, Finder window metadata, icon positions, +volume presentation, deterministic packaging dependencies, and the existing +`build-app.sh` DMG stage. + +Out of scope: app runtime UI, application behavior, entitlements, signing +identity selection, notarization policy, release credentials, or installing the +app during validation. + +## Constraints + +- Preserve the current Swift Package and release-signing flow. +- Keep generated PNG/TIFF files, downloaded Python packages, `.app`, `.dmg`, and + screenshots out of Git. +- Use only macOS-compatible tooling available to local builders and GitHub macOS + runners; fail rather than silently ship an unstyled or unverifiable image. +- Treat local ad-hoc signing as packaging evidence, not public distribution + evidence. + +## Acceptance criteria + +- The real Finder window opens at 680 by 440 points with Utter and Applications + icons at the intended positions, a readable light background, and no clipped + installation copy. +- The background contains 1x and 2x representations and the DMG has a custom + volume icon. +- Packaging writes Finder layout metadata without launching or controlling + Finder. +- Packaging dependencies are version- and SHA-256-pinned and cached only below + `.build`. +- `verify-release-artifact.sh` and strict mounted-app code-signature validation + accept the locally generated artifact. + +## Open questions + +None. Human review still decides whether the new packaging dependency and +visual treatment are accepted for release. diff --git a/docs/sdlc/changes/2026-08-26-dmg-installer-polish/plan.md b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/plan.md new file mode 100644 index 0000000..906290c --- /dev/null +++ b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/plan.md @@ -0,0 +1,33 @@ +# Plan: Polish and harden the Utter DMG installer + +## Work items + +- [x] Audit the existing DMG stage and release workflow. +- [x] Render restrained 1x and 2x branded installation backgrounds. +- [x] Add deterministic Finder geometry, icon positions, Applications symlink, + and volume icon metadata without Finder automation. +- [x] Pin packaging dependencies by version and SHA-256 below the ignored build + cache. +- [x] Preserve strict mounted-app signature validity and the latest release + artifact verifier. +- [x] Re-run all repository, unit, release-build, artifact, and real-window + checks on the latest `origin/main` base. +- [x] Record final evidence and residual risk for review. + +## Verification plan + +- [x] `python3 scripts/sdlc.py validate --worktree` +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer swift test` +- [x] `DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer ./scripts/build-app.sh --version=0.0.0 --sign=-` +- [x] Inspect the mounted DMG, strict code signature, layout metadata, Retina + background representations, architecture, symlink, checksum, and real Finder + window. +- [x] `git diff --check` + +## Human gates + +An independent reviewer must accept the high-risk packaging/dependency change +and its rollback before merge. The protected release environment separately +owns the configured signing identity, Developer ID/notarization path, and public +release authorization. diff --git a/docs/sdlc/changes/2026-08-26-dmg-installer-polish/spec.md b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/spec.md new file mode 100644 index 0000000..c1d7623 --- /dev/null +++ b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/spec.md @@ -0,0 +1,62 @@ +# Spec: Polish and harden the Utter DMG installer + +## Context + +`scripts/build-app.sh` already builds with Xcode, assembles `Utter.app`, compiles +appearance-aware app icons, signs the bundle, and verifies release artifacts. +Its previous DMG stage copied the app and Applications symlink into a staging +folder and asked `hdiutil` to compress that folder. It did not create a Finder +layout or installation background. + +Finder automation can create `.DS_Store`, but it depends on interactive Finder +state and can be blocked by unrelated Finder dialogs. The packaging path must be +deterministic in both a developer session and GitHub Actions. + +## Design + +`generate-dmg-background.swift` renders a 680 by 440 AppKit bitmap at 1x and 2x. +The light neutral surface, subtle Utter color accents, centered installation +instruction, and arrow are positioned around the Finder icons rather than +duplicating them in the artwork. + +`dmg-settings.py` owns the window rectangle, icon size, icon locations, HFS+ +filesystem, UDZO compression, Applications symlink, and volume icon. The build +script installs `dmgbuild`, `ds-store`, and `mac-alias` into +`.build/xcode/dmg-tools` from `dmg-requirements.txt`. Every wheel is pinned by +version and SHA-256, and a versioned stamp avoids repeated installation. + +The existing release verifier remains the final build step. No packaging code +changes signing identity selection, entitlements, notarization, or publication. + +## Safety and failure modes + +- A missing Python 3.10+ runtime, unavailable package index, hash mismatch, or + import failure stops the build; it does not fall back to an unstyled DMG. +- Downloaded code is isolated under ignored `.build` paths and never shipped in + `Utter.app`. +- Finder extension-hiding metadata is not written to `Utter.app`, because that + extended attribute makes strict code-signature verification fail. +- A corrupt tool cache fails during module import or DMG creation. Removing the + ignored cache allows a clean hash-verified reinstall. +- The packaging library currently emits macOS 26 deprecation warnings for its + internal `hdiutil` calls; artifact verification remains the acceptance gate. + +## Test strategy + +- Validate shell syntax, Python syntax, Swift background rendering, repository + policy, and the complete Swift test suite. +- Build a release-style ad-hoc app and DMG on the latest `main` base. +- Verify the DMG checksum, Applications symlink, mounted app signature, arm64 + executable, 1x/2x TIFF representations, and exact `.DS_Store` geometry. +- Open the final image through Finder and inspect the actual title bar, artwork, + icon labels, spacing, arrow, and lower-edge clipping without installing or + launching the app. + +## Rollout and rollback + +Merge through the normal PR and release review path. The next release workflow +build exercises the same DMG stage with its configured signing identity and +artifact verifier. Stop if clean-runner dependency installation or mounted-DMG +verification fails. Roll back by reverting this change, which restores the +previous plain `hdiutil -srcfolder` image without changing app binaries or user +data. diff --git a/docs/sdlc/changes/2026-08-26-dmg-installer-polish/state.json b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/state.json new file mode 100644 index 0000000..39d3556 --- /dev/null +++ b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/state.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "2026-08-26-dmg-installer-polish", + "title": "Polish and harden the Utter DMG installer", + "risk": "high", + "status": "verified", + "owners": [ + "repository maintainer" + ], + "acceptanceCriteria": [ + "Opening the DMG presents a compact branded Finder window with readable labels and an unambiguous drag-to-Applications flow.", + "The DMG layout is generated without depending on interactive Finder state and uses hash-pinned packaging tools.", + "The mounted app preserves its strict code-signature validity and the release artifact verifier accepts the generated DMG.", + "Generated build products, downloaded tools, and visual QA screenshots remain outside the committed change." + ], + "governedPaths": [ + "scripts/build-app.sh", + "scripts/dmg-requirements.txt", + "scripts/dmg-settings.py", + "scripts/generate-dmg-background.swift" + ], + "artifacts": { + "intent": "intent.md", + "spec": "spec.md", + "plan": "plan.md", + "verification": "verification.md" + } +} diff --git a/docs/sdlc/changes/2026-08-26-dmg-installer-polish/verification.md b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/verification.md new file mode 100644 index 0000000..ac61bb1 --- /dev/null +++ b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/verification.md @@ -0,0 +1,44 @@ +# Verification: Polish and harden the Utter DMG installer + +## Evidence + +The implementation was rebased onto `origin/main` at `7e0689a`. During the +implementation loop, a real Finder inspection caught two defects before PR +preparation: dark artwork made Finder labels hard to read, and hiding the +`.app` extension added FinderInfo that invalidated strict mounted-app signature +verification. Both were corrected before the latest-base verification run. + +| Check | Result | Evidence | +|---|---|---| +| `python3 scripts/sdlc.py validate --worktree` | Pass | Verified bundle covers every governed changed path | +| `bash scripts/ci-basic-checks.sh` | Pass | SDLC, 13 harness tests, versioning, plist, localization, identifiers, lexicon, resources, conflicts, credentials, and symlink checks passed | +| `swift test` | Pass | 564 XCTest tests, 8 skipped, 0 failures; 1 Swift Testing test passed | +| Clean pinned tool install | Pass | `pip --require-hashes` installed `dmgbuild 1.6.7`, `ds-store 1.3.3`, and `mac-alias 2.2.3` into a new temporary directory | +| Release-style app and DMG build | Pass | `build-app.sh --version=0.0.0 --sign=-`; the built-in release artifact verifier passed | +| Mounted artifact verification | Pass | DMG checksum valid; strict deep code-signature verification passed; app is arm64; Applications resolves to `/Applications`; background contains two image representations | +| Finder layout metadata | Pass | 680 by 440 window; toolbar, sidebar, status bar, and path bar hidden; 112-point icons at `(174, 250)` and `(506, 250)`; background image enabled | +| Real Finder window | Pass with recapture limitation | The same final installer design was inspected in a real Finder window and showed readable, unclipped labels and a clear drag flow. A latest-base recapture attempt failed because ScreenCaptureKit could not start; final-artifact metadata and image representations were rechecked independently | +| `git diff --check` | Pass | No whitespace errors | + +## Acceptance criteria + +- Branded, readable, unclipped real Finder installation window — pass, with the + latest-base screenshot recapture limitation recorded above. +- Finder-independent, hash-pinned packaging — pass in clean temporary install + and repository checks. +- Strict mounted-app signature and release verifier — pass on the latest-base + release-style build. +- Generated products excluded from Git — pass in final worktree inspection. + +## Residual risk + +Independent high-risk review, configured release-identity verification, and any +Developer ID notarization run remain external/human gates. The packaging library +also emits non-fatal macOS 26 `hdiutil` deprecation warnings, and the latest-base +Finder screenshot should be spot-checked by the reviewer because the recapture +tool was unavailable. + +## Decision + +Ready for human review. No human approval is claimed here; approval remains a PR +review and protected-release decision. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 1548274..d5718d1 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -13,6 +13,7 @@ # Requirements: # - macOS with Xcode (full install, not just CLI tools) # - Swift 6.0+ +# - Python 3.10+ (DMG packaging only; pinned tools are cached under .build) # set -euo pipefail @@ -65,6 +66,7 @@ done APP_BUNDLE="${DIST_DIR}/${APP_NAME}.app" DMG_PATH="${DIST_DIR}/${APP_NAME}-${VERSION}.dmg" +DMG_VOLUME_NAME="${APP_NAME} ${VERSION}" # ─── Helpers ──────────────────────────────────────────────────────────────────── @@ -201,21 +203,38 @@ step "Creating DMG…" rm -f "${DMG_PATH}" -DMG_TMP="${DIST_DIR}/.dmg-staging" -rm -rf "${DMG_TMP}" -mkdir -p "${DMG_TMP}" - -cp -R "${APP_BUNDLE}" "${DMG_TMP}/" -ln -s /Applications "${DMG_TMP}/Applications" - -hdiutil create \ - -volname "${APP_NAME}" \ - -srcfolder "${DMG_TMP}" \ - -ov -format UDZO \ - "${DMG_PATH}" \ - -quiet +DMG_BACKGROUND_DIR="${DERIVED_DATA}/dmg-background" +DMG_TOOLS_DIR="${DERIVED_DATA}/dmg-tools" +DMG_TOOLS_STAMP="${DMG_TOOLS_DIR}/.utter-dmg-tools-1.6.7" + +rm -rf "${DMG_BACKGROUND_DIR}" +mkdir -p "${DMG_BACKGROUND_DIR}" +swift "${SCRIPT_DIR}/generate-dmg-background.swift" \ + "${DMG_BACKGROUND_DIR}/installer-background.png" 1 +swift "${SCRIPT_DIR}/generate-dmg-background.swift" \ + "${DMG_BACKGROUND_DIR}/installer-background@2x.png" 2 + +if [ ! -f "${DMG_TOOLS_STAMP}" ]; then + rm -rf "${DMG_TOOLS_DIR}" + mkdir -p "${DMG_TOOLS_DIR}" + python3 -m pip install \ + --disable-pip-version-check \ + --no-deps \ + --only-binary=:all: \ + --require-hashes \ + --target "${DMG_TOOLS_DIR}" \ + -r "${SCRIPT_DIR}/dmg-requirements.txt" \ + -q + touch "${DMG_TOOLS_STAMP}" +fi -rm -rf "${DMG_TMP}" +PYTHONPATH="${DMG_TOOLS_DIR}" python3 -m dmgbuild \ + -s "${SCRIPT_DIR}/dmg-settings.py" \ + -D application="${APP_BUNDLE}" \ + -D background="${DMG_BACKGROUND_DIR}/installer-background.png" \ + -D volume_icon="${APP_BUNDLE}/Contents/Resources/AppIcon.icns" \ + "${DMG_VOLUME_NAME}" \ + "${DMG_PATH}" done_msg "DMG created" diff --git a/scripts/dmg-requirements.txt b/scripts/dmg-requirements.txt new file mode 100644 index 0000000..e618280 --- /dev/null +++ b/scripts/dmg-requirements.txt @@ -0,0 +1,6 @@ +dmgbuild==1.6.7 \ + --hash=sha256:37ee5771c377beb3203d9164aae8046ffed8531c06edf9227f5788b3c599b1bf +ds-store==1.3.3 \ + --hash=sha256:b92a371efbf1b4ccce2a04d1ed13fceacc4736c81ba09cf5aefb74c088160a35 +mac-alias==2.2.3 \ + --hash=sha256:7362b521d2132ef92f606a37abfed5fcd849ceb2f28b6f9743e014b02af92f0d diff --git a/scripts/dmg-settings.py b/scripts/dmg-settings.py new file mode 100644 index 0000000..5b7a696 --- /dev/null +++ b/scripts/dmg-settings.py @@ -0,0 +1,20 @@ +application = defines["application"] +background = defines["background"] +icon = defines["volume_icon"] + +files = [(application, "Utter.app")] +symlinks = {"Applications": "/Applications"} +icon_locations = { + "Utter.app": (174, 250), + "Applications": (506, 250), +} + +window_rect = ((120, 120), (680, 440)) +icon_size = 112 +text_size = 13 +label_pos = "bottom" +show_icon_preview = True + +format = "UDZO" +filesystem = "HFS+" +compression_level = 9 diff --git a/scripts/generate-dmg-background.swift b/scripts/generate-dmg-background.swift new file mode 100644 index 0000000..e28e373 --- /dev/null +++ b/scripts/generate-dmg-background.swift @@ -0,0 +1,206 @@ +#!/usr/bin/env swift + +import AppKit +import Foundation + +let canvasSize = CGSize(width: 680, height: 440) +let outputURL = CommandLine.arguments.dropFirst().first.map(URL.init(fileURLWithPath:)) +let pixelScale = CommandLine.arguments.count > 2 ? Int(CommandLine.arguments[2]) : 1 + +guard let outputURL, let pixelScale, (1...2).contains(pixelScale) else { + fputs("Usage: swift scripts/generate-dmg-background.swift [1|2]\n", stderr) + exit(1) +} + +guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(canvasSize.width) * pixelScale, + pixelsHigh: Int(canvasSize.height) * pixelScale, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 +) else { + fputs("Could not create the DMG background bitmap.\n", stderr) + exit(1) +} + +bitmap.size = canvasSize +NSGraphicsContext.saveGraphicsState() +NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + +guard let context = NSGraphicsContext.current?.cgContext else { + fputs("Could not create the DMG background graphics context.\n", stderr) + exit(1) +} + +context.translateBy(x: 0, y: canvasSize.height) +context.scaleBy(x: 1, y: -1) + +let colorSpace = CGColorSpaceCreateDeviceRGB() +let background = CGGradient( + colorsSpace: colorSpace, + colors: [ + NSColor(srgbRed: 0.975, green: 0.980, blue: 0.995, alpha: 1).cgColor, + NSColor(srgbRed: 0.915, green: 0.930, blue: 0.970, alpha: 1).cgColor, + ] as CFArray, + locations: [0, 1] +)! +context.drawLinearGradient( + background, + start: CGPoint(x: canvasSize.width / 2, y: 0), + end: CGPoint(x: canvasSize.width / 2, y: canvasSize.height), + options: [] +) + +func drawGlow(center: CGPoint, color: NSColor, radius: CGFloat) { + let glow = CGGradient( + colorsSpace: colorSpace, + colors: [color.cgColor, color.withAlphaComponent(0).cgColor] as CFArray, + locations: [0, 1] + )! + context.drawRadialGradient( + glow, + startCenter: center, + startRadius: 0, + endCenter: center, + endRadius: radius, + options: .drawsAfterEndLocation + ) +} + +drawGlow( + center: CGPoint(x: 166, y: 266), + color: NSColor(srgbRed: 0.34, green: 0.30, blue: 1, alpha: 0.10), + radius: 230 +) +drawGlow( + center: CGPoint(x: 523, y: 260), + color: NSColor(srgbRed: 0.22, green: 0.67, blue: 1, alpha: 0.075), + radius: 230 +) + +context.setStrokeColor(NSColor.black.withAlphaComponent(0.045).cgColor) +context.setLineWidth(1) +for x in stride(from: CGFloat(0), through: canvasSize.width, by: 40) { + context.move(to: CGPoint(x: x, y: 0)) + context.addLine(to: CGPoint(x: x, y: canvasSize.height)) +} +for y in stride(from: CGFloat(0), through: canvasSize.height, by: 40) { + context.move(to: CGPoint(x: 0, y: y)) + context.addLine(to: CGPoint(x: canvasSize.width, y: y)) +} +context.strokePath() + +func drawText( + _ text: String, + at point: CGPoint, + font: NSFont, + color: NSColor, + alignment: NSTextAlignment = .left +) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = alignment + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle, + ] + let string = NSString(string: text) + let size = string.size(withAttributes: attributes) + let x = alignment == .center ? point.x - size.width / 2 : point.x + let rect = CGRect(origin: CGPoint(x: x, y: point.y), size: size) + + context.saveGState() + context.translateBy(x: 0, y: rect.minY * 2 + rect.height) + context.scaleBy(x: 1, y: -1) + string.draw(in: rect, withAttributes: attributes) + context.restoreGState() +} + +let white = NSColor(srgbRed: 0.11, green: 0.12, blue: 0.17, alpha: 1) +let secondary = NSColor(srgbRed: 0.39, green: 0.41, blue: 0.49, alpha: 1) +let accent = NSColor(srgbRed: 0.51, green: 0.48, blue: 1, alpha: 1) + +context.setFillColor(accent.cgColor) +context.fillEllipse(in: CGRect(x: 40, y: 43, width: 10, height: 10)) +drawText( + "UTTER", + at: CGPoint(x: 60, y: 35), + font: .systemFont(ofSize: 20, weight: .bold), + color: white +) +drawText( + "macOS 26+ · Apple Silicon", + at: CGPoint(x: 462, y: 40), + font: .systemFont(ofSize: 12, weight: .medium), + color: secondary +) + +context.setStrokeColor(NSColor.black.withAlphaComponent(0.09).cgColor) +context.move(to: CGPoint(x: 40, y: 76)) +context.addLine(to: CGPoint(x: 640, y: 76)) +context.strokePath() + +drawText( + "Drag Utter to Applications", + at: CGPoint(x: canvasSize.width / 2, y: 101), + font: .systemFont(ofSize: 23, weight: .semibold), + color: white, + alignment: .center +) +drawText( + "Install in one step", + at: CGPoint(x: canvasSize.width / 2, y: 134), + font: .systemFont(ofSize: 13, weight: .medium), + color: secondary, + alignment: .center +) + +let arrowRect = CGRect(x: 289, y: 226, width: 102, height: 48) +let arrowBackground = CGPath( + roundedRect: arrowRect, + cornerWidth: 24, + cornerHeight: 24, + transform: nil +) +context.setFillColor(NSColor.black.withAlphaComponent(0.045).cgColor) +context.addPath(arrowBackground) +context.fillPath() +context.setStrokeColor(NSColor.black.withAlphaComponent(0.12).cgColor) +context.setLineWidth(1) +context.addPath(arrowBackground) +context.strokePath() + +context.setStrokeColor(white.withAlphaComponent(0.88).cgColor) +context.setLineCap(.round) +context.setLineJoin(.round) +context.setLineWidth(2.5) +context.move(to: CGPoint(x: 316, y: 250)) +context.addLine(to: CGPoint(x: 365, y: 250)) +context.move(to: CGPoint(x: 356, y: 241)) +context.addLine(to: CGPoint(x: 365, y: 250)) +context.addLine(to: CGPoint(x: 356, y: 259)) +context.strokePath() + +NSGraphicsContext.restoreGraphicsState() + +guard let data = bitmap.representation(using: .png, properties: [:]) else { + fputs("Could not encode the DMG background as PNG.\n", stderr) + exit(1) +} + +do { + try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: outputURL, options: .atomic) + print("DMG background -> \(outputURL.path)") +} catch { + fputs("Could not write the DMG background: \(error.localizedDescription)\n", stderr) + exit(1) +}