mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
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:
parent
c269811f0f
commit
93674001cd
5 changed files with 329 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -20,6 +20,9 @@ build/
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code auto-memory (user-local)
|
||||
.claude/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
|
|
|
|||
5
CrisisViz/.gitignore
vendored
5
CrisisViz/.gitignore
vendored
|
|
@ -4,3 +4,8 @@ Package.resolved
|
|||
*.xcodeproj
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
|
||||
# Build artifacts produced by bundle.sh
|
||||
CrisisViz.app/
|
||||
AppIcon.iconset/
|
||||
AppIcon.icns
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
216
CrisisViz/Tools/MakeAppIcon.swift
Normal file
216
CrisisViz/Tools/MakeAppIcon.swift
Normal 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 (top→bottom).
|
||||
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
85
CrisisViz/bundle.sh
Executable 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
|
||||
Loading…
Reference in a new issue