From 24124a35c6da149e70aa413707e9bc582a2ed385 Mon Sep 17 00:00:00 2001 From: Jayakrishna Konda Date: Wed, 21 Oct 2026 22:41:57 -0400 Subject: [PATCH 1/2] Ship a styled drag-to-install dmg, and build it on every PR The release dmg was a plain hdiutil image. package-dmg.sh now builds it with dmgbuild, which writes the Finder layout straight into the image: a window with the app on the left, Applications on the right, a hidden .app extension and the app icon as the disk icon. Support/makedmgbackground.swift draws the window background at 1x and 2x in the icon's palette, so nothing binary is committed: haze on the left clearing to the cream of the focused window, a warm glow behind Applications, and an arrow whose haze particles come into focus on the way there. release.yml uses the script, and the CI macos job runs it as a dry run on every PR, like the Windows installer. dmgbuild and its dependencies are pinned and run from a venv that is rebuilt each time. --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 7 +- .gitignore | 1 + macos/README.md | 8 +- macos/Support/makedmgbackground.swift | 141 ++++++++++++++++++++++++++ macos/dmg-settings.py | 26 +++++ macos/package-dmg.sh | 45 ++++++++ 7 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 macos/Support/makedmgbackground.swift create mode 100644 macos/dmg-settings.py create mode 100755 macos/package-dmg.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb52f23..1d4290b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: run: | cd macos ./build.sh + - name: Package .dmg (dry run, not published) + working-directory: macos + run: ./package-dmg.sh 0.0.0-ci windows: needs: changes if: needs.changes.outputs.code == 'true' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10a5628..a987417 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,12 +37,7 @@ jobs: - name: Package .dmg working-directory: macos - run: | - mkdir -p dist - cp -R build/OpenHaze.app dist/OpenHaze.app - ln -s /Applications dist/Applications - hdiutil create -volname "OpenHaze" -srcfolder dist -ov -format UDZO \ - "OpenHaze-${{ needs.version.outputs.version }}-macOS.dmg" + run: ./package-dmg.sh "${{ needs.version.outputs.version }}" - uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 0c782b0..c49b3a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # macOS build outputs macos/build/ macos/.build/ +macos/*.dmg # Windows build outputs windows/OpenHaze.exe windows/*.pdb diff --git a/macos/README.md b/macos/README.md index a4972d6..fbf0bb3 100644 --- a/macos/README.md +++ b/macos/README.md @@ -40,6 +40,11 @@ That compiles with `swiftc` (plain Command Line Tools are enough — SwiftPM/Xco not required), assembles `build/OpenHaze.app`, ad-hoc signs it, copies it to `~/Applications`, and launches it. +To package `build/OpenHaze.app` into the drag-to-install disk image, run +`./package-dmg.sh 0.2.1` after `./build.sh` (it needs Python 3.10 or newer for +dmgbuild). The version only names the file; releases stamp the app's own +version from the tag. + ## First run OpenHaze starts working immediately. It will offer to enable **Accessibility** @@ -112,8 +117,9 @@ Sources/OpenHaze/ HotkeyManager.swift Carbon global hotkeys OnboardingWindow.swift first-run / permission flow StateBox.swift @State replacement (CLT lacks SwiftUI macros) -Support/ Info.plist, sdef, icon generator +Support/ Info.plist, sdef, icon and dmg background generators build.sh build + bundle + sign (+ --install --run) +package-dmg.sh drag-to-install .dmg, laid out by dmg-settings.py ``` ## Known limitations diff --git a/macos/Support/makedmgbackground.swift b/macos/Support/makedmgbackground.swift new file mode 100644 index 0000000..ed37b98 --- /dev/null +++ b/macos/Support/makedmgbackground.swift @@ -0,0 +1,141 @@ +// Generates the disk image window background in the icon's palette: haze that +// clears from left to right, a warm glow behind Applications, and an arrow whose +// haze particles condense into a solid stroke on the way there. +// Usage: makedmgbackground /path/to/background.png +// (also writes background@2x.png beside it; dmgbuild pairs the two) +import AppKit + +let outputPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "dmg-background.png" + +// Points, top-left origin like Finder. dmg-settings.py uses the same window size +// and icon centers; the arrow and the glow are placed relative to the centers. +let size = CGSize(width: 640, height: 360) +let appCenter = CGPoint(x: 160, y: 160) +let applicationsCenter = CGPoint(x: 480, y: 160) + +// ink, cream and amber are the plate navy, the focused window and the glow from +// makeicon.swift; haze is a light grey-lavender for the dimmed side. +let ink = NSColor(srgbRed: 0.16, green: 0.18, blue: 0.26, alpha: 1) +let haze = NSColor(srgbRed: 0.89, green: 0.89, blue: 0.92, alpha: 1) +let cream = NSColor(srgbRed: 0.99, green: 0.96, blue: 0.90, alpha: 1) +let amber = NSColor(srgbRed: 1.0, green: 0.78, blue: 0.35, alpha: 1) + +// The arrow is one cubic curve that arcs up between the two 128 pt icons, +// starting and ending 22 pt clear of them. +let start = CGPoint(x: appCenter.x + 86, y: appCenter.y + 10) +let control1 = CGPoint(x: appCenter.x + 128, y: appCenter.y - 36) +let control2 = CGPoint(x: applicationsCenter.x - 128, y: applicationsCenter.y - 36) +let end = CGPoint(x: applicationsCenter.x - 86, y: applicationsCenter.y + 2) + +func point(at t: CGFloat) -> CGPoint { + let u = 1 - t + let a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t + return CGPoint(x: a * start.x + b * control1.x + c * control2.x + d * end.x, + y: a * start.y + b * control1.y + c * control2.y + d * end.y) +} + +func tangent(at t: CGFloat) -> CGVector { + let u = 1 - t + let dx = 3 * u * u * (control1.x - start.x) + 6 * u * t * (control2.x - control1.x) + + 3 * t * t * (end.x - control2.x) + let dy = 3 * u * u * (control1.y - start.y) + 6 * u * t * (control2.y - control1.y) + + 3 * t * t * (end.y - control2.y) + let length = max(hypot(dx, dy), 0.001) + return CGVector(dx: dx / length, dy: dy / length) +} + +func mix(_ a: NSColor, _ b: NSColor, _ t: CGFloat) -> NSColor { + a.blended(withFraction: min(max(t, 0), 1), of: b) ?? b +} + +func draw() { + // Haze on the left clearing to the focused window's cream on the right. + NSGradient(colorsAndLocations: (haze, 0.0), (haze, 0.12), (cream, 0.68), (cream, 1.0))? + .draw(in: NSRect(origin: .zero, size: size), angle: 0) + + // Warm glow behind Applications, like the one behind the icon's front window. + NSGradient(colors: [amber.withAlphaComponent(0.3), amber.withAlphaComponent(0)])? + .draw(fromCenter: applicationsCenter, radius: 0, toCenter: applicationsCenter, + radius: 118, options: []) + + // Haze particles come into focus along the first half of the curve: soft, + // faint and scattered at the start, then crisp, dark and lined up. + let particles = 16 + for i in 0.. Data? { + guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: Int(size.width * scale), + pixelsHigh: Int(size.height * scale), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0), + let bitmap = NSGraphicsContext(bitmapImageRep: rep) else { return nil } + let cg = bitmap.cgContext + cg.scaleBy(x: scale, y: scale) + cg.translateBy(x: 0, y: size.height) + cg.scaleBy(x: 1, y: -1) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: cg, flipped: true) + draw() + NSGraphicsContext.restoreGraphicsState() + rep.size = size // records 144 dpi on the @2x image + return rep.representation(using: .png, properties: [:]) +} + +let output = URL(fileURLWithPath: outputPath) +let retina = output.deletingPathExtension().path + "@2x.png" +for (scale, path) in [(CGFloat(1), output.path), (2, retina)] { + guard let data = pngData(scale: scale) else { fatalError("render \(path)") } + try data.write(to: URL(fileURLWithPath: path)) + print("Wrote \(path)") +} diff --git a/macos/dmg-settings.py b/macos/dmg-settings.py new file mode 100644 index 0000000..81d8dba --- /dev/null +++ b/macos/dmg-settings.py @@ -0,0 +1,26 @@ +# dmgbuild settings for the OpenHaze disk image, used by package-dmg.sh. +# Opening the image shows the app on the left and an Applications shortcut on +# the right, over the background Support/makedmgbackground.swift draws. +# Paths are relative to macos/, where package-dmg.sh runs. The window size and +# icon positions match the layout constants in makedmgbackground.swift. + +application = "build/OpenHaze.app" + +format = "UDZO" +files = [application] +symlinks = {"Applications": "/Applications"} +hide_extensions = ["OpenHaze.app"] +icon = "Support/AppIcon.icns" + +# dmgbuild pairs this with dmg-background@2x.png for Retina displays. +background = "build/dmg-background.png" +window_rect = ((100, 100), (640, 360)) +default_view = "icon-view" +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +icon_size = 128 +text_size = 13 +icon_locations = {"OpenHaze.app": (160, 160), "Applications": (480, 160)} diff --git a/macos/package-dmg.sh b/macos/package-dmg.sh new file mode 100755 index 0000000..78e518c --- /dev/null +++ b/macos/package-dmg.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Package build/OpenHaze.app into the drag-to-install disk image. +# ./package-dmg.sh 0.2.1 writes OpenHaze-0.2.1-macOS.dmg +# The version only names the file. Releases stamp the app's own version into +# Info.plist from the tag before building. Run ./build.sh first. +set -euo pipefail +cd "$(dirname "$0")" + +VERSION="${1:-}" +if [ -z "$VERSION" ]; then + echo "Usage: ./package-dmg.sh VERSION (for example ./package-dmg.sh 0.2.1)" >&2 + exit 1 +fi +if [ ! -d build/OpenHaze.app ]; then + echo "build/OpenHaze.app not found. Run ./build.sh first." >&2 + exit 1 +fi +# dmgbuild needs Python 3.10 or newer; the Command Line Tools ship 3.9. +if ! python3 -c 'import sys; sys.exit(sys.version_info < (3, 10))' 2>/dev/null; then + echo "package-dmg.sh needs Python 3.10 or newer for dmgbuild, found: $(python3 --version 2>&1)" >&2 + echo "Install one with: brew install python" >&2 + exit 1 +fi + +echo "▸ Drawing the window background…" +swiftc -module-cache-path build/module-cache -o build/makedmgbackground Support/makedmgbackground.swift +build/makedmgbackground build/dmg-background.png + +# dmgbuild writes the Finder layout (window size, icon positions, background) +# straight into the image's .DS_Store, so it never has to script Finder, which +# can stall on CI. It runs from a venv under build/ that is recreated each time, +# so a moved checkout or an upgraded Python cannot leave it broken, and the +# system Python is never touched. Its dependencies are pinned too, so release +# images do not change when a new version appears on PyPI. +echo "▸ Installing dmgbuild…" +python3 -m venv --clear build/dmgenv +build/dmgenv/bin/python -m pip install --quiet --disable-pip-version-check \ + dmgbuild==1.6.7 ds_store==1.3.3 mac_alias==2.2.3 + +DMG="OpenHaze-$VERSION-macOS.dmg" +echo "▸ Packaging $DMG…" +rm -f "$DMG" +build/dmgenv/bin/python -m dmgbuild -s dmg-settings.py "OpenHaze" "$DMG" + +echo "✓ Done: $DMG" From 47667d1e792b569e5ad526f9a26fc9a4d26319a0 Mon Sep 17 00:00:00 2001 From: Jayakrishna Konda Date: Wed, 21 Oct 2026 22:53:27 -0400 Subject: [PATCH 2/2] Fix the dmg dry run under the macOS runner's locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's macos job failed with "DMG: unbound variable". Apple's bash 3.2 runs in a Latin-1 locale on the hosted runner, where the first byte of the "…" after $DMG reads as a letter and becomes part of the variable name. ${DMG} ends the name explicitly. --- macos/package-dmg.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/package-dmg.sh b/macos/package-dmg.sh index 78e518c..d94402c 100755 --- a/macos/package-dmg.sh +++ b/macos/package-dmg.sh @@ -38,7 +38,7 @@ build/dmgenv/bin/python -m pip install --quiet --disable-pip-version-check \ dmgbuild==1.6.7 ds_store==1.3.3 mac_alias==2.2.3 DMG="OpenHaze-$VERSION-macOS.dmg" -echo "▸ Packaging $DMG…" +echo "▸ Packaging ${DMG}…" rm -f "$DMG" build/dmgenv/bin/python -m dmgbuild -s dmg-settings.py "OpenHaze" "$DMG"