Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .github/scripts/dmg-background.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env swift
import AppKit
import Foundation

// Draw the Grimoire disk image background. Finder places the app icon and
// the Applications shortcut over the lower half, so the wide logo stays above them.
// Usage: dmg-background.swift <logo.png> <1x.png> <2x.png> <width> <height> <app-x> <applications-x>

let args = CommandLine.arguments
guard args.count == 8 else {
fputs("usage: dmg-background.swift <logo.png> <1x.png> <2x.png> <width> <height> <app-x> <applications-x>\n", stderr)
exit(1)
}

let logoURL = URL(fileURLWithPath: args[1])
let out1x = args[2]
let out2x = args[3]
guard let width = Int(args[4]), let height = Int(args[5]),
let appX = Double(args[6]), let applicationsX = Double(args[7]),
width > 0, height > 0 else {
fputs("width, height, and icon positions must be numbers\n", stderr)
exit(1)
}
guard let source = NSImage(contentsOf: logoURL), source.size.width > 0, source.size.height > 0 else {
fputs("could not read logo \(logoURL.path)\n", stderr)
exit(1)
}

func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> NSColor {
let red = CGFloat((hex >> 16) & 0xFF) / 255
let green = CGFloat((hex >> 8) & 0xFF) / 255
let blue = CGFloat(hex & 0xFF) / 255
return NSColor(srgbRed: red, green: green, blue: blue, alpha: alpha)
}

// Quick Look pads a wide SVG into a square with white. Drop that padding so the
// logo keeps its own aspect ratio.
func trimmedLogo(_ image: NSImage) -> NSImage {
guard let rep = NSBitmapImageRep(data: image.tiffRepresentation ?? Data()) else {
return image
}
let pixelsWide = rep.pixelsWide
let pixelsHigh = rep.pixelsHigh

func isPadding(_ x: Int, _ y: Int) -> Bool {
guard let sample = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else {
return true
}
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
sample.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return alpha < 0.05 || (red > 0.97 && green > 0.97 && blue > 0.97)
}

var minX = pixelsWide
var minY = pixelsHigh
var maxX = 0
var maxY = 0
for y in 0..<pixelsHigh {
for x in 0..<pixelsWide where !isPadding(x, y) {
minX = min(minX, x)
minY = min(minY, y)
maxX = max(maxX, x)
maxY = max(maxY, y)
}
}
guard maxX > minX + 8, maxY > minY + 8 else {
return image
}

// Drop the anti-aliased edge left where the logo's background met the
// white padding Quick Look adds around a wide SVG.
let crop = NSRect(x: minX + 3, y: minY + 3, width: maxX - minX - 5, height: maxY - minY - 5)
guard let cropped = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(crop.width),
pixelsHigh: Int(crop.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
) else {
return image
}
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: cropped)
image.draw(
in: NSRect(x: 0, y: 0, width: crop.width, height: crop.height),
from: crop,
operation: .copy,
fraction: 1
)
NSGraphicsContext.restoreGraphicsState()

let result = NSImage(size: NSSize(width: crop.width, height: crop.height))
result.addRepresentation(cropped)
return result
}

let logo = trimmedLogo(source)

func render(scale: Int, destination: String) {
let pixelsWide = width * scale
let pixelsHigh = height * scale
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixelsWide,
pixelsHigh: pixelsHigh,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
) else {
fputs("could not create background bitmap\n", stderr)
exit(1)
}

NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
let scale = CGFloat(scale)
let canvas = NSRect(x: 0, y: 0, width: CGFloat(width) * scale, height: CGFloat(height) * scale)

color(0x1A1714).setFill()
NSBezierPath(rect: canvas).fill()

let logoWidth = canvas.width * 0.86
let logoHeight = logoWidth * (logo.size.height / logo.size.width)
let logoRect = NSRect(
x: (canvas.width - logoWidth) / 2,
y: canvas.height - logoHeight - 28 * scale,
width: logoWidth,
height: logoHeight
)
logo.draw(in: logoRect, from: .zero, operation: .sourceOver, fraction: 1)

// Finder draws icon names in black on a picture background, and it will not
// keep a custom label color. A light chip under each name keeps that text readable.
func labelChip(centerX: CGFloat, chipWidth: CGFloat) {
let chipHeight = 32 * scale
let rect = NSRect(
x: centerX * scale - chipWidth * scale / 2,
y: 48 * scale,
width: chipWidth * scale,
height: chipHeight
)
color(0xEFE6D8).setFill()
NSBezierPath(roundedRect: rect, xRadius: 8 * scale, yRadius: 8 * scale).fill()
}
labelChip(centerX: CGFloat(appX), chipWidth: 112)
labelChip(centerX: CGFloat(applicationsX), chipWidth: 132)

NSGraphicsContext.restoreGraphicsState()

guard let png = rep.representation(using: .png, properties: [:]) else {
fputs("could not encode background\n", stderr)
exit(1)
}
do {
try png.write(to: URL(fileURLWithPath: destination))
} catch {
fputs("could not write \(destination): \(error)\n", stderr)
exit(1)
}
}

render(scale: 1, destination: out1x)
render(scale: 2, destination: out2x)
120 changes: 114 additions & 6 deletions .github/scripts/package-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ fi
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/grimoire-app.XXXXXX")"
KEYCHAIN_PATH=""
PREVIOUS_KEYCHAINS=""
MOUNTED=""

cleanup() {
if [[ -n "${MOUNTED}" ]]; then
hdiutil detach "${MOUNTED}" >/dev/null 2>&1 || hdiutil detach -force "${MOUNTED}" >/dev/null 2>&1 || true
fi
if [[ -n "${KEYCHAIN_PATH}" ]]; then
security delete-keychain "${KEYCHAIN_PATH}" >/dev/null 2>&1 || true
fi
Expand Down Expand Up @@ -115,20 +119,124 @@ if [[ -n "${IDENTITY}" ]]; then
codesign --force --options runtime --timestamp --sign "${IDENTITY}" "${APP}"
fi

# Window matches the background image. Icon positions are centers:
# Grimoire.app on the left, Applications on the right.
DMG_WIDTH=680
DMG_HEIGHT=480
DMG_ICON_SIZE=128
DMG_APP_X=180
DMG_APP_Y=330
DMG_APPLICATIONS_X=500
DMG_APPLICATIONS_Y=330

write_styled_dmg() {
local root="$1"
local dest="$2"
local rw="${STAGE}/grimoire-rw.dmg"
local logo_dir="${STAGE}/logo"
local logo="${logo_dir}/grimoire_logo-wide-dark.svg.png"
local volname

mkdir -p "${logo_dir}"
# Quick Look renders the wide SVG. The background script crops the square padding.
qlmanage -t -s 3200 -o "${logo_dir}" "${ROOT}/static/grimoire_logo-wide-dark.svg" >/dev/null
if [[ ! -f "${logo}" ]]; then
echo "Could not render static/grimoire_logo-wide-dark.svg" >&2
exit 1
fi

hdiutil create -size 240m -fs HFS+ -volname Grimoire -ov "${rw}" >/dev/null
# An open Grimoire disk takes /Volumes/Grimoire. The new image would then
# mount at "/Volumes/Grimoire 1", and Finder would style the wrong disk.
if [[ -d /Volumes/Grimoire ]]; then
echo "Ejecting the open Grimoire disk so the new image can use that name."
hdiutil detach "/Volumes/Grimoire" >/dev/null 2>&1 \
|| hdiutil detach -force "/Volumes/Grimoire" >/dev/null 2>&1 \
|| true
fi
# Mount under /Volumes so Finder sees the volume name. A private mountpoint
# shows up under the folder name instead, and the window styling cannot find it.
local attach_plist="${STAGE}/attach.plist"
hdiutil attach "${rw}" -noverify -noautoopen -plist > "${attach_plist}"
MOUNTED="$(python3 -c '
import plistlib, sys
info = plistlib.load(open(sys.argv[1], "rb"))
for entity in info.get("system-entities", []):
point = entity.get("mount-point")
if point:
print(point)
raise SystemExit
raise SystemExit("mounted image has no mount point")
' "${attach_plist}")"
if [[ "${MOUNTED}" != "/Volumes/Grimoire" ]]; then
echo "The disk image mounted at ${MOUNTED} instead of /Volumes/Grimoire." >&2
echo "Eject every open Grimoire disk, then package again." >&2
exit 1
fi
volname="Grimoire"
cp -R "${root}/." "${MOUNTED}/"
mkdir -p "${MOUNTED}/.background"
swift "${ROOT}/.github/scripts/dmg-background.swift" \
"${logo}" \
"${MOUNTED}/.background/background.png" \
"${MOUNTED}/.background/background@2x.png" \
"${DMG_WIDTH}" \
"${DMG_HEIGHT}" \
"${DMG_APP_X}" \
"${DMG_APPLICATIONS_X}"

osascript <<EOF
tell application "Finder"
tell disk "${volname}"
open
delay 1
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {200, 120, 200 + ${DMG_WIDTH}, 120 + ${DMG_HEIGHT}}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to ${DMG_ICON_SIZE}
set text size of viewOptions to 13
set background picture of viewOptions to file ".background:background.png"
delay 1
set position of item "Grimoire.app" of container window to {${DMG_APP_X}, ${DMG_APP_Y}}
set position of item "Applications" of container window to {${DMG_APPLICATIONS_X}, ${DMG_APPLICATIONS_Y}}
close
open
delay 1
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {200, 120, 200 + ${DMG_WIDTH}, 120 + ${DMG_HEIGHT}}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to ${DMG_ICON_SIZE}
set background picture of viewOptions to file ".background:background.png"
set position of item "Grimoire.app" of container window to {${DMG_APP_X}, ${DMG_APP_Y}}
set position of item "Applications" of container window to {${DMG_APPLICATIONS_X}, ${DMG_APPLICATIONS_Y}}
update without registering applications
delay 2
close
end tell
end tell
EOF

sync
hdiutil detach "${MOUNTED}" >/dev/null
MOUNTED=""
hdiutil convert "${rw}" -format UDZO -imagekey zlib-level=9 -o "${dest}" >/dev/null
}

DMG_ROOT="${STAGE}/dmg"
mkdir -p "${DMG_ROOT}"
cp -R "${APP}" "${DMG_ROOT}/"
ln -s /Applications "${DMG_ROOT}/Applications"
if [[ -z "${IDENTITY}" ]]; then
cp "${ROOT}/app/macos/Install Grimoire.command" "${DMG_ROOT}/Install Grimoire.command"
chmod +x "${DMG_ROOT}/Install Grimoire.command"
cp "${ROOT}/app/macos/How to install.txt" "${DMG_ROOT}/How to install.txt"
fi

mkdir -p "${OUT}"
DMG="${OUT}/Grimoire_${VERSION}_${ARCH}.dmg"
rm -f "${DMG}"
hdiutil create -volname Grimoire -srcfolder "${DMG_ROOT}" -ov -format UDZO "${DMG}" >/dev/null
write_styled_dmg "${DMG_ROOT}" "${DMG}"

if [[ -n "${IDENTITY}" ]]; then
codesign --force --timestamp --sign "${IDENTITY}" "${DMG}"
Expand Down
4 changes: 2 additions & 2 deletions app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",

[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSImage"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["NSData"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSImage", "NSResponder", "NSSound"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["NSArray", "NSBundle", "NSData", "NSDate", "NSObject", "NSString", "NSURL"] }

[profile.release]
codegen-units = 1
Expand Down
18 changes: 0 additions & 18 deletions app/macos/How to install.txt

This file was deleted.

Loading
Loading