Add macOS .app bundle with native Dock icon and activation policy

- bundle.sh assembles CrisisViz.app (Info.plist + AppIcon.icns + resources)
  and ad-hoc codesigns it so it launches as a proper Foreground app.
- Tools/MakeAppIcon.swift renders the app icon programmatically (10 sizes,
  16-1024 px) as a 3-round mini-DAG matching the live node palette.
- CrisisApp.swift forces .regular activation policy via NSApplicationDelegate
  so the Dock tile and menu bar appear even when launched unbundled
  via `swift run CrisisViz`.
- Ignore build artifacts (.build, AppIcon.iconset, CrisisViz.app) and the
  user-local .claude/ auto-memory directory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-04-30 20:21:18 +02:00
parent c269811f0f
commit 93674001cd
5 changed files with 329 additions and 1 deletions

3
.gitignore vendored
View file

@ -20,6 +20,9 @@ build/
.DS_Store
Thumbs.db
# Claude Code auto-memory (user-local)
.claude/
# Testing
.pytest_cache/
.coverage

View file

@ -4,3 +4,8 @@ Package.resolved
*.xcodeproj
DerivedData/
xcuserdata/
# Build artifacts produced by bundle.sh
CrisisViz.app/
AppIcon.iconset/
AppIcon.icns

View file

@ -1,12 +1,31 @@
import SwiftUI
import AppKit
/// AppDelegate exists for one reason: when CrisisViz is launched unbundled
/// (e.g. via `swift run CrisisViz` during development) macOS defaults the
/// activation policy to `.accessory`, which means no Dock tile, no menu-bar
/// presence, and no Cmd-Tab visibility. Forcing `.regular` here makes the
/// running app behave like a real native app even without a `.app` bundle.
final class CrisisAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
}
@main
struct CrisisApp: App {
@NSApplicationDelegateAdaptor(CrisisAppDelegate.self) private var appDelegate
@State private var captureRequested = CommandLine.arguments.contains("--capture")
|| CommandLine.arguments.contains("--testbed")
var body: some Scene {
WindowGroup {
WindowGroup("CrisisViz") {
ImmersiveView()
.task {
if captureRequested {

View file

@ -0,0 +1,216 @@
#!/usr/bin/env swift
// Renders a CrisisViz app icon at every macOS-required size and writes them
// into ./AppIcon.iconset, ready for `iconutil -c icns AppIcon.iconset`.
//
// Run with: swift Tools/MakeAppIcon.swift
// Or via the bundle.sh script (which then calls iconutil for you).
import Cocoa
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
let outDir = "AppIcon.iconset"
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
// (filename suffix, pixel size) Apple's required iconset contents.
let entries: [(name: String, px: Int)] = [
("16x16", 16),
("16x16@2x", 32),
("32x32", 32),
("32x32@2x", 64),
("128x128", 128),
("128x128@2x", 256),
("256x256", 256),
("256x256@2x", 512),
("512x512", 512),
("512x512@2x", 1024),
]
// Palette matches the live app's node colours.
let palette: [CGColor] = [
CGColor(red: 0.30, green: 0.69, blue: 0.94, alpha: 1.0), // cyan
CGColor(red: 0.35, green: 0.85, blue: 0.55, alpha: 1.0), // green
CGColor(red: 0.95, green: 0.60, blue: 0.20, alpha: 1.0), // orange
CGColor(red: 0.80, green: 0.40, blue: 0.90, alpha: 1.0), // purple
CGColor(red: 0.95, green: 0.45, blue: 0.45, alpha: 1.0), // pink
CGColor(red: 0.90, green: 0.75, blue: 0.30, alpha: 1.0), // gold
]
func renderIcon(px: Int) -> CGImage {
let cs = CGColorSpaceCreateDeviceRGB()
let info = CGImageAlphaInfo.premultipliedLast.rawValue
guard let ctx = CGContext(
data: nil, width: px, height: px,
bitsPerComponent: 8, bytesPerRow: px * 4,
space: cs, bitmapInfo: info
) else { fatalError("CGContext failed") }
let s = CGFloat(px)
// macOS Big-Sur-and-later icon mask: rounded square, ~22.5% corner radius
let inset = s * 0.10
let body = CGRect(x: inset, y: inset, width: s - 2 * inset, height: s - 2 * inset)
let radius: CGFloat = body.width * 0.225
let mask = CGPath(roundedRect: body, cornerWidth: radius, cornerHeight: radius, transform: nil)
ctx.saveGState()
ctx.addPath(mask)
ctx.clip()
// Background dark indigo gradient (topbottom).
let bgColors = [
CGColor(red: 0.06, green: 0.08, blue: 0.16, alpha: 1.0),
CGColor(red: 0.02, green: 0.03, blue: 0.08, alpha: 1.0),
]
if let grad = CGGradient(
colorsSpace: cs, colors: bgColors as CFArray,
locations: [0, 1]
) {
ctx.drawLinearGradient(
grad,
start: CGPoint(x: 0, y: body.maxY),
end: CGPoint(x: 0, y: body.minY),
options: []
)
}
// Subtle round-separator lines (echo of the chapters' "round zones").
ctx.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.05))
ctx.setLineWidth(max(0.5, s * 0.003))
let sep = body.width / 4
for i in 1..<4 {
let x = body.minX + sep * CGFloat(i)
ctx.move(to: CGPoint(x: x, y: body.minY + sep * 0.4))
ctx.addLine(to: CGPoint(x: x, y: body.maxY - sep * 0.4))
}
ctx.strokePath()
// Layout: a 3-round mini-DAG. Three columns of nodes with edges between them.
let cx = body.midX
let cy = body.midY
let span = body.width * 0.62
let colDx = span / 2
let nodeR = max(s * 0.045, 2.0)
let edgeWidth = max(s * 0.012, 1.0)
struct Node {
let pos: CGPoint
let color: CGColor
let radiusScale: CGFloat
}
// Three columns: left (round 0, 2 nodes), middle (round 1, 3 nodes), right (round 2, 2 nodes).
let leftX = cx - colDx
let midX = cx
let rightX = cx + colDx
let yStep = body.height * 0.18
let nodes: [Node] = [
Node(pos: CGPoint(x: leftX, y: cy + yStep * 0.7), color: palette[0], radiusScale: 1.0),
Node(pos: CGPoint(x: leftX, y: cy - yStep * 0.7), color: palette[3], radiusScale: 1.0),
Node(pos: CGPoint(x: midX, y: cy + yStep), color: palette[1], radiusScale: 1.05),
Node(pos: CGPoint(x: midX, y: cy), color: palette[5], radiusScale: 1.4), // emphasized centre
Node(pos: CGPoint(x: midX, y: cy - yStep), color: palette[4], radiusScale: 1.05),
Node(pos: CGPoint(x: rightX, y: cy + yStep * 0.7), color: palette[2], radiusScale: 1.0),
Node(pos: CGPoint(x: rightX, y: cy - yStep * 0.7), color: palette[0], radiusScale: 1.0),
]
// Edges (parent references): each later-column node points to a couple of earlier-column nodes.
let edges: [(Int, Int)] = [
(2, 0), (2, 1),
(3, 0), (3, 1),
(4, 0), (4, 1),
(5, 2), (5, 3),
(6, 3), (6, 4),
]
ctx.setLineCap(.round)
ctx.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.55))
ctx.setLineWidth(edgeWidth)
for (a, b) in edges {
ctx.move(to: nodes[a].pos)
ctx.addLine(to: nodes[b].pos)
}
ctx.strokePath()
// Glow pass under each node.
for n in nodes {
let r = nodeR * n.radiusScale * 2.2
let rect = CGRect(x: n.pos.x - r, y: n.pos.y - r, width: r * 2, height: r * 2)
ctx.saveGState()
ctx.setShadow(offset: .zero, blur: r * 1.4, color: n.color)
ctx.setFillColor(n.color)
ctx.setAlpha(0.28)
ctx.fillEllipse(in: rect)
ctx.restoreGState()
}
// Filled nodes.
for n in nodes {
let r = nodeR * n.radiusScale
let rect = CGRect(x: n.pos.x - r, y: n.pos.y - r, width: r * 2, height: r * 2)
ctx.setFillColor(n.color)
ctx.fillEllipse(in: rect)
// Inner highlight
ctx.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.45))
let hr = r * 0.4
let highlight = CGRect(x: n.pos.x - hr * 0.6, y: n.pos.y + r * 0.2 - hr * 0.6, width: hr, height: hr)
ctx.fillEllipse(in: highlight)
}
// Centre vertex emphasis ring.
let centre = nodes[3]
let cr = nodeR * centre.radiusScale + s * 0.012
let centerRect = CGRect(x: centre.pos.x - cr, y: centre.pos.y - cr, width: cr * 2, height: cr * 2)
ctx.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.85))
ctx.setLineWidth(max(s * 0.006, 0.8))
ctx.strokeEllipse(in: centerRect)
// Wordmark "C" only at large sizes (bottom-right corner).
if px >= 128 {
let label = "Δ" // suggestive of consensus / change
let fontSize = s * 0.16
let font = NSFont(name: "Menlo-Bold", size: fontSize) ?? NSFont.systemFont(ofSize: fontSize, weight: .heavy)
let para = NSMutableParagraphStyle()
para.alignment = .center
let attrs: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: NSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.55),
.paragraphStyle: para,
]
let str = NSAttributedString(string: label, attributes: attrs)
let line = CTLineCreateWithAttributedString(str)
let bounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
let dx = body.maxX - bounds.width - s * 0.06
let dy = body.minY + s * 0.06
ctx.textPosition = CGPoint(x: dx, y: dy)
CTLineDraw(line, ctx)
}
ctx.restoreGState()
return ctx.makeImage()!
}
func writePNG(_ image: CGImage, to path: String) {
let url = URL(fileURLWithPath: path)
guard let dest = CGImageDestinationCreateWithURL(
url as CFURL,
UTType.png.identifier as CFString,
1, nil
) else {
fputs("✘ Failed to create dest for \(path)\n", stderr); exit(1)
}
CGImageDestinationAddImage(dest, image, nil)
CGImageDestinationFinalize(dest)
}
for entry in entries {
let img = renderIcon(px: entry.px)
let path = "\(outDir)/icon_\(entry.name).png"
writePNG(img, to: path)
print("\(path) (\(entry.px)×\(entry.px))")
}
print("Done. Now: iconutil -c icns \(outDir) -o AppIcon.icns")

85
CrisisViz/bundle.sh Executable file
View file

@ -0,0 +1,85 @@
#!/bin/bash
# Build CrisisViz.app — a proper macOS application bundle.
#
# ./bundle.sh build, package, and launch via `open`
# ./bundle.sh --no-launch build only
#
# The bundle has a real Info.plist + AppIcon.icns + crisis_data.json, so when
# launched with `open CrisisViz.app` it appears in the Dock with its icon and
# behaves like a native macOS application.
set -euo pipefail
cd "$(dirname "$0")"
APP=CrisisViz.app
BIN_NAME=CrisisViz
BUNDLE_ID=org.crisis.CrisisViz
VERSION=1.0
BUILD=1
LAUNCH=1
for arg in "$@"; do
case "$arg" in
--no-launch) LAUNCH=0 ;;
esac
done
echo "▸ Generating AppIcon.icns…"
swift Tools/MakeAppIcon.swift > /dev/null
iconutil -c icns AppIcon.iconset -o AppIcon.icns
echo "▸ Building release binary…"
swift build -c release
echo "▸ Assembling ${APP}"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS"
mkdir -p "$APP/Contents/Resources"
cp ".build/release/$BIN_NAME" "$APP/Contents/MacOS/$BIN_NAME"
cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
cp Sources/CrisisViz/crisis_data.json "$APP/Contents/Resources/crisis_data.json"
# Copy any other Bundle.module resources that SwiftPM placed alongside the binary
# so they are still findable from Bundle.main when running from the .app.
if [ -d ".build/release/${BIN_NAME}_${BIN_NAME}.bundle" ]; then
cp -R ".build/release/${BIN_NAME}_${BIN_NAME}.bundle" "$APP/Contents/Resources/"
fi
cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key> <string>en</string>
<key>CFBundleExecutable</key> <string>${BIN_NAME}</string>
<key>CFBundleIconFile</key> <string>AppIcon</string>
<key>CFBundleIdentifier</key> <string>${BUNDLE_ID}</string>
<key>CFBundleInfoDictionaryVersion</key> <string>6.0</string>
<key>CFBundleName</key> <string>CrisisViz</string>
<key>CFBundleDisplayName</key> <string>CrisisViz</string>
<key>CFBundlePackageType</key> <string>APPL</string>
<key>CFBundleShortVersionString</key> <string>${VERSION}</string>
<key>CFBundleVersion</key> <string>${BUILD}</string>
<key>LSMinimumSystemVersion</key> <string>14.0</string>
<key>LSApplicationCategoryType</key> <string>public.app-category.education</string>
<key>NSPrincipalClass</key> <string>NSApplication</string>
<key>NSHighResolutionCapable</key> <true/>
<key>NSSupportsAutomaticTermination</key> <true/>
<key>NSSupportsSuddenTermination</key> <true/>
</dict>
</plist>
PLIST
# Ad-hoc codesign so macOS treats it as a signed app bundle.
codesign --force --deep --sign - "$APP" 2>/dev/null || true
# Refresh the icon cache so Finder & Dock pick up the new icon immediately.
touch "$APP"
echo "✓ Built $APP"
if [ "$LAUNCH" -eq 1 ]; then
echo "▸ Launching ${APP}"
open "$APP"
fi