Replaces the original shannoncoat.sh entirely with in-process Swift. The script stopped earning its keep once the rest went native, and being a persistent process rather than a one-shot CLI is what later makes live window tracking possible at all. - ProfileStore: JSON config at ~/.shannoncoat/<name>.json (was hand-rolled YAML), with ~ expansion, paths relative to the config dir, and name/dir-collision validation in one place. - ProcessInspector: sysctl(KERN_PROC_ALL/KERN_PROCARGS2) enumeration instead of shelling out to pgrep. - ClaudeControl: launch via Process, focus via direct Accessibility calls (unhide, un-minimize, poll-for-window, AXRaise) instead of AppleScript, and a quit that respects Claude's own termination handling rather than unconditionally SIGKILLing after a flat 5s timeout. - LiveState: NSWorkspace notification-driven state with no polling timer, so the menu bar reflects reality within about a second — including changes made outside the app entirely. - ManageWindow: one non-modal window replacing what would otherwise be a string of separate popup alerts, with inline add/remove and directory pickers. - LaunchAtLogin / UpdateChecker: SMAppService login-item toggle, and a minimal VERSION-file update check that alerts on a manual check and posts a quiet notification on the automatic one. Also drops the standalone CLI in favour of GUI-only, and adds a placeholder app icon drawn from vector shapes rather than composited on top of Claude's own icon assets. Claude Desktop is located at runtime rather than assumed: a path the user picked previously, then /Applications, then a per-user ~/Applications install, each accepted only if it actually contains the executable. If none match, launching a profile asks the user to locate it once and remembers the answer — a launch that silently does nothing gives them no way to work out what's wrong.
116 lines
4.5 KiB
Swift
116 lines
4.5 KiB
Swift
// Generates a placeholder AppIcon.iconset — a plain rounded-square
|
|
// background with the coat glyph on top. Deliberately not derived from
|
|
// Claude's own icon assets (unlike the old version of this file, which
|
|
// composited on top of Claude.app's icon) — this is a stand-in until a
|
|
// real icon is designed, so it draws everything itself from vector shapes.
|
|
//
|
|
// Usage:
|
|
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
|
// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns
|
|
// rm -rf icon/AppIcon.iconset
|
|
import Cocoa
|
|
|
|
// Renders at exact pixel dimensions via an explicit NSBitmapImageRep rather
|
|
// than NSImage.lockFocus() — lockFocus rasterizes at the *main screen's*
|
|
// backing scale factor, which would silently double every size on a Retina
|
|
// display instead of producing the exact px asked for.
|
|
func renderIcon(pixels: Int) -> Data {
|
|
let s = CGFloat(pixels)
|
|
guard let rep = NSBitmapImageRep(
|
|
bitmapDataPlanes: nil,
|
|
pixelsWide: pixels,
|
|
pixelsHigh: pixels,
|
|
bitsPerSample: 8,
|
|
samplesPerPixel: 4,
|
|
hasAlpha: true,
|
|
isPlanar: false,
|
|
colorSpaceName: .deviceRGB,
|
|
bytesPerRow: 0,
|
|
bitsPerPixel: 0
|
|
) else { fatalError("could not create bitmap rep") }
|
|
rep.size = NSSize(width: s, height: s)
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
let ctx = NSGraphicsContext(bitmapImageRep: rep)
|
|
NSGraphicsContext.current = ctx
|
|
ctx?.imageInterpolation = .high
|
|
|
|
// Base: a plain rounded-square background (macOS "squircle"-ish corner
|
|
// radius), a neutral slate color — no borrowed assets.
|
|
let bg = NSColor(calibratedRed: 0.30, green: 0.32, blue: 0.36, alpha: 1.0)
|
|
let bgPath = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s),
|
|
xRadius: s * 0.22, yRadius: s * 0.22)
|
|
bg.setFill()
|
|
bgPath.fill()
|
|
|
|
// Foreground: the same coat glyph as before — an original shape, not
|
|
// derived from any borrowed icon.
|
|
let coat = NSColor(calibratedRed: 0.62, green: 0.44, blue: 0.24, alpha: 1.0)
|
|
let coatShadow = NSColor(calibratedRed: 0.48, green: 0.33, blue: 0.16, alpha: 1.0)
|
|
let midX = s * 0.5
|
|
let topY = s * 0.62
|
|
let collarOutX = s * 0.34
|
|
let collarInX = s * 0.11
|
|
let shoulderY = s * 0.53
|
|
let waistY = s * 0.30
|
|
let waistHalf = s * 0.30
|
|
let hemY = s * 0.06
|
|
let hemHalf = s * 0.40
|
|
|
|
let path = NSBezierPath()
|
|
path.move(to: NSPoint(x: midX, y: topY))
|
|
path.line(to: NSPoint(x: midX - collarOutX, y: shoulderY))
|
|
path.line(to: NSPoint(x: midX - collarInX, y: shoulderY - s * 0.07))
|
|
path.line(to: NSPoint(x: midX - waistHalf, y: waistY))
|
|
path.line(to: NSPoint(x: midX - hemHalf, y: hemY))
|
|
path.line(to: NSPoint(x: midX + hemHalf, y: hemY))
|
|
path.line(to: NSPoint(x: midX + waistHalf, y: waistY))
|
|
path.line(to: NSPoint(x: midX + collarInX, y: shoulderY - s * 0.07))
|
|
path.line(to: NSPoint(x: midX + collarOutX, y: shoulderY))
|
|
path.close()
|
|
coat.setFill()
|
|
path.fill()
|
|
|
|
// A thin darker strip down the centerline reads as the coat's front
|
|
// seam/placket, breaking up the flat fill at larger sizes.
|
|
let seam = NSBezierPath()
|
|
seam.move(to: NSPoint(x: midX, y: topY))
|
|
seam.line(to: NSPoint(x: midX, y: hemY))
|
|
seam.lineWidth = max(s * 0.012, 1)
|
|
coatShadow.setStroke()
|
|
seam.stroke()
|
|
|
|
let buttonRadius = max(s * 0.02, 0.75)
|
|
for t: CGFloat in [0.20, 0.42, 0.64] {
|
|
let y = hemY + (shoulderY - hemY) * t
|
|
let r = NSRect(x: midX - buttonRadius, y: y - buttonRadius, width: buttonRadius * 2, height: buttonRadius * 2)
|
|
coatShadow.setFill()
|
|
NSBezierPath(ovalIn: r).fill()
|
|
}
|
|
|
|
ctx?.flushGraphics()
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
guard let png = rep.representation(using: .png, properties: [:]) else {
|
|
fatalError("failed to encode PNG at \(pixels)px")
|
|
}
|
|
return png
|
|
}
|
|
|
|
let sizes: [(name: String, px: Int)] = [
|
|
("icon_16x16", 16), ("icon_16x16@2x", 32),
|
|
("icon_32x32", 32), ("icon_32x32@2x", 64),
|
|
("icon_128x128", 128), ("icon_128x128@2x", 256),
|
|
("icon_256x256", 256), ("icon_256x256@2x", 512),
|
|
("icon_512x512", 512), ("icon_512x512@2x", 1024),
|
|
]
|
|
|
|
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset"
|
|
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
|
for (name, px) in sizes {
|
|
let data = renderIcon(pixels: px)
|
|
let path = "\(outDir)/\(name).png"
|
|
try? data.write(to: URL(fileURLWithPath: path))
|
|
print("wrote \(path)")
|
|
}
|