Rewrite shannoncoat as a native Swift menu-bar app
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.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// 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)")
|
||||
}
|
||||
Reference in New Issue
Block a user