Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea208ced4 | ||
|
|
e803f008f0 | ||
|
|
58fc3444d5 | ||
|
|
150a2394dd | ||
|
|
e7777d5dfc | ||
|
|
cf7d9a4583 | ||
|
|
af2cae7fa9 |
@@ -28,7 +28,7 @@ jobs:
|
||||
# below treats as "fall back to ad-hoc".
|
||||
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
||||
files: |
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
created: ${{ steps.tag.outputs.created }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -45,5 +45,11 @@ jobs:
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
tag: ${{ needs.tag.outputs.tag }}
|
||||
# A called workflow gets no secrets unless they're handed over, so
|
||||
# without this the signing secrets are empty inside release.yml and it
|
||||
# quietly builds ad-hoc — which is exactly what signing exists to
|
||||
# prevent, and which its own ad-hoc guard misses, since that guard only
|
||||
# fires when an identity was configured.
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||

|
||||
|
||||
Run multiple isolated Claude profiles on macOS — each one its own Claude Desktop
|
||||
**and** its own paired Claude Code setup — and tell their windows apart at a
|
||||
glance.
|
||||
|
||||
Your normal Claude install is left completely alone. It stays exactly as it is
|
||||
and keeps working normally; shannoncoat only adds profiles alongside it.
|
||||
|
||||
A menu-bar item allows quick switching and profile management.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 13 or later
|
||||
- Claude Desktop
|
||||
|
||||
## Install
|
||||
|
||||
1. Download `shannoncoat.app.zip` from the
|
||||
[latest release](https://github.com/bdeshi/shannoncoat/releases/latest).
|
||||
2. Unzip it and move **shannoncoat.app** to `/Applications` or `~/Applications`.
|
||||
3. Open it. macOS will block the first launch, because the app isn't notarized.
|
||||
Go to **System Settings → Privacy & Security**, find the blocked item, and
|
||||
click **Open Anyway**.
|
||||
4. When prompted, grant **Accessibility**. The window tags are drawn from
|
||||
Accessibility data and won't appear without it.
|
||||
|
||||
## First run
|
||||
|
||||
With nothing running, shannoncoat offers a list of your profiles and starts
|
||||
whichever you tick. You can start several at once.
|
||||
|
||||
## Everyday use
|
||||
|
||||
The menu bar shows a coloured dot per running profile — dot and name when only
|
||||
one is up, dots alone when there are more (hover for the names).
|
||||
|
||||
Open the menu and:
|
||||
|
||||
- **Click a profile that isn't running** to switch to it. Every other running
|
||||
profile is quit first, so you end up with just that one.
|
||||
- **Click a profile that is already running** to bring its window forward,
|
||||
leaving everything else where it is.
|
||||
- **Shift-click a profile** to open it *alongside* whatever is already running.
|
||||
|
||||
A `⇆` marks the profile you last switched to. **Close Profile** quits one
|
||||
profile; **Quit Claude** quits all of them and leaves shannoncoat running.
|
||||
**Manage Profiles…** opens the main window.
|
||||
|
||||
Quitting Claude by hand, from the Dock, or via Force Quit is picked up within
|
||||
about a second — the menu bar always reflects what is actually running.
|
||||
|
||||
## Window tags
|
||||
|
||||
Each managed Claude window carries a small tag in that profile's colour, so two
|
||||
otherwise identical windows are easy to tell apart.
|
||||
|
||||
Drag a tag anywhere on its window; each profile remembers where you put its own.
|
||||
If you'd rather have something smaller, **Settings → Window tag → Colored dot**
|
||||
replaces the name chip with a plain dot.
|
||||
|
||||
## Profiles
|
||||
|
||||
**default** is your real, untouched Claude install. shannoncoat can start and
|
||||
stop it but never manages it, so it gets no tag and can't be deleted.
|
||||
|
||||
To add a custom isolated profile, open **Manage Profiles… → +** and enter a
|
||||
name. The directories are optional — leave them blank and the profile gets
|
||||
`~/.shannoncoat/data/<name>/app` and `~/.shannoncoat/data/<name>/code`. Point
|
||||
them anywhere you like if you'd rather keep a profile's data with a project.
|
||||
|
||||
Selecting a profile shows both paths, ready to copy.
|
||||
|
||||
Each profile is a small JSON file at `~/.shannoncoat/<name>.json`, named after
|
||||
the profile. Two profiles may not share a directory — that would silently merge
|
||||
their Claude sessions, so shannoncoat refuses to start and tells you which files
|
||||
collide.
|
||||
|
||||
Deleting a profile removes only that pointer file. **Its Claude data stays on
|
||||
disk**, so you can recreate the profile later and pick up where you left off. If
|
||||
it's running, it's quit first.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Launch at Login** — start shannoncoat automatically.
|
||||
- **Window tag** — name chip or coloured dot.
|
||||
- **Automatically check for updates** — once a day at most.
|
||||
|
||||
## Updates
|
||||
|
||||
shannoncoat compares its version against the one published on GitHub. The
|
||||
automatic check posts a quiet notification; **Check for Updates…** in Settings
|
||||
reports either way.
|
||||
|
||||
It never replaces itself. When a new version exists, download it from the
|
||||
releases page and swap the app in yourself.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**The window tags don't appear.** Almost always Accessibility. Check
|
||||
**System Settings → Privacy & Security → Accessibility**.
|
||||
|
||||
If shannoncoat is listed *and already ticked* but the tags still don't show,
|
||||
select it, remove it with **−**, then add it back. This happens after replacing
|
||||
the app with a build that has a different code signature; the old entry stays
|
||||
visible but no longer applies, and toggling it off and on won't fix it.
|
||||
|
||||
**Claude Desktop can't be found.** shannoncoat looks in `/Applications` and
|
||||
`~/Applications`. If yours is somewhere else, you'll be asked to locate it the
|
||||
first time you start a profile.
|
||||
|
||||
**A profile won't quit.** Claude was asked to quit and declined — usually its
|
||||
own "unsaved work" or "still generating" dialog. shannoncoat deliberately waits
|
||||
rather than killing Claude underneath it. Answer the dialog.
|
||||
|
||||
## Build from source
|
||||
|
||||
Requires the Xcode Command Line Tools.
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
The app is written to `.build/shannoncoat.app`; copy it to `/Applications` or
|
||||
`~/Applications`.
|
||||
|
||||
One thing worth setting up: an unsigned local build gets a new code identity
|
||||
every time, which silently invalidates its Accessibility grant on each rebuild.
|
||||
`.env.example` walks through creating a self-signed certificate once so the
|
||||
permission survives.
|
||||
|
||||
---
|
||||
|
||||
shannoncoat started as the shell script in the first commit, written by hand.
|
||||
Claude Code built the app out of it — most of the Swift, the icon, and this
|
||||
README — working from direction, review and testing.
|
||||
+35
-12
@@ -15,12 +15,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
private var accessibilityPromptShown = false
|
||||
private var accessibilityRecheckScheduled = false
|
||||
private var activationObserver: NSObjectProtocol?
|
||||
private var appearanceObserver: NSKeyValueObservation?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
menu.delegate = self
|
||||
menu.autoenablesItems = false
|
||||
statusItem.menu = menu
|
||||
statusItem.button?.image = menuBarIcon()
|
||||
statusItem.button?.image = MenuBarIcon.image()
|
||||
statusItem.button?.imagePosition = .imageLeft
|
||||
|
||||
liveState.delegate = self
|
||||
@@ -34,6 +35,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in self?.updateOverlayVisibility() }
|
||||
|
||||
// The glyph is a template image and re-tints itself, but the coloured
|
||||
// dots beside it don't: ProfileColor picks a brightness per current
|
||||
// appearance at the moment it's asked, and the title is only rebuilt
|
||||
// when profiles start or stop. Without this, switching to dark mode
|
||||
// leaves dots mixed for that appearance sitting in the menu bar until
|
||||
// something unrelated happens to redraw them.
|
||||
appearanceObserver = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.applyTitle(self.currentInfos)
|
||||
ManageWindowController.shared.update(self.currentInfos)
|
||||
}
|
||||
}
|
||||
|
||||
liveState.reconcile()
|
||||
|
||||
// Launching this app fresh with nothing running means there's no
|
||||
@@ -46,17 +62,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
UpdateChecker.checkAutomaticallyIfDue()
|
||||
}
|
||||
|
||||
// Claude's own app icon (bundled as Contents/Resources/AppIcon.icns —
|
||||
// currently a placeholder, see icon/generate-icon.swift), scaled down
|
||||
// for the menu bar.
|
||||
private func menuBarIcon() -> NSImage {
|
||||
let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
|
||||
let resized = NSImage(size: NSSize(width: 18, height: 18))
|
||||
resized.lockFocus()
|
||||
source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0)
|
||||
resized.unlockFocus()
|
||||
return resized
|
||||
}
|
||||
// The menu bar draws its own glyph (see MenuBarIcon) rather than the app
|
||||
// icon scaled down, which is what it used to do: at 18pt the app icon's
|
||||
// three coats collapse into a smudge, and its colours ignore the menu
|
||||
// bar's appearance entirely. MenuBarIcon returns a template image, so
|
||||
// macOS tints it for light/dark and for the open-menu inversion, and
|
||||
// re-tints it by itself when the theme changes.
|
||||
|
||||
// MARK: - LiveStateDelegate
|
||||
|
||||
@@ -349,7 +360,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
}
|
||||
}
|
||||
|
||||
// Switching means "make this the one that's running" — so it only has to
|
||||
// quit anything when the profile isn't up yet. Clicking one that's
|
||||
// already open is a request to look at it, not to tear down the window
|
||||
// beside it: the menu shows every running profile at once, so the click
|
||||
// that reaches for the second one is almost always someone moving
|
||||
// between two open windows rather than asking for one of them to go.
|
||||
// Quitting the neighbour there costs a relaunch and whatever was on
|
||||
// screen, to carry out an instruction nobody gave.
|
||||
private func switchTo(_ profile: ResolvedProfile) {
|
||||
guard !currentInfos.contains(where: { $0.profile.name == profile.name && $0.running }) else {
|
||||
openProfile(profile)
|
||||
return
|
||||
}
|
||||
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
|
||||
guard !others.isEmpty else { openProfile(profile); return }
|
||||
var remaining = others.count
|
||||
|
||||
@@ -640,7 +640,6 @@ private final class SettingsViewController: NSViewController {
|
||||
|
||||
// MARK: - About tab
|
||||
|
||||
private let projectAuthor = "bdeshi"
|
||||
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
|
||||
|
||||
private final class AboutViewController: NSViewController {
|
||||
@@ -668,19 +667,36 @@ private final class AboutViewController: NSViewController {
|
||||
icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
|
||||
])
|
||||
|
||||
// Centred under the icon, same wrapper trick as the icon itself.
|
||||
let tagline = NSTextField(labelWithString: "which coat will Claude wear today?")
|
||||
tagline.font = .systemFont(ofSize: NSFont.systemFontSize)
|
||||
tagline.textColor = .secondaryLabelColor
|
||||
tagline.translatesAutoresizingMaskIntoConstraints = false
|
||||
let taglineContainer = NSView()
|
||||
taglineContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
taglineContainer.addSubview(tagline)
|
||||
NSLayoutConstraint.activate([
|
||||
tagline.topAnchor.constraint(equalTo: taglineContainer.topAnchor),
|
||||
tagline.bottomAnchor.constraint(equalTo: taglineContainer.bottomAnchor),
|
||||
tagline.centerXAnchor.constraint(equalTo: taglineContainer.centerXAnchor),
|
||||
])
|
||||
|
||||
// Names itself rather than opening with a bare verb — this panel is
|
||||
// reachable without the window title in view.
|
||||
let description = NSTextField(wrappingLabelWithString:
|
||||
"Runs multiple Claude Desktop profiles side by side, each paired with its own isolated Claude Code config.")
|
||||
"shannoncoat runs multiple Claude Desktop profiles side by side, each paired with its own "
|
||||
+ "isolated Claude Code config.")
|
||||
description.font = .systemFont(ofSize: NSFont.systemFontSize)
|
||||
|
||||
let authorField = NSTextField(labelWithString: "Author: \(projectAuthor)")
|
||||
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
|
||||
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
|
||||
for field in [authorField, sourceField, versionField] {
|
||||
for field in [sourceField, versionField] {
|
||||
field.textColor = .secondaryLabelColor
|
||||
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
}
|
||||
|
||||
let stack = NSStackView(views: [iconContainer, description, authorField, sourceField, versionField])
|
||||
let stack = NSStackView(views: [iconContainer, taglineContainer, description,
|
||||
sourceField, versionField])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 10
|
||||
@@ -694,6 +710,7 @@ private final class AboutViewController: NSViewController {
|
||||
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
|
||||
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
|
||||
taglineContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
|
||||
])
|
||||
|
||||
view.layoutSubtreeIfNeeded()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// The menu bar's own glyph — Claude's asterisk over a coat's collar and
|
||||
// shoulders. Drawn here rather than scaled down from the app icon, which is
|
||||
// what the menu bar used to show: at 18pt the app icon's three coats, lapels
|
||||
// and pockets collapse into a smudge, and its colours fight the menu bar
|
||||
// instead of following it.
|
||||
//
|
||||
// Returned as a *template* image, which is what makes it track the system
|
||||
// theme with no code of ours involved. A template carries only an alpha
|
||||
// channel; macOS supplies the colour — dark on a light menu bar, light on a
|
||||
// dark one, inverted again while the menu is open — and re-tints it on the fly
|
||||
// when the theme switches. That also dictates the drawing: no fills of our
|
||||
// own, and any internal detail has to be a hole punched through the alpha.
|
||||
import AppKit
|
||||
|
||||
enum MenuBarIcon {
|
||||
// Six rays rather than the app icon's eight. At 18pt an eight-ray asterisk
|
||||
// has under a pixel of gap between arms, so they merge into a blob; six
|
||||
// resolve as separate arms. Lengths stay slightly uneven, as on the app
|
||||
// icon, so it doesn't read as a snowflake.
|
||||
private static let rayCount = 6
|
||||
private static let rayLengths: [CGFloat] = [1.0, 0.88, 0.97, 0.86, 1.0, 0.9]
|
||||
private static let rayRadius: CGFloat = 22
|
||||
private static let rayWidth: CGFloat = 7.2
|
||||
private static let asteriskCentre = NSPoint(x: 50, y: 28)
|
||||
|
||||
static func image(pointSize: CGFloat = 18) -> NSImage {
|
||||
let image = NSImage(size: NSSize(width: pointSize, height: pointSize))
|
||||
image.lockFocusFlipped(false)
|
||||
|
||||
// Authored y-down like the app icon; flip once rather than mirroring
|
||||
// every coordinate.
|
||||
let flip = NSAffineTransform()
|
||||
flip.translateX(by: 0, yBy: pointSize)
|
||||
flip.scaleX(by: pointSize / 100, yBy: -pointSize / 100)
|
||||
flip.concat()
|
||||
|
||||
NSColor.black.setStroke()
|
||||
asterisk().stroke()
|
||||
NSColor.black.setFill()
|
||||
coat().fill()
|
||||
|
||||
image.unlockFocus()
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
|
||||
private static func asterisk() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
for index in 0..<rayCount {
|
||||
let angle = (CGFloat(index) * 360 / CGFloat(rayCount) + 8) * .pi / 180
|
||||
let radius = rayRadius * rayLengths[index]
|
||||
path.move(to: asteriskCentre)
|
||||
path.line(to: NSPoint(x: asteriskCentre.x + radius * cos(angle),
|
||||
y: asteriskCentre.y + radius * sin(angle)))
|
||||
}
|
||||
path.lineWidth = rayWidth
|
||||
path.lineCapStyle = .round
|
||||
return path
|
||||
}
|
||||
|
||||
// Read along the top edge from one side to the other: shoulder, a slight
|
||||
// tilt up, a flat collar top, the valley at the neck, then the mirror of
|
||||
// that. Square sides and a flat hem below.
|
||||
//
|
||||
// The flat sections are 13 units wide because anything narrower stops
|
||||
// reading as flat at this size and just looks like part of the slope —
|
||||
// 13 units is roughly 2.3px at 18pt, about the floor for that.
|
||||
private static func coat() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
path.move(to: NSPoint(x: 15, y: 72)) // left shoulder
|
||||
path.line(to: NSPoint(x: 27, y: 60)) // slight tilt up
|
||||
path.line(to: NSPoint(x: 40, y: 60)) // flat collar top
|
||||
path.line(to: NSPoint(x: 50, y: 78)) // valley at the neck
|
||||
path.line(to: NSPoint(x: 60, y: 60))
|
||||
path.line(to: NSPoint(x: 73, y: 60))
|
||||
path.line(to: NSPoint(x: 85, y: 72)) // right shoulder
|
||||
path.line(to: NSPoint(x: 85, y: 94))
|
||||
path.line(to: NSPoint(x: 15, y: 94))
|
||||
path.close()
|
||||
return path
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
@@ -0,0 +1,83 @@
|
||||
// Generates docs/hero.png — the banner at the top of the README.
|
||||
//
|
||||
// Composes the already-generated app icon with the wordmark and tagline rather
|
||||
// than redrawing the coats, so the banner can never drift out of step with the
|
||||
// icon itself. Run generate-icon.swift first.
|
||||
//
|
||||
// Usage:
|
||||
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
||||
// cp icon/AppIcon.iconset/icon_512x512.png icon/shannoncoat.png
|
||||
// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns
|
||||
// rm -rf icon/AppIcon.iconset
|
||||
// swift icon/generate-hero.swift icon/shannoncoat.png docs/hero.png
|
||||
import Cocoa
|
||||
|
||||
private func rgb(_ hex: UInt32) -> NSColor {
|
||||
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
|
||||
blue: CGFloat(hex & 255) / 255, alpha: 1)
|
||||
}
|
||||
|
||||
// Slate ground rather than cloud: GitHub renders READMEs on both a light and a
|
||||
// dark page, and a dark banner sits comfortably on either, where a near-white
|
||||
// one glares against a dark page. It also lets the coral icon carry the colour.
|
||||
private let ground = rgb(0x26_26_25)
|
||||
private let wordmark = rgb(0xF0_EE_E6)
|
||||
private let subtitle = rgb(0xA8_A2_98)
|
||||
|
||||
private let width = 1200
|
||||
private let height = 360
|
||||
|
||||
let args = CommandLine.arguments
|
||||
let iconPath = args.count > 1 ? args[1] : "icon/shannoncoat.png"
|
||||
let outPath = args.count > 2 ? args[2] : "docs/hero.png"
|
||||
|
||||
guard let icon = NSImage(contentsOfFile: iconPath) else {
|
||||
fatalError("couldn't read \(iconPath) — run generate-icon.swift first")
|
||||
}
|
||||
|
||||
guard let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
|
||||
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: width, height: height)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
|
||||
ground.setFill()
|
||||
NSBezierPath(rect: NSRect(x: 0, y: 0, width: width, height: height)).fill()
|
||||
|
||||
let iconSide: CGFloat = 184
|
||||
icon.draw(in: NSRect(x: 96, y: (CGFloat(height) - iconSide) / 2, width: iconSide, height: iconSide))
|
||||
|
||||
let textX: CGFloat = 96 + iconSide + 56
|
||||
let name = NSAttributedString(string: "shannoncoat", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 76, weight: .semibold),
|
||||
.foregroundColor: wordmark,
|
||||
])
|
||||
let tagline = NSAttributedString(string: "which coat will Claude wear today?", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 30, weight: .regular),
|
||||
.foregroundColor: subtitle,
|
||||
])
|
||||
|
||||
// Stacked around the vertical centre, using each string's own measured height
|
||||
// so the pair sits optically centred against the icon rather than by guesswork.
|
||||
let nameSize = name.size(), taglineSize = tagline.size()
|
||||
let gap: CGFloat = 14
|
||||
let blockHeight = nameSize.height + gap + taglineSize.height
|
||||
let top = (CGFloat(height) + blockHeight) / 2
|
||||
name.draw(at: NSPoint(x: textX, y: top - nameSize.height))
|
||||
tagline.draw(at: NSPoint(x: textX + 3, y: top - nameSize.height - gap - taglineSize.height))
|
||||
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
let outURL = URL(fileURLWithPath: outPath)
|
||||
try? FileManager.default.createDirectory(
|
||||
at: outURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
guard let png = rep.representation(using: .png, properties: [:]) else {
|
||||
fatalError("failed to encode hero PNG")
|
||||
}
|
||||
try! png.write(to: outURL)
|
||||
print("wrote \(outPath)")
|
||||
+174
-75
@@ -1,8 +1,11 @@
|
||||
// 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.
|
||||
// Generates AppIcon.iconset — Claude's asterisk above a rack of overcoats, on
|
||||
// Claude's own coral. The wardrobe is the app in one picture: several coats to
|
||||
// pick between, one currently worn.
|
||||
//
|
||||
// Everything is drawn from vector paths here rather than exported from a
|
||||
// design tool, so a size can be tuned independently (small sizes need fatter
|
||||
// strokes than a straight downscale gives) and nothing binary but the derived
|
||||
// .icns is committed. Deliberately not derived from Claude's own icon assets.
|
||||
//
|
||||
// Usage:
|
||||
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
||||
@@ -10,85 +13,182 @@
|
||||
// rm -rf icon/AppIcon.iconset
|
||||
import Cocoa
|
||||
|
||||
// Claude's coral and cloud, plus tints of each for depth. The lapel is a
|
||||
// lighter grey than the coat and the back coats are darker corals — tone
|
||||
// separation survives downscaling, where outlines don't.
|
||||
private enum Palette {
|
||||
static func rgb(_ hex: UInt32) -> NSColor {
|
||||
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
|
||||
blue: CGFloat(hex & 255) / 255, alpha: 1)
|
||||
}
|
||||
static let tile = rgb(0xD9_77_57)
|
||||
static let coat = rgb(0xF0_EE_E6)
|
||||
static let lapel = rgb(0xB3_AA_97)
|
||||
static let pocket = rgb(0xB8_AF_9C)
|
||||
static let backLeft = rgb(0x8A_3E_2A)
|
||||
static let backLeftPocket = rgb(0x6E_35_24)
|
||||
static let backRight = rgb(0xB4_61_4A)
|
||||
static let backRightPocket = rgb(0x94_4A_34)
|
||||
}
|
||||
|
||||
private func p(_ x: CGFloat, _ y: CGFloat) -> NSPoint { NSPoint(x: x, y: y) }
|
||||
|
||||
// MARK: - Shapes, drawn in a 100x100 space with y pointing down
|
||||
|
||||
// Eight rays of slightly uneven length, a few degrees off the compass points —
|
||||
// a mechanically regular asterisk reads as a snowflake. Wider than tall, so it
|
||||
// doesn't look like it's standing on end.
|
||||
private func asterisk() -> NSBezierPath {
|
||||
let centre = p(50, 24)
|
||||
let tips = [p(65, 24), p(59, 33.3), p(49.5, 38.5), p(40.8, 32.6),
|
||||
p(35, 23.7), p(40.8, 14.5), p(50.2, 9.8), p(58.6, 15.1)]
|
||||
let path = NSBezierPath()
|
||||
for tip in tips { path.move(to: centre); path.line(to: tip) }
|
||||
path.lineWidth = 5.6
|
||||
path.lineCapStyle = .round
|
||||
return path
|
||||
}
|
||||
|
||||
// One coat in its own 40x44 box, so the three can be placed at different
|
||||
// scales and angles. The centre split is a true hole, so the tile shows through.
|
||||
private func coatBody() -> NSBezierPath {
|
||||
let b = NSBezierPath()
|
||||
b.move(to: p(16, 2))
|
||||
b.curve(to: p(3, 6.5), controlPoint1: p(12, 2.5), controlPoint2: p(6, 4))
|
||||
b.curve(to: p(2.5, 42), controlPoint1: p(1.5, 16), controlPoint2: p(1.5, 30))
|
||||
b.line(to: p(37.5, 42))
|
||||
b.curve(to: p(37, 6.5), controlPoint1: p(38.5, 30), controlPoint2: p(38.5, 16))
|
||||
b.curve(to: p(24, 2), controlPoint1: p(34, 4), controlPoint2: p(28, 2.5))
|
||||
b.line(to: p(20, 26))
|
||||
b.close()
|
||||
b.appendRect(NSRect(x: 19.15, y: 26, width: 1.7, height: 16))
|
||||
b.windingRule = .evenOdd
|
||||
return b
|
||||
}
|
||||
|
||||
// One connected band round the neck rather than two tabs on the shoulders,
|
||||
// dipping at centre where the lapel opening begins. Its top corners sit at
|
||||
// x15/x25 so the lapels cover them — any further out and they poke through as
|
||||
// spikes either side of the neck.
|
||||
private func coatCollar() -> NSBezierPath {
|
||||
let c = NSBezierPath()
|
||||
c.move(to: p(15, -1.6)); c.line(to: p(25, -1.6))
|
||||
c.line(to: p(29, 2.6)); c.line(to: p(21.6, 2.6))
|
||||
c.line(to: p(20, 1)); c.line(to: p(18.4, 2.6))
|
||||
c.line(to: p(11, 2.6))
|
||||
c.close()
|
||||
return c
|
||||
}
|
||||
|
||||
// Notch lapels, each one shape running from the collar's outer corner down to
|
||||
// the button point. The notch sits at (12.5, 8) — pulled down and outward
|
||||
// deliberately, because nearer the inner edge it pinches the polygon to a
|
||||
// hairline and the lapel renders as two separate triangles.
|
||||
private func coatLapels() -> NSBezierPath {
|
||||
let l = NSBezierPath()
|
||||
l.move(to: p(15, -1.6)); l.line(to: p(10.5, 3.5)); l.line(to: p(12.5, 8))
|
||||
l.line(to: p(9, 14)); l.line(to: p(20, 26)); l.close()
|
||||
l.move(to: p(25, -1.6)); l.line(to: p(29.5, 3.5)); l.line(to: p(27.5, 8))
|
||||
l.line(to: p(31, 14)); l.line(to: p(20, 26)); l.close()
|
||||
return l
|
||||
}
|
||||
|
||||
// Filled marks, not holes — a hole would show whichever coat is behind it.
|
||||
private func coatPockets() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
path.appendRect(NSRect(x: 6.5, y: 22, width: 9, height: 1.7))
|
||||
path.appendRect(NSRect(x: 24.5, y: 22, width: 9, height: 1.7))
|
||||
return path
|
||||
}
|
||||
|
||||
// A hairline along the lapel edges separating the grey lapel from the tile
|
||||
// showing through the opening. Deliberately fine: a detail for 128px and up.
|
||||
// Thickening it to survive smaller sizes only blurs the lapel edge, because at
|
||||
// this diagonal a one-pixel stroke covers a fraction of each pixel it crosses.
|
||||
private func coatOpeningBorder() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
path.move(to: p(15, -1.6)); path.line(to: p(20, 26)); path.line(to: p(25, -1.6))
|
||||
path.lineWidth = 0.7
|
||||
path.lineJoinStyle = .round
|
||||
path.lineCapStyle = .round
|
||||
return path
|
||||
}
|
||||
|
||||
private func placed(_ path: NSBezierPath, x: CGFloat, y: CGFloat,
|
||||
rotation: CGFloat, scale: CGFloat) -> NSBezierPath {
|
||||
let transform = NSAffineTransform()
|
||||
transform.translateX(by: x, yBy: y)
|
||||
if rotation != 0 { transform.rotate(byDegrees: rotation) }
|
||||
transform.scale(by: scale)
|
||||
let copy = path.copy() as! NSBezierPath
|
||||
copy.transform(using: transform as AffineTransform)
|
||||
return copy
|
||||
}
|
||||
|
||||
// Each coat is finished before the next is laid over it. Drawing all three
|
||||
// bodies and then all three sets of details would put the back coats' collars
|
||||
// and pockets on top of the front coat.
|
||||
private func drawCoat(x: CGFloat, y: CGFloat, rotation: CGFloat, scale: CGFloat,
|
||||
body: NSColor, pocket: NSColor, lapel: NSColor? = nil) {
|
||||
body.setFill()
|
||||
placed(coatBody(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
placed(coatCollar(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
if let lapel {
|
||||
lapel.setFill()
|
||||
placed(coatLapels(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
body.setStroke()
|
||||
let border = placed(coatOpeningBorder(), x: x, y: y, rotation: rotation, scale: scale)
|
||||
border.lineWidth = 0.7 * scale
|
||||
border.stroke()
|
||||
}
|
||||
pocket.setFill()
|
||||
placed(coatPockets(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
}
|
||||
|
||||
private func drawIcon() {
|
||||
Palette.tile.setFill()
|
||||
NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: 100, height: 100),
|
||||
xRadius: 22, yRadius: 22).fill()
|
||||
|
||||
Palette.coat.setStroke()
|
||||
asterisk().stroke()
|
||||
|
||||
drawCoat(x: 11, y: 50, rotation: -8, scale: 0.78,
|
||||
body: Palette.backLeft, pocket: Palette.backLeftPocket)
|
||||
drawCoat(x: 60, y: 47, rotation: 8, scale: 0.78,
|
||||
body: Palette.backRight, pocket: Palette.backRightPocket)
|
||||
drawCoat(x: 31.5, y: 44, rotation: 0, scale: 0.92,
|
||||
body: Palette.coat, pocket: Palette.pocket, lapel: Palette.lapel)
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
// 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)
|
||||
private func renderIcon(pixels: Int) -> Data {
|
||||
let side = 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
|
||||
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)
|
||||
rep.size = NSSize(width: side, height: side)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
let ctx = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current = ctx
|
||||
ctx?.imageInterpolation = .high
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current?.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()
|
||||
// The shapes are authored y-down, the way the drawing reads; flip once
|
||||
// here rather than mirroring every coordinate.
|
||||
let flip = NSAffineTransform()
|
||||
flip.translateX(by: 0, yBy: side)
|
||||
flip.scaleX(by: side / 100, yBy: -side / 100)
|
||||
flip.concat()
|
||||
|
||||
// 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
|
||||
drawIcon()
|
||||
|
||||
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 {
|
||||
@@ -108,8 +208,7 @@ let sizes: [(name: String, px: Int)] = [
|
||||
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))
|
||||
try? renderIcon(pixels: px).write(to: URL(fileURLWithPath: path))
|
||||
print("wrote \(path)")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user