10 Commits
Author SHA1 Message Date
bdeshi 654269a4d2 release 1.1.0 🎉 2026-08-14 23:14:58 +06:00
Claude Opus 5andbdeshi ffbd1536b6 Let the menu's click and shift-click actions be swapped
Clicking a profile switched to it exclusively and shift-click opened it
alongside, with no way round for anyone who opens alongside far more often
than they switch. Settings now carries the choice, storing only the plain
click's action — the other gesture gets whatever is left, so the two can't
both end up meaning the same thing, and no migration is needed for anyone
who never touches it.

Switching also quits the other profiles again when the profile clicked is
already up, which e7777d5 had carved out on the reasoning that a click
reaching for a second open window is someone moving between windows rather
than asking for one to go. That guess is what this setting now settles:
with each action bound to a gesture of its own, choosing the switch gesture
is the instruction, and second-guessing it left no way to ask for an
exclusive switch at all once the target was running.

The menu spells out both gestures rather than only the modified one, since
which of them quits things is no longer fixed. Each profile's tooltip
describes what a click will actually do to it as things stand — it promises
to quit the neighbours only when there are neighbours to quit.
2026-08-14 23:14:49 +06:00
bdeshiandGitHub 97b131070e Revise README for improved clarity and structure
Brings ai-authorship note above the fold, and adds a note about imperfect isolation guarantee.

Signed-off-by: bdeshi <bdeshi@bdeshi.space>
2026-08-06 01:06:12 +06:00
bdeshi 8ea208ced4 release 1.0.0 :party: 2026-08-06 00:35:15 +06:00
Claude Opus 5andbdeshi e803f008f0 Move the workflow actions off Node 20
checkout and action-gh-release both still target Node 20, which GitHub
has deprecated and is already force-running on Node 24. Nothing is broken
yet, but the runners won't accommodate it indefinitely, and a release
workflow is a poor place to discover that.
2026-08-06 00:35:02 +06:00
Claude Opus 5andbdeshi 58fc3444d5 0.0.10
Supersedes 0.0.9, whose published build went out ad-hoc signed because
the release workflow ran without the signing secrets. Anyone installing
it gets an app whose Accessibility grant is pinned to that one binary and
breaks on their next update, so it wants replacing rather than leaving as
the latest release.
2026-08-06 00:25:40 +06:00
Claude Opus 5andbdeshi 150a2394dd Hand the signing secrets to the called release workflow
A workflow invoked with `uses:` gets no secrets unless they are passed to
it, so every secret was empty inside release.yml when tag-on-version-bump
called it. The certificate import was skipped, the build came out ad-hoc,
and the release published that way — the precise outcome signing exists
to prevent.

Its own ad-hoc guard didn't catch it either: that guard only fires when
an identity was configured, and from the called workflow's point of view
none was. So the failure was silent in both places at once, and the run
went green.

Verified against the 0.0.9 build, whose log shows an empty
SIGNING_IDENTITY and Signature=adhoc on the published artifact.
2026-08-06 00:23:52 +06:00
Claude Opus 5andbdeshi e7777d5dfc Don't quit the neighbour when switching to an already-open profile
Clicking a profile quit every other running one first, unconditionally —
including when the profile clicked was already open. The menu lists every
running profile at once, so the click that reaches for the second one is
almost always someone moving between two open windows, and it cost them a
relaunch and whatever was on screen to carry out an instruction nobody
gave.

Switching now only quits anything when the target isn't up yet. An
already-running profile is simply brought forward, which is what
shift-click did and what the click on a running profile always looked
like it would do.
2026-08-06 00:01:46 +06:00
Claude Opus 5andbdeshi cf7d9a4583 Draw the real icon, and give the menu bar its own glyph
Replaces the placeholder coat silhouette with the app's actual subject:
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.

The menu bar stops scaling the app icon down and draws its own glyph
instead. At 18pt the icon's three coats, lapels and pockets collapse into
a smudge, and its colours ignore the menu bar entirely. The glyph is a
template image, so macOS tints it for light and dark and for the
open-menu inversion, and re-tints it itself when the theme changes. That
also drove its shape: six rays rather than eight, because at that size
eight have under a pixel between arms and merge into a blob.

The coloured dots beside it don't get that for free — ProfileColor picks
a brightness for the current appearance at the moment it's asked, and the
title is only rebuilt when profiles start or stop. Observing
effectiveAppearance rebuilds it, so a theme switch doesn't leave dots
mixed for the old appearance sitting in the menu bar.

Everything is still drawn from vector paths rather than exported from a
design tool, so each size can be tuned — small sizes need fatter strokes
than a straight downscale gives — and nothing binary but the derived
assets is committed. The README gains a hero banner, composed from the
generated icon so it can't drift out of step with it.

About gains the tagline under the icon, names itself in the description
rather than opening with a bare verb, and drops the author line.
2026-08-05 21:06:58 +06:00
Claude Opus 5andbdeshi af2cae7fa9 Add a README for end users
Everything a user needs to know has lived only in source comments and
commit messages until now: that a profile is an isolated Claude Desktop
plus its own paired Claude Code setup, that the normal install is left
untouched, that Accessibility is what draws the window tags, and that an
unnotarized download needs Open Anyway rather than the Control-click most
people reach for.

Deliberately end-user only. Building from source gets one short section
pointing at .env.example, not a developer guide, and there are no
screenshots yet — the app icon is still a placeholder, so anything shown
now would need replacing immediately.

Also bumps the version to 0.0.9. Nothing has been published yet, so the
numbering stays below 1.0.0 until a release has been tested for real.
2026-08-05 15:06:30 +06:00
12 changed files with 682 additions and 115 deletions
+2 -2
View File
@@ -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: |
+7 -1
View File
@@ -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
+156
View File
@@ -0,0 +1,156 @@
![shannoncoat — which coat will Claude wear today?](docs/hero.png)
Run multiple isolated [**\***](#isolation) 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 alone. It stays as it is and keeps working
normally; shannoncoat only adds profiles alongside it.
A menu-bar item allows quick switching and profile management.
---
> [!NOTE]
> shannoncoat started as the shell script in the first commit, written by hand.
Claude built the GUI app out of it — most of the Swift, the icon, and this
README — working from human direction, review, and dogfooding.
## 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** to switch to it. Every other running profile is quit
first, so you end up with just that one — including when the profile you
clicked is already up, in which case it's the neighbours that go.
- **Shift-click a profile** to open it *alongside* whatever is already running,
or to bring its window forward if it's already up, leaving everything else
where it is.
If you open alongside more often than you switch, **Settings → Click a profile
to** swaps the two around.
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.
- **Click a profile to** — whether plain click switches and Shift-click opens
alongside, or the reverse.
- **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
Local builds require the Xcode Command Line Tools.
```
xcode-select --install
```
Then run the build script
```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.
## Isolation
> [!IMPORTANT]
> Perfect isolation is not guaranteed. Claude and Claude Code can have functionally
> full disk access regardless of which profile it's running from, including access
> to default configuration folders.
>
> Some Claude integrations — skills, plugins, etc — may also hardcode the
> `~/.claude` path which may modify content in there.
+112 -23
View File
@@ -4,6 +4,43 @@
import AppKit
import ApplicationServices
// What clicking a profile in the menu does. The two actions are always both
// reachable whichever one isn't on the plain click is on Shift-click so
// this is a swap, not an on/off.
enum MenuClickAction: String {
case switchTo
case openAlongside
// Names the action generically, for the menu's two hint lines the
// per-profile tooltips say what it does to that profile as things
// actually stand (see AppDelegate.outcome).
var phrase: String {
switch self {
case .switchTo: return "switch (quits the others)"
case .openAlongside: return "open alongside"
}
}
}
// One setting for the whole app, stored as the *plain* click's action; the
// Shift-click action is derived, which keeps the two from ever being set to
// the same thing.
enum MenuClickBehavior {
private static let plainClickKey = "MenuPlainClickAction"
static var plainClick: MenuClickAction {
get {
UserDefaults.standard.string(forKey: plainClickKey)
.flatMap(MenuClickAction.init(rawValue:)) ?? .switchTo
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: plainClickKey) }
}
static var shiftClick: MenuClickAction {
plainClick == .switchTo ? .openAlongside : .switchTo
}
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let menu = NSMenu()
@@ -15,12 +52,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 +72,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 +99,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
@@ -228,15 +276,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
menu.addItem(header)
menu.addItem(.separator())
let hint = NSMenuItem(title: "\u{21e7}-click to open alongside", action: nil, keyEquivalent: "")
hint.isEnabled = false
menu.addItem(hint)
// Read once per rebuild rather than per item. The menu is rebuilt
// from scratch on every open, so changing the setting is reflected
// the next time the menu is shown without anything having to tell
// the delegate about it.
let plainClick = MenuClickBehavior.plainClick
let shiftClick = MenuClickBehavior.shiftClick
// Both gestures are spelled out, not just the modified one: which is
// which is now a setting, so a single line about Shift would leave the
// plain click to be assumed and it's the plain click that quits
// things when it's the one bound to switching.
for (gesture, action) in [("Click", plainClick), ("\u{21e7}-click", shiftClick)] {
let hint = NSMenuItem(title: "\(gesture) to \(action.phrase)", action: nil, keyEquivalent: "")
hint.isEnabled = false
menu.addItem(hint)
}
menu.addItem(.separator())
for info in profiles {
// Click = exclusive switch (quits every other running profile).
// Shift-click = open alongside instead, without disturbing
// anything else that's running.
// One click switches (quitting every other running profile), the
// other opens alongside without disturbing what's running which
// is on the plain click and which is on Shift is the user's
// choice, see MenuClickBehavior.
let title = info.profile.name == liveState.lastActiveName
? "\(info.profile.name) \u{21c6}" : info.profile.name
let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "")
@@ -244,7 +306,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
item.representedObject = info.profile.name
item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
item.state = info.running ? .on : .off
item.toolTip = info.running ? "Running — click to focus" : "Click to switch • Shift-click to open alongside"
item.toolTip = "Click to \(outcome(of: plainClick, for: info))"
+ "\u{21e7}-click to \(outcome(of: shiftClick, for: info))"
menu.addItem(item)
}
@@ -288,6 +351,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
menu.addItem(quitSelfItem)
}
// What a gesture will actually do to THIS profile as things stand the
// same action reads differently depending on what's up. Switching only
// quits neighbours when there are neighbours to quit, and opening
// alongside is only "alongside" something when something else is running.
private func outcome(of action: MenuClickAction, for info: ProfileInfo) -> String {
let others = currentInfos.filter { $0.running && $0.profile.name != info.profile.name }.count
let reach = info.running ? "focus it" : "start it"
switch action {
case .openAlongside:
return others == 0 ? reach : "\(reach), leaving the others running"
case .switchTo:
guard others > 0 else { return reach }
return "\(reach), quitting the other \(others == 1 ? "profile" : "\(others) profiles")"
}
}
// Shown once at launch when nothing is running. A checklist rather
// than a single pick: unlike the menu's click/Shift-click (one
// profile, exclusive vs. alongside), there's nothing running yet to be
@@ -331,10 +410,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
let profile = currentInfos.first(where: { $0.profile.name == name })?.profile
else { return }
liveState.markActive(name)
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
openProfile(profile)
} else {
switchTo(profile)
let shiftHeld = NSApp.currentEvent?.modifierFlags.contains(.shift) == true
switch shiftHeld ? MenuClickBehavior.shiftClick : MenuClickBehavior.plainClick {
case .openAlongside: openProfile(profile)
case .switchTo: switchTo(profile)
}
}
@@ -349,6 +428,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
}
}
// Switching means "make this the one that's running", so it quits the
// others whether or not this profile is already up.
//
// It used to bail out early for an already-running profile, on the
// reasoning that a click reaching for a second open window is someone
// moving between windows rather than asking for one to go. That guess is
// exactly what MenuClickBehavior now settles: both actions have a gesture
// of their own, so choosing the switch gesture *is* the instruction, and
// second-guessing it left no way to ask for an exclusive switch at all
// once the target was up.
private func switchTo(_ profile: ResolvedProfile) {
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
guard !others.isEmpty else { openProfile(profile); return }
+65 -13
View File
@@ -512,6 +512,9 @@ private final class SettingsViewController: NSViewController {
private let tagStyleLabel = NSTextField(labelWithString: "Window tag:")
private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var tagStyleRow: NSStackView!
private let clickActionLabel = NSTextField(labelWithString: "Click a profile to:")
private let clickActionPopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var clickActionRow: NSStackView!
private var stack: NSStackView!
var onOverlayStyleChanged: (() -> Void)?
@@ -523,6 +526,14 @@ private final class SettingsViewController: NSViewController {
("Colored dot", .dot),
]
// Same index-to-value arrangement. Each title spells out the Shift-click
// half too: the two actions always swap together, so showing only the
// plain click's half would leave the other looking unset.
private static let clickActions: [(title: String, action: MenuClickAction)] = [
("Switch to it (\u{21e7}: open alongside)", .switchTo),
("Open it alongside (\u{21e7}: switch to it)", .openAlongside),
]
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
@@ -535,6 +546,16 @@ private final class SettingsViewController: NSViewController {
tagStyleRow.orientation = .horizontal
tagStyleRow.spacing = 8
clickActionPopUp.addItems(withTitles: Self.clickActions.map(\.title))
clickActionPopUp.selectItem(
at: Self.clickActions.firstIndex { $0.action == MenuClickBehavior.plainClick } ?? 0)
clickActionPopUp.target = self
clickActionPopUp.action = #selector(changeClickAction)
clickActionRow = NSStackView(views: [clickActionLabel, clickActionPopUp])
clickActionRow.orientation = .horizontal
clickActionRow.spacing = 8
launchAtLoginCheckbox.target = self
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
@@ -547,7 +568,9 @@ private final class SettingsViewController: NSViewController {
checkNowButton.action = #selector(checkNow)
checkNowButton.bezelStyle = .rounded
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
stack = NSStackView(views: [
launchAtLoginCheckbox, clickActionRow, tagStyleRow, autoUpdateCheckbox, checkNowButton,
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 14
@@ -578,14 +601,18 @@ private final class SettingsViewController: NSViewController {
// is what AutoLayout itself uses to size an unconstrained control
// sidesteps whatever the stack's own aggregation is getting wrong.
private func updatePreferredSize() {
// Same reasoning for the tag-style row: summed from its two
// controls plus the stack spacing rather than trusting the row's
// Same reasoning for the label+popup rows: summed from their two
// controls plus the row spacing rather than trusting the row's
// own fittingSize.
let rowWidth = tagStyleLabel.intrinsicContentSize.width
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
.map(\.intrinsicContentSize.width)
.max().map { max($0, rowWidth) } ?? rowWidth
let rowWidths = [
tagStyleLabel.intrinsicContentSize.width
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing,
clickActionLabel.intrinsicContentSize.width
+ clickActionPopUp.intrinsicContentSize.width + clickActionRow.spacing,
]
let widest = ([launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
.map(\.intrinsicContentSize.width) + rowWidths)
.max() ?? 0
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
}
@@ -596,6 +623,14 @@ private final class SettingsViewController: NSViewController {
onOverlayStyleChanged?()
}
// Nothing to notify: the menu bar rebuilds its menu on every open and
// reads the setting then.
@objc private func changeClickAction() {
let index = clickActionPopUp.indexOfSelectedItem
guard Self.clickActions.indices.contains(index) else { return }
MenuClickBehavior.plainClick = Self.clickActions[index].action
}
@objc private func toggleLaunchAtLogin() {
do {
if launchAtLoginCheckbox.state == .on {
@@ -640,7 +675,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 +702,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 +745,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()
+82
View File
@@ -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
}
}
+1 -1
View File
@@ -1 +1 @@
0.0.8
1.1.0
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
View File
Binary file not shown.
+83
View File
@@ -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
View File
@@ -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