Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
654269a4d2
|
||
|
|
ffbd1536b6
|
||
|
|
97b131070e | ||
|
|
8ea208ced4 | ||
|
|
e803f008f0 | ||
|
|
58fc3444d5 | ||
|
|
150a2394dd |
@@ -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
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||

|
||||
|
||||
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.
|
||||
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 completely alone. It stays exactly as it is
|
||||
and keeps working normally; shannoncoat only adds profiles alongside it.
|
||||
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
|
||||
@@ -37,11 +43,15 @@ 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.
|
||||
- **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.
|
||||
@@ -83,6 +93,8 @@ 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.
|
||||
|
||||
@@ -115,8 +127,12 @@ rather than killing Claude underneath it. Answer the dialog.
|
||||
|
||||
## Build from source
|
||||
|
||||
Requires the Xcode Command Line Tools.
|
||||
Local builds require the Xcode Command Line Tools.
|
||||
```
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
Then run the build script
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
@@ -129,8 +145,12 @@ 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
|
||||
|
||||
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.
|
||||
> [!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.
|
||||
|
||||
+89
-23
@@ -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()
|
||||
@@ -239,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: "")
|
||||
@@ -255,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)
|
||||
}
|
||||
|
||||
@@ -299,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
|
||||
@@ -342,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,19 +428,17 @@ 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.
|
||||
// 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) {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user